diff --git a/.coveragerc b/.coveragerc index a335557d4f1..d39d3d5f02b 100644 --- a/.coveragerc +++ b/.coveragerc @@ -29,3 +29,6 @@ exclude_lines = ^\s*if TYPE_CHECKING: ^\s*@overload( |$) + ^\s*def .+: \.\.\.$ + + ^\s*@pytest\.mark\.xfail diff --git a/.gitblameignore b/.git-blame-ignore-revs similarity index 66% rename from .gitblameignore rename to .git-blame-ignore-revs index 0cb298b024d..4f4cdb6c564 100644 --- a/.gitblameignore +++ b/.git-blame-ignore-revs @@ -5,7 +5,7 @@ # # To "install" it: # -# git config --local blame.ignoreRevsFile .gitblameignore +# git config --local blame.ignoreRevsFile .git-blame-ignore-revs # run black 703e4b11ba76171eccd3f13e723c47b810ded7ef @@ -23,6 +23,13 @@ afc607cfd81458d4e4f3b1f3cf8cc931b933907e 5f95dce95602921a70bfbc7d8de2f7712c5e4505 # ran pyupgrade-docs again 75d0b899bbb56d6849e9d69d83a9426ed3f43f8b - # move argument parser to own file c9df77cbd6a365dcb73c39618e4842711817e871 +# Replace reorder-python-imports by isort due to black incompatibility (#11896) +8b54596639f41dfac070030ef20394b9001fe63c +# Run blacken-docs with black's 2024's style +4546d5445aaefe6a03957db028c263521dfb5c4b +# Migration to ruff / ruff format +4588653b2497ed25976b7aaff225b889fb476756 +# Use format specifiers instead of percent format +4788165e69d08e10fc6b9c0124083fb358e2e9b0 diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 5f2d1cf09c8..88049407b45 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,5 +1,7 @@ # info: # * https://help.github.com/en/articles/displaying-a-sponsor-button-in-your-repository # * https://tidelift.com/subscription/how-to-connect-tidelift-with-github +github: pytest-dev tidelift: pypi/pytest open_collective: pytest +thanks_dev: u/gh/pytest-dev diff --git a/.github/chronographer.yml b/.github/chronographer.yml new file mode 100644 index 00000000000..803db1e3417 --- /dev/null +++ b/.github/chronographer.yml @@ -0,0 +1,20 @@ +--- + +branch-protection-check-name: Changelog entry +action-hints: + check-title-prefix: "Chronographer: " + external-docs-url: >- + https://docs.pytest.org/en/latest/contributing.html#preparing-pull-requests + inline-markdown: >- + See + https://docs.pytest.org/en/latest/contributing.html#preparing-pull-requests + for details. +enforce-name: + suffix: .rst +exclude: + humans: + - pyup-bot +labels: + skip-changelog: skip news + +... diff --git a/.github/patchback.yml b/.github/patchback.yml new file mode 100644 index 00000000000..5d62fca12fe --- /dev/null +++ b/.github/patchback.yml @@ -0,0 +1,7 @@ +--- + +backport_branch_prefix: patchback/backports/ +backport_label_prefix: 'backport ' # IMPORTANT: the labels are space-delimited +# target_branch_prefix: '' # The project's backport branches are non-prefixed + +... diff --git a/.github/workflows/backport.yml b/.github/workflows/backport.yml deleted file mode 100644 index aa90b51e273..00000000000 --- a/.github/workflows/backport.yml +++ /dev/null @@ -1,51 +0,0 @@ -name: backport - -on: - # Note that `pull_request_target` has security implications: - # https://securitylab.github.com/research/github-actions-preventing-pwn-requests/ - # In particular: - # - Only allow triggers that can be used only be trusted users - # - Don't execute any code from the target branch - # - Don't use cache - pull_request_target: - types: [labeled] - -# Set permissions at the job level. -permissions: {} - -jobs: - backport: - if: startsWith(github.event.label.name, 'backport ') && github.event.pull_request.merged - runs-on: ubuntu-latest - permissions: - contents: write - pull-requests: write - - steps: - - uses: actions/checkout@v3 - with: - fetch-depth: 0 - persist-credentials: true - - - name: Create backport PR - run: | - set -eux - - git config --global user.name "pytest bot" - git config --global user.email "pytestbot@gmail.com" - - label='${{ github.event.label.name }}' - target_branch="${label#backport }" - backport_branch=backport-${{ github.event.number }}-to-"${target_branch}" - subject="[$target_branch] $(gh pr view --json title -q .title ${{ github.event.number }})" - - git checkout origin/"${target_branch}" -b "${backport_branch}" - git cherry-pick -x --mainline 1 ${{ github.event.pull_request.merge_commit_sha }} - git commit --amend --message "$subject" - git push --set-upstream origin --force-with-lease "${backport_branch}" - gh pr create \ - --base "${target_branch}" \ - --title "${subject}" \ - --body "Backport of PR #${{ github.event.number }} to $target_branch branch. PR created by backport workflow." - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 25280994687..ba593c7c3a9 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,60 +1,116 @@ name: deploy on: - push: - tags: - # These tags are protected, see: - # https://github.com/pytest-dev/pytest/settings/tag_protection - - "[0-9]+.[0-9]+.[0-9]+" - - "[0-9]+.[0-9]+.[0-9]+rc[0-9]+" + workflow_dispatch: + inputs: + version: + description: 'Release version' + required: true + default: '1.2.3' # Set permissions at the job level. permissions: {} jobs: - - deploy: - if: github.repository == 'pytest-dev/pytest' - + package: runs-on: ubuntu-latest - timeout-minutes: 30 + env: + SETUPTOOLS_SCM_PRETEND_VERSION: ${{ github.event.inputs.version }} + timeout-minutes: 10 + + # Required by attest-build-provenance-github. permissions: - contents: write + id-token: write + attestations: write steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: fetch-depth: 0 persist-credentials: false - name: Build and Check Package - uses: hynek/build-and-inspect-python-package@v1.5 + uses: hynek/build-and-inspect-python-package@b5076c307dc91924a82ad150cdd1533b444d3310 + with: + attest-build-provenance-github: 'true' + + deploy: + if: github.repository == 'pytest-dev/pytest' + needs: [package] + runs-on: ubuntu-latest + environment: deploy + timeout-minutes: 30 + permissions: + id-token: write + contents: write + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: true - name: Download Package - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 with: name: Packages path: dist - name: Publish package to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 + uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc + with: + attestations: true + + - name: Push tag + env: + VERSION: ${{ github.event.inputs.version }} + run: | + git config user.name "pytest bot" + git config user.email "pytestbot@gmail.com" + git tag --annotate --message=v"$VERSION" "$VERSION" ${{ github.sha }} + git push origin "$VERSION" + + release-notes: + + # todo: generate the content in the build job + # the goal being of using a github action script to push the release data + # after success instead of creating a complete python/tox env + needs: [deploy] + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: write + steps: + - uses: actions/checkout@v4 with: - password: ${{ secrets.pypi_token }} + fetch-depth: 0 + persist-credentials: false + + - name: Download Package + uses: actions/download-artifact@v4 + with: + name: Packages + path: dist - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: - python-version: "3.7" + python-version: "3.11" - name: Install tox run: | python -m pip install --upgrade pip pip install --upgrade tox - - name: Publish GitHub release notes + - name: Generate release notes env: - GH_RELEASE_NOTES_TOKEN: ${{ github.token }} + VERSION: ${{ github.event.inputs.version }} run: | sudo apt-get install pandoc - tox -e publish-gh-release-notes + tox -e generate-gh-release-notes -- "$VERSION" scripts/latest-release-notes.md + + - name: Publish GitHub Release + env: + VERSION: ${{ github.event.inputs.version }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release create --notes-file scripts/latest-release-notes.md --verify-tag "$VERSION" dist/* diff --git a/.github/workflows/prepare-release-pr.yml b/.github/workflows/prepare-release-pr.yml index 76bf14d7e56..b21ca70cb46 100644 --- a/.github/workflows/prepare-release-pr.yml +++ b/.github/workflows/prepare-release-pr.yml @@ -27,14 +27,16 @@ jobs: pull-requests: write steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: fetch-depth: 0 + # persist-credentials is needed in order for us to push the release branch. + persist-credentials: true - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: - python-version: "3.8" + python-version: "3.x" - name: Install dependencies run: | @@ -43,10 +45,18 @@ jobs: - name: Prepare release PR (minor/patch release) if: github.event.inputs.major == 'no' + env: + BRANCH: ${{ github.event.inputs.branch }} + PRERELEASE: ${{ github.event.inputs.prerelease }} + GH_TOKEN: ${{ github.token }} run: | - tox -e prepare-release-pr -- ${{ github.event.inputs.branch }} ${{ github.token }} --prerelease='${{ github.event.inputs.prerelease }}' + tox -e prepare-release-pr -- "$BRANCH" --prerelease="$PRERELEASE" - name: Prepare release PR (major release) if: github.event.inputs.major == 'yes' + env: + BRANCH: ${{ github.event.inputs.branch }} + PRERELEASE: ${{ github.event.inputs.prerelease }} + GH_TOKEN: ${{ github.token }} run: | - tox -e prepare-release-pr -- ${{ github.event.inputs.branch }} ${{ github.token }} --major --prerelease='${{ github.event.inputs.prerelease }}' + tox -e prepare-release-pr -- "$BRANCH" --major --prerelease="$PRERELEASE" diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 3f8ca186f0e..82f9a1f2579 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -10,14 +10,14 @@ jobs: permissions: issues: write steps: - - uses: actions/stale@v8 + - uses: actions/stale@v9 with: debug-only: false days-before-issue-stale: 14 days-before-issue-close: 7 only-labels: "status: needs information" stale-issue-label: "stale" - stale-issue-message: "This issue is stale because it has been open for 14 days with no activity." - close-issue-message: "This issue was closed because it has been inactive for 7 days since being marked as stale." + stale-issue-message: "This issue is stale because it has the `status: needs information` label and requested follow-up information was not provided for 14 days." + close-issue-message: "This issue was closed because it has the `status: needs information` label and follow-up information has not been provided for 7 days since being marked as stale." days-before-pr-stale: -1 days-before-pr-close: -1 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index cf5027223e1..7873b66a49a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,6 +14,11 @@ on: branches: - main - "[0-9]+.[0-9]+.x" + types: + - opened # default + - synchronize # default + - reopened # default + - ready_for_review # used in PRs created from the release workflow env: PYTEST_ADDOPTS: "--color=yes" @@ -27,7 +32,19 @@ concurrency: permissions: {} jobs: + package: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false + - name: Build and Check Package + uses: hynek/build-and-inspect-python-package@b5076c307dc91924a82ad150cdd1533b444d3310 + build: + needs: [package] + runs-on: ${{ matrix.os }} timeout-minutes: 45 permissions: @@ -37,149 +54,247 @@ jobs: fail-fast: false matrix: name: [ - "windows-py37", - "windows-py37-pluggy", - "windows-py38", - "windows-py39", + "windows-py39-unittest-asynctest", + "windows-py39-unittest-twisted24", + "windows-py39-unittest-twisted25", + "windows-py39-pluggy", + "windows-py39-xdist", "windows-py310", "windows-py311", "windows-py312", + "windows-py313", + "windows-py314", - "ubuntu-py37", - "ubuntu-py37-pluggy", - "ubuntu-py37-freeze", - "ubuntu-py38", - "ubuntu-py39", - "ubuntu-py310", + "ubuntu-py39-unittest-asynctest", + "ubuntu-py39-unittest-twisted24", + "ubuntu-py39-unittest-twisted25", + "ubuntu-py39-lsof-numpy-pexpect", + "ubuntu-py39-pluggy", + "ubuntu-py39-freeze", + "ubuntu-py39-xdist", + "ubuntu-py310-xdist", "ubuntu-py311", "ubuntu-py312", - "ubuntu-pypy3", + "ubuntu-py313-pexpect", + "ubuntu-py314", + "ubuntu-pypy3-xdist", - "macos-py37", "macos-py39", "macos-py310", "macos-py312", + "macos-py313", + "macos-py314", - "docs", "doctesting", "plugins", ] include: - - name: "windows-py37" - python: "3.7" + # Use separate jobs for different unittest flavors (twisted, asynctest) to ensure proper coverage. + - name: "windows-py39-unittest-asynctest" + python: "3.9" os: windows-latest - tox_env: "py37-numpy" - - name: "windows-py37-pluggy" - python: "3.7" + tox_env: "py39-asynctest" + use_coverage: true + + - name: "windows-py39-unittest-twisted24" + python: "3.9" os: windows-latest - tox_env: "py37-pluggymain-pylib-xdist" - - name: "windows-py38" - python: "3.8" + tox_env: "py39-twisted24" + use_coverage: true + + - name: "windows-py39-unittest-twisted25" + python: "3.9" os: windows-latest - tox_env: "py38-unittestextras" + tox_env: "py39-twisted25" use_coverage: true - - name: "windows-py39" + + - name: "windows-py39-pluggy" + python: "3.9" + os: windows-latest + tox_env: "py39-pluggymain-pylib-xdist" + + - name: "windows-py39-xdist" python: "3.9" os: windows-latest tox_env: "py39-xdist" + - name: "windows-py310" python: "3.10" os: windows-latest tox_env: "py310-xdist" + - name: "windows-py311" python: "3.11" os: windows-latest tox_env: "py311" + - name: "windows-py312" - python: "3.12-dev" + python: "3.12" os: windows-latest tox_env: "py312" - - name: "ubuntu-py37" - python: "3.7" + - name: "windows-py313" + python: "3.13" + os: windows-latest + tox_env: "py313" + + - name: "windows-py314" + python: "3.14" + os: windows-latest + tox_env: "py314" + + # Use separate jobs for different unittest flavors (twisted, asynctest) to ensure proper coverage. + - name: "ubuntu-py39-unittest-asynctest" + python: "3.9" os: ubuntu-latest - tox_env: "py37-lsof-numpy-pexpect" + tox_env: "py39-asynctest" use_coverage: true - - name: "ubuntu-py37-pluggy" - python: "3.7" + + - name: "ubuntu-py39-unittest-twisted24" + python: "3.9" os: ubuntu-latest - tox_env: "py37-pluggymain-pylib-xdist" - - name: "ubuntu-py37-freeze" - python: "3.7" + tox_env: "py39-twisted24" + use_coverage: true + + - name: "ubuntu-py39-unittest-twisted25" + python: "3.9" os: ubuntu-latest - tox_env: "py37-freeze" - - name: "ubuntu-py38" - python: "3.8" + tox_env: "py39-twisted25" + use_coverage: true + + - name: "ubuntu-py39-lsof-numpy-pexpect" + python: "3.9" os: ubuntu-latest - tox_env: "py38-xdist" - - name: "ubuntu-py39" + tox_env: "py39-lsof-numpy-pexpect" + use_coverage: true + + - name: "ubuntu-py39-pluggy" + python: "3.9" + os: ubuntu-latest + tox_env: "py39-pluggymain-pylib-xdist" + + - name: "ubuntu-py39-freeze" + python: "3.9" + os: ubuntu-latest + tox_env: "py39-freeze" + + - name: "ubuntu-py39-xdist" python: "3.9" os: ubuntu-latest tox_env: "py39-xdist" - - name: "ubuntu-py310" + + - name: "ubuntu-py310-xdist" python: "3.10" os: ubuntu-latest tox_env: "py310-xdist" + - name: "ubuntu-py311" python: "3.11" os: ubuntu-latest tox_env: "py311" use_coverage: true + - name: "ubuntu-py312" - python: "3.12-dev" + python: "3.12" os: ubuntu-latest tox_env: "py312" use_coverage: true - - name: "ubuntu-pypy3" - python: "pypy-3.7" + + - name: "ubuntu-py313-pexpect" + python: "3.13" + os: ubuntu-latest + tox_env: "py313-pexpect" + use_coverage: true + + - name: "ubuntu-py314" + python: "3.14" + os: ubuntu-latest + tox_env: "py314" + use_coverage: true + + - name: "ubuntu-pypy3-xdist" + python: "pypy-3.9" os: ubuntu-latest tox_env: "pypy3-xdist" - - name: "macos-py37" - python: "3.7" - os: macos-latest - tox_env: "py37-xdist" + - name: "macos-py39" python: "3.9" os: macos-latest tox_env: "py39-xdist" use_coverage: true + - name: "macos-py310" python: "3.10" os: macos-latest tox_env: "py310-xdist" + - name: "macos-py312" - python: "3.12-dev" + python: "3.12" os: macos-latest tox_env: "py312-xdist" + - name: "macos-py313" + python: "3.13" + os: macos-latest + tox_env: "py313-xdist" + + - name: "macos-py314" + python: "3.14" + os: macos-latest + tox_env: "py314-xdist" + - name: "plugins" - python: "3.9" + python: "3.12" os: ubuntu-latest tox_env: "plugins" - - name: "docs" - python: "3.7" - os: ubuntu-latest - tox_env: "docs" + - name: "doctesting" - python: "3.7" + python: "3.9" os: ubuntu-latest tox_env: "doctesting" use_coverage: true + continue-on-error: >- + ${{ + contains( + fromJSON( + '[ + "windows-py39-pluggy", + "windows-py313", + "ubuntu-py39-pluggy", + "ubuntu-py39-freeze", + "ubuntu-py313", + "macos-py39", + "macos-py313" + ]' + ), + matrix.name + ) + && true + || false + }} + steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: fetch-depth: 0 persist-credentials: false + - name: Download Package + uses: actions/download-artifact@v4 + with: + name: Packages + path: dist + - name: Set up Python ${{ matrix.python }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python }} - check-latest: ${{ endsWith(matrix.python, '-dev') }} + check-latest: true + allow-prereleases: true - name: Install dependencies run: | @@ -188,11 +303,13 @@ jobs: - name: Test without coverage if: "! matrix.use_coverage" - run: "tox -e ${{ matrix.tox_env }}" + shell: bash + run: tox run -e ${{ matrix.tox_env }} --installpkg `find dist/*.tar.gz` - name: Test with coverage if: "matrix.use_coverage" - run: "tox -e ${{ matrix.tox_env }}-coverage" + shell: bash + run: tox run -e ${{ matrix.tox_env }}-coverage --installpkg `find dist/*.tar.gz` - name: Generate coverage report if: "matrix.use_coverage" @@ -200,16 +317,22 @@ jobs: - name: Upload coverage to Codecov if: "matrix.use_coverage" - uses: codecov/codecov-action@v3 - continue-on-error: true + uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 with: - fail_ci_if_error: true + fail_ci_if_error: false files: ./coverage.xml verbose: true - check-package: + check: # This job does nothing and is only used for the branch protection + if: always() + + needs: + - build + runs-on: ubuntu-latest + steps: - - uses: actions/checkout@v3 - - name: Build and Check Package - uses: hynek/build-and-inspect-python-package@v1.5 + - name: Decide whether the needed jobs succeeded or failed + uses: re-actors/alls-green@223e4bb7a751b91f43eda76992bcfbf23b8b0302 + with: + jobs: ${{ toJSON(needs) }} diff --git a/.github/workflows/update-plugin-list.yml b/.github/workflows/update-plugin-list.yml index e7cbc4e96be..c10aefa3a55 100644 --- a/.github/workflows/update-plugin-list.yml +++ b/.github/workflows/update-plugin-list.yml @@ -20,25 +20,35 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: fetch-depth: 0 + persist-credentials: false - name: Setup Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: - python-version: 3.8 + python-version: "3.11" + cache: pip + + - name: requests-cache + uses: actions/cache@v4 + with: + path: ~/.cache/pytest-plugin-list/ + key: plugins-http-cache-${{ github.run_id }} # Can use time based key as well + restore-keys: plugins-http-cache- - name: Install dependencies run: | python -m pip install --upgrade pip - pip install packaging requests tabulate[widechars] tqdm + pip install packaging requests tabulate[widechars] tqdm requests-cache platformdirs - name: Update Plugin List run: python scripts/update-plugin-list.py - name: Create Pull Request - uses: peter-evans/create-pull-request@153407881ec5c347639a548ade7d8ad1d6740e38 + id: pr + uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e with: commit-message: '[automated] Update plugin list' author: 'pytest bot ' @@ -47,3 +57,14 @@ jobs: branch-suffix: short-commit-hash title: '[automated] Update plugin list' body: '[automated] Update plugin list' + draft: true + + - name: Instruct the maintainers to trigger CI by undrafting the PR + env: + GITHUB_TOKEN: ${{ github.token }} + PULL_REQUEST_NUMBER: ${{ steps.pr.outputs.pull-request-number }} + run: >- + gh pr comment + --body 'Please mark the PR as ready for review to trigger PR checks.' + --repo '${{ github.repository }}' + "$PULL_REQUEST_NUMBER" diff --git a/.gitignore b/.gitignore index 3cac2474a59..c4557b33a1c 100644 --- a/.gitignore +++ b/.gitignore @@ -25,7 +25,6 @@ src/_pytest/_version.py doc/*/_build doc/*/.doctrees -doc/*/_changelog_towncrier_draft.rst build/ dist/ *.egg-info @@ -51,6 +50,7 @@ coverage.xml .settings .vscode __pycache__/ +.python-version # generated by pip pip-wheel-metadata/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b5089e12942..149f7e6af59 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,77 +1,95 @@ repos: -- repo: https://github.com/psf/black - rev: 23.3.0 - hooks: - - id: black - args: [--safe, --quiet] -- repo: https://github.com/asottile/blacken-docs - rev: 1.14.0 - hooks: - - id: blacken-docs - additional_dependencies: [black==23.1.0] +- repo: https://github.com/astral-sh/ruff-pre-commit + rev: "v0.11.11" + hooks: + - id: ruff + args: ["--fix"] + - id: ruff-format - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.4.0 + rev: v5.0.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer - - id: fix-encoding-pragma - args: [--remove] - id: check-yaml - - id: debug-statements - exclude: _pytest/(debugging|hookspec).py - language_version: python3 -- repo: https://github.com/PyCQA/autoflake - rev: v2.1.1 +- repo: https://github.com/woodruffw/zizmor-pre-commit + rev: v1.8.0 + hooks: + - id: zizmor +- repo: https://github.com/adamchainz/blacken-docs + rev: 1.19.1 hooks: - - id: autoflake - name: autoflake - args: ["--in-place", "--remove-unused-variables", "--remove-all-unused-imports"] - language: python - files: \.py$ -- repo: https://github.com/PyCQA/flake8 - rev: 6.0.0 + - id: blacken-docs + additional_dependencies: [black==24.1.1] +- repo: https://github.com/codespell-project/codespell + rev: v2.4.1 hooks: - - id: flake8 - language_version: python3 + - id: codespell + args: ["--toml=pyproject.toml"] additional_dependencies: - - flake8-typing-imports==1.12.0 - - flake8-docstrings==1.5.0 -- repo: https://github.com/asottile/reorder-python-imports - rev: v3.10.0 - hooks: - - id: reorder-python-imports - args: ['--application-directories=.:src', --py37-plus] -- repo: https://github.com/asottile/pyupgrade - rev: v3.7.0 - hooks: - - id: pyupgrade - args: [--py37-plus] -- repo: https://github.com/asottile/setup-cfg-fmt - rev: v2.3.0 - hooks: - - id: setup-cfg-fmt - args: ["--max-py-version=3.12", "--include-version-classifiers"] + - tomli - repo: https://github.com/pre-commit/pygrep-hooks rev: v1.10.0 hooks: - id: python-use-type-annotations - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.3.0 + rev: v1.15.0 hooks: - id: mypy - files: ^(src/|testing/) - args: [] + files: ^(src/|testing/|scripts/) + additional_dependencies: + - iniconfig>=1.1.0 + - attrs>=19.2.0 + - pluggy>=1.5.0 + - packaging + - tomli + - types-setuptools + - types-tabulate + # for mypy running on python>=3.11 since exceptiongroup is only a dependency + # on <3.11 + - exceptiongroup>=1.0.0rc8 +- repo: https://github.com/RobertCraigie/pyright-python + rev: v1.1.401 + hooks: + - id: pyright + files: ^(src/|scripts/) additional_dependencies: - iniconfig>=1.1.0 - attrs>=19.2.0 + - pluggy>=1.5.0 - packaging - tomli - - types-pkg_resources + - types-setuptools + - types-tabulate # for mypy running on python>=3.11 since exceptiongroup is only a dependency # on <3.11 - exceptiongroup>=1.0.0rc8 + # Manual because passing pyright is a work in progress. + stages: [manual] +- 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.9"] +- repo: https://github.com/asottile/pyupgrade + rev: v3.20.0 + hooks: + - id: pyupgrade + args: + - "--py39-plus" + # Manual because ruff does what pyupgrade does and the two are not out of sync + # often enough to make launching pyupgrade everytime worth it + stages: [manual] - repo: local hooks: + - id: pylint + name: pylint + entry: pylint + language: system + types: [python] + args: ["-rn", "-sn", "--fail-on=I", "--enable-all-extentions"] + require_serial: true + stages: [manual] - id: rst name: rst entry: rst-lint --encoding utf-8 @@ -81,9 +99,50 @@ repos: - id: changelogs-rst name: changelog filenames language: fail - entry: 'changelog files must be named ####.(breaking|bugfix|deprecation|doc|feature|improvement|trivial|vendor).rst' - exclude: changelog/(\d+\.(breaking|bugfix|deprecation|doc|feature|improvement|trivial|vendor).rst|README.rst|_template.rst) + entry: >- + changelog files must be named + ####.( + breaking + | deprecation + | feature + | improvement + | bugfix + | vendor + | doc + | packaging + | contrib + | misc + )(.#)?(.rst)? + exclude: >- + (?x) + ^ + changelog/( + \.gitignore + |\d+\.( + breaking + |deprecation + |feature + |improvement + |bugfix + |vendor + |doc + |packaging + |contrib + |misc + )(\.\d+)?(\.rst)? + |README\.rst + |_template\.rst + ) + $ files: ^changelog/ + - id: changelogs-user-role + name: Changelog files should use a non-broken :user:`name` role + language: pygrep + entry: :user:([^`]+`?|`[^`]+[\s,]) + pass_filenames: true + types: + - file + - rst - id: py-deprecated name: py library is deprecated language: pygrep diff --git a/.readthedocs.yml b/.readthedocs.yaml similarity index 65% rename from .readthedocs.yml rename to .readthedocs.yaml index b506c5f4039..f7370f1bb98 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yaml @@ -9,12 +9,21 @@ python: path: . - requirements: doc/en/requirements.txt +sphinx: + configuration: doc/en/conf.py + fail_on_warning: true + build: - os: ubuntu-20.04 + os: ubuntu-24.04 tools: - python: "3.9" + python: >- + 3.12 apt_packages: - inkscape + jobs: + post_checkout: + - git fetch --unshallow || true + - git fetch --tags || true formats: - epub diff --git a/AUTHORS b/AUTHORS index ee4ef203f0c..df338aa8c6e 100644 --- a/AUTHORS +++ b/AUTHORS @@ -11,6 +11,7 @@ Adam Johnson Adam Stewart Adam Uhlir Ahn Ki-Wook +Akhilesh Ramakrishnan Akiomi Kamakura Alan Velasco Alessio Izzo @@ -23,6 +24,7 @@ Alice Purcell Allan Feldman Aly Sivji Amir Elkess +Ammar Askar Anatoly Bubenkoff Anders Hovmöller Andras Mitzki @@ -30,16 +32,19 @@ Andras Tim Andrea Cimatoribus Andreas Motl Andreas Zeidler +Andrew Pikul Andrew Shapton Andrey Paramonov Andrzej Klajnert Andrzej Ostrowski Andy Freeland +Anita Hammer Anthon van der Neut Anthony Shaw Anthony Sottile Anton Grinevich Anton Lodder +Anton Zhilin Antony Lee Arel Cordero Arias Emmanuel @@ -47,15 +52,22 @@ Ariel Pillemer Armin Rigo Aron Coyle Aron Curzon +Arthur Richard Ashish Kurmi +Ashley Whetter Aviral Verma Aviv Palivoda Babak Keyvani +Bahram Farahmand Barney Gale +Ben Brown Ben Gartner +Ben Leith Ben Webb Benjamin Peterson +Benjamin Schubert Bernard Pratz +Bo Wu Bob Ippolito Brian Dorsey Brian Larsen @@ -78,6 +90,7 @@ Chris NeJame Chris Rose Chris Wheeler Christian Boelsen +Christian Clauss Christian Fetzer Christian Neumüller Christian Theunert @@ -86,14 +99,19 @@ Christine Mecklenborg Christoph Buelter Christopher Dignam Christopher Gilling +Christopher Head Claire Cecil Claudio Madotto +Clément M.T. Robert +Cornelius Riemenschneider CrazyMerlyn Cristian Vera Cyrus Maden +Daara Shaw Damian Skrzypczak Daniel Grana Daniel Hahler +Daniel Miller Daniel Nuri Daniel Sánchez Castelló Daniel Valenzuela Zenteno @@ -105,12 +123,15 @@ Dave Hunt David Díaz-Barquero David Mohr David Paul Röthlisberger +David Peled David Szotten David Vierra Daw-Ran Liou Debi Mishra Denis Kirisov Denivy Braiam Rück +Deysha Rivera +Dheeraj C K Dhiren Serai Diego Russo Dmitry Dygalo @@ -121,25 +142,36 @@ Edison Gustavo Muenz Edoardo Batini Edson Tadeu M. Manoel Eduardo Schettino +Edward Haigh +Eero Vaher Eli Boyarski Elizaveta Shashkova Éloi Rivard +Emil Hjelm Endre Galaczi Eric Hunsberger Eric Liu Eric Siegerman +Eric Yuan Erik Aronesty +Erik Hasse Erik M. Bray +Ethan Wass Evan Kepner Evgeny Seliverstov +Fabian Sturm Fabien Zarifian Fabio Zadrozny +Farbod Ahmadian +faph Felix Hofstätter Felix Nieuwenhuizen Feng Ma Florian Bruhin Florian Dahlitz Floris Bruynooghe +Frank Hoffmann +Fraser Stark Gabriel Landau Gabriel Reis Garvit Shubham @@ -166,6 +198,8 @@ Ian Bicking Ian Lesperance Ilya Konstantinov Ionuț Turturică +Isaac Virshup +Israel Fruchter Itxaso Aizpurua Iwan Briquemont Jaap Broekhuizen @@ -173,6 +207,7 @@ Jake VanderPlas Jakob van Santen Jakub Mitoraj James Bourbeau +James Frost Jan Balster Janne Vanhala Jason R. Coombs @@ -181,6 +216,8 @@ Javier Romero Jeff Rackauckas Jeff Widman Jenni Rinker +Jens Tröger +Jiajun Xu John Eddie Ayson John Litborn John Towler @@ -191,8 +228,10 @@ Jordan Guymon Jordan Moldow Jordan Speicher Joseph Hunkeler +Joseph Sawaya Josh Karpel Joshua Bronson +Julian Valentin Jurko Gospodnetić Justice Ndou Justyna Janczyszyn @@ -218,23 +257,32 @@ Kristoffer Nordström Kyle Altendorf Lawrence Mitchell Lee Kamentsky +Leonardus Chen Lev Maximov +Levon Saldamli Lewis Cowles +Liam DeVoe Llandy Riveron Del Risco Loic Esteve +lovetheguitar Lukas Bednar Luke Murphy Maciek Fijalkowski +Maggie Chung Maho Maik Figura Mandeep Bhutani Manuel Krebber +Marc Mueller Marc Schlaich Marcelo Duarte Trevisani +Marcin Augustynów Marcin Bachry +Marc Bresson Marco Gorelli Mark Abramowitz Mark Dickinson +Mark Vong Marko Pacak Markus Unterwaditzer Martijn Faassen @@ -255,25 +303,37 @@ Michael Droettboom Michael Goerz Michael Krebs Michael Seifert +Michael Vogt Michal Wajszczuk +Michał Górny Michał Zięba Mickey Pashov Mihai Capotă +Mihail Milushev Mike Hoyle (hoylemd) Mike Lundy +Milan Lesnek Miro Hrončok +mrbean-bremen +Nathan Goldbaum +Nathan Rousseau Nathaniel Compton Nathaniel Waisbrot +Nauman Ahmed Ned Batchelder +Neil Martin Neven Mundar Nicholas Devenish Nicholas Murphy Niclas Olofsson Nicolas Delaby +Nicolas Simonds +Nico Vidal Nikolay Kondratyev Nipunn Koorapati Oleg Pidsadnyi Oleg Sushchenko +Oleksandr Zavertniev Olga Matoula Oliver Bestwalter Omar Kohl @@ -282,18 +342,22 @@ Ondřej Súkup Oscar Benjamin Parth Patel Patrick Hayes +Patrick Lannigan Paul Müller Paul Reece Pauli Virtanen Pavel Karateev +Pavel Zhukov Paweł Adamczak Pedro Algarvio +Peter Gessler Petter Strandmark Philipp Loose Pierre Sassoulas Pieter Mulder Piotr Banaszkiewicz Piotr Helm +Poulami Sau Prakhar Gurunani Prashant Anand Prashant Sharma @@ -308,8 +372,11 @@ Ran Benita Raphael Castaneda Raphael Pierzina Rafal Semik +Reza Mousavi Raquel Alegre Ravi Chandra +Reagan Lee +Rob Arrow Robert Holt Roberto Aldera Roberto Polli @@ -320,25 +387,35 @@ Ronny Pfannschmidt Ross Lawley Ruaridh Williamson Russel Winder +Russell Martin +Ryan Puddephatt Ryan Wooden +Sadra Barikbin Saiprasad Kale Samuel Colvin Samuel Dion-Girardeau +Samuel Jirovec Samuel Searles-Bryant +Samuel Therrien (Avasam) Samuele Pedroni Sanket Duthade Sankt Petersbug Saravanan Padmanaban +Sean Malloy Segev Finer Serhii Mozghovyi Seth Junot Shantanu Jain +Sharad Nair +Shaygan Hooshyari Shubham Adep +Simon Blanchard Simon Gomizelj Simon Holesch Simon Kerr Skylar Downes Srinivas Reddy Thatiparthy +Stefaan Lippens Stefan Farmbauer Stefan Scherfke Stefan Zimmermann @@ -346,12 +423,16 @@ Stefanie Molin Stefano Taschini Steffen Allner Stephan Obermann +Sven Sven-Hendrik Haase +Sviatoslav Sydorenko Sylvain Marié Tadek Teleżyński Takafumi Arakaki +Takumi Otani Taneli Hukkinen Tanvi Mehta +Tanya Agarwal Tarcisio Fischer Tareq Alayan Tatiana Ovary @@ -359,10 +440,12 @@ Ted Xiao Terje Runde Thomas Grainger Thomas Hisch +Tianyu Dongfang Tim Hoffmann Tim Strazny TJ Bruno Tobias Diez +Tobias Petersen Tom Dalton Tom Viner Tomáš Gavenčiak @@ -370,7 +453,9 @@ Tomer Keren Tony Narlock Tor Colvin Trevor Bekolay +Tushar Sadhwani Tyler Goodlet +Tyler Smart Tzu-ping Chung Vasily Kuznetsov Victor Maryama @@ -378,15 +463,18 @@ Victor Rodriguez Victor Uriarte Vidar T. Fauske Vijay Arora +Virendra Patil Virgil Dupras Vitaly Lashmanov Vivaan Verma Vlad Dragos Vlad Radziuk Vladyslav Rachek +Volodymyr Kochetkov Volodymyr Piskun Wei Lin Wil Cooley +Will Riley William Lee Wim Glenn Wouter van Ackooy @@ -394,11 +482,14 @@ Xixi Zhao Xuan Luong Xuecong Liao Yannick Péroux +Yao Xiao Yoav Caspi Yuliang Shao Yusuke Kadowaki +Yutian Li Yuval Shimon Zac Hatfield-Dodds +Zach Snicker Zachary Kneupper Zachary OBrien Zhouxin Qiu diff --git a/CITATION b/CITATION index d4e9d8ec7a1..98beee72209 100644 --- a/CITATION +++ b/CITATION @@ -1,16 +1,28 @@ NOTE: Change "x.y" by the version you use. If you are unsure about which version -you are using run: `pip show pytest`. +you are using run: `pip show pytest`. Do not include the patch number (i.e., z in x.y.z) Text: [pytest] pytest x.y, 2004 Krekel et al., https://github.com/pytest-dev/pytest +BibLaTeX: + +@software{pytest, + title = {pytest x.y}, + author = {Holger Krekel and Bruno Oliveira and Ronny Pfannschmidt and Floris Bruynooghe and Brianna Laugher and Florian Bruhin}, + year = {2004}, + version = {x.y}, + url = {https://github.com/pytest-dev/pytest}, + note = {Contributors: Holger Krekel and Bruno Oliveira and Ronny Pfannschmidt and Floris Bruynooghe and Brianna Laugher and Florian Bruhin and others} +} + BibTeX: -@misc{pytestx.y, - title = {pytest x.y}, - author = {Krekel, Holger and Oliveira, Bruno and Pfannschmidt, Ronny and Bruynooghe, Floris and Laugher, Brianna and Bruhin, Florian}, - year = {2004}, - url = {https://github.com/pytest-dev/pytest}, +@misc{pytest, + author = {Holger Krekel and Bruno Oliveira and Ronny Pfannschmidt and Floris Bruynooghe and Brianna Laugher and Florian Bruhin}, + title = {pytest x.y}, + year = {2004}, + howpublished = {\url{https://github.com/pytest-dev/pytest}}, + note = {Version x.y. Contributors include Holger Krekel, Bruno Oliveira, Ronny Pfannschmidt, Floris Bruynooghe, Brianna Laugher, Florian Bruhin, and others.} } diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 791f988306f..b79955e1c01 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -1,14 +1,10 @@ ============================ -Contribution getting started +Contributing ============================ Contributions are highly welcomed and appreciated. Every little bit of help counts, so do not hesitate! -.. contents:: - :depth: 2 - :backlinks: none - .. _submitfeedback: @@ -50,7 +46,7 @@ Fix bugs -------- Look through the `GitHub issues for bugs `_. -See also the `"status: easy" issues `_ +See also the `"good first issue" issues `_ that are friendly to new contributors. :ref:`Talk ` to developers to find out how you can fix specific bugs. To indicate that you are going @@ -128,7 +124,7 @@ For example: Submitting Plugins to pytest-dev -------------------------------- -Pytest development of the core, some plugins and support code happens +Development of the pytest core, support code, and some plugins happens in repositories living under the ``pytest-dev`` organisations: - `pytest-dev on GitHub `_ @@ -143,9 +139,8 @@ The objectives of the ``pytest-dev`` organisation are: * Sharing some of the maintenance responsibility (in case a maintainer no longer wishes to maintain a plugin) -You can submit your plugin by subscribing to the `pytest-dev mail list -`_ and writing a -mail pointing to your existing pytest plugin repository which must have +You can submit your plugin by posting a new topic in the `pytest-dev GitHub Discussions +`_ pointing to your existing pytest plugin repository which must have the following: - PyPI presence with packaging metadata that contains a ``pytest-`` @@ -197,11 +192,12 @@ Short version ~~~~~~~~~~~~~ #. Fork the repository. +#. Fetch tags from upstream if necessary (if you cloned only main `git fetch --tags https://github.com/pytest-dev/pytest`). #. Enable and install `pre-commit `_ to ensure style-guides and code checks are followed. -#. Follow **PEP-8** for naming and `black `_ for formatting. +#. Follow `PEP-8 `_ for naming. #. Tests are run using ``tox``:: - tox -e linting,py37 + tox -e linting,py39 The test environments above are usually enough to cover most cases locally. @@ -236,6 +232,7 @@ Here is a simple overview, with pytest-specific bits: $ git clone git@github.com:YOUR_GITHUB_USERNAME/pytest.git $ cd pytest + $ git fetch --tags https://github.com/pytest-dev/pytest # now, create your own branch off "main": $ git checkout -b your-bugfix-branch-name main @@ -272,35 +269,35 @@ Here is a simple overview, with pytest-specific bits: #. Run all the tests - You need to have Python 3.7 available in your system. Now + You need to have Python 3.9 or later available in your system. Now running tests is as simple as issuing this command:: - $ tox -e linting,py37 + $ tox -e linting,py39 - This command will run tests via the "tox" tool against Python 3.7 + This command will run tests via the "tox" tool against Python 3.9 and also perform "lint" coding-style checks. -#. You can now edit your local working copy and run the tests again as necessary. Please follow PEP-8 for naming. +#. You can now edit your local working copy and run the tests again as necessary. Please follow `PEP-8 `_ for naming. - You can pass different options to ``tox``. For example, to run tests on Python 3.7 and pass options to pytest + You can pass different options to ``tox``. For example, to run tests on Python 3.9 and pass options to pytest (e.g. enter pdb on failure) to pytest you can do:: - $ tox -e py37 -- --pdb + $ tox -e py39 -- --pdb - Or to only run tests in a particular test module on Python 3.7:: + Or to only run tests in a particular test module on Python 3.9:: - $ tox -e py37 -- testing/test_config.py + $ tox -e py39 -- testing/test_config.py When committing, ``pre-commit`` will re-format the files if necessary. #. If instead of using ``tox`` you prefer to run the tests directly, then we suggest to create a virtual environment and use - an editable install with the ``testing`` extra:: + an editable install with the ``dev`` extra:: $ python3 -m venv .venv $ source .venv/bin/activate # Linux $ .venv/Scripts/activate.bat # Windows - $ pip install -e ".[testing]" + $ pip install -e ".[dev]" Afterwards, you can edit the files and run pytest normally:: @@ -382,6 +379,57 @@ pull requests from other contributors yourself after having reviewed them. +Merge/squash guidelines +----------------------- + +When a PR is approved and ready to be integrated to the ``main`` branch, one has the option to *merge* the commits unchanged, or *squash* all the commits into a single commit. + +Here are some guidelines on how to proceed, based on examples of a single PR commit history: + +1. Miscellaneous commits: + + * ``Implement X`` + * ``Fix test_a`` + * ``Add myself to AUTHORS`` + * ``fixup! Fix test_a`` + * ``Update tests/test_integration.py`` + * ``Merge origin/main into PR branch`` + * ``Update tests/test_integration.py`` + + In this case, prefer to use the **Squash** merge strategy: the commit history is a bit messy (not in a derogatory way, often one just commits changes because they know the changes will eventually be squashed together), so squashing everything into a single commit is best. You must clean up the commit message, making sure it contains useful details. + +2. Separate commits related to the same topic: + + * ``Implement X`` + * ``Add myself to AUTHORS`` + * ``Update CHANGELOG for X`` + + In this case, prefer to use the **Squash** merge strategy: while the commit history is not "messy" as in the example above, the individual commits do not bring much value overall, specially when looking at the changes a few months/years down the line. + +3. Separate commits, each with their own topic (refactorings, renames, etc), but still have a larger topic/purpose. + + * ``Refactor class X in preparation for feature Y`` + * ``Remove unused method`` + * ``Implement feature Y`` + + In this case, prefer to use the **Merge** strategy: each commit is valuable on its own, even if they serve a common topic overall. Looking at the history later, it is useful to have the removal of the unused method separately on its own commit, along with more information (such as how it became unused in the first place). + +4. Separate commits, each with their own topic, but without a larger topic/purpose other than improve the code base (using more modern techniques, improve typing, removing clutter, etc). + + * ``Improve internal names in X`` + * ``Add type annotations to Y`` + * ``Remove unnecessary dict access`` + * ``Remove unreachable code due to EOL Python`` + + In this case, prefer to use the **Merge** strategy: each commit is valuable on its own, and the information on each is valuable in the long term. + + +As mentioned, those are overall guidelines, not rules cast in stone. This topic was discussed in `#12633 `_. + + +*Backport PRs* (as those created automatically from a ``backport`` label) should always be **squashed**, as they preserve the original PR author. + + Backporting bug fixes for the next patch release ------------------------------------------------ @@ -440,6 +488,8 @@ above? All the above are not rules, but merely some guidelines/suggestions on what we should expect about backports. +Backports should be **squashed** (rather than **merged**), as doing so preserves the original PR author correctly. + Handling stale issues/PRs ------------------------- @@ -487,9 +537,9 @@ When closing a Pull Request, it needs to be acknowledging the time, effort, and -Closing Issues +Closing issues -------------- When a pull request is submitted to fix an issue, add text like ``closes #XYZW`` to the PR description and/or commits (where ``XYZW`` is the issue number). See the `GitHub docs `_ for more information. -When an issue is due to user error (e.g. misunderstanding of a functionality), please politely explain to the user why the issue raised is really a non-issue and ask them to close the issue if they have no further questions. If the original requestor is unresponsive, the issue will be handled as described in the section `Handling stale issues/PRs`_ above. +When an issue is due to user error (e.g. misunderstanding of a functionality), please politely explain to the user why the issue raised is really a non-issue and ask them to close the issue if they have no further questions. If the original requester is unresponsive, the issue will be handled as described in the section `Handling stale issues/PRs`_ above. diff --git a/README.rst b/README.rst index 034034a40b8..091afc363da 100644 --- a/README.rst +++ b/README.rst @@ -20,16 +20,13 @@ :target: https://codecov.io/gh/pytest-dev/pytest :alt: Code coverage Status -.. image:: https://github.com/pytest-dev/pytest/workflows/test/badge.svg +.. image:: https://github.com/pytest-dev/pytest/actions/workflows/test.yml/badge.svg :target: https://github.com/pytest-dev/pytest/actions?query=workflow%3Atest .. image:: https://results.pre-commit.ci/badge/github/pytest-dev/pytest/main.svg :target: https://results.pre-commit.ci/latest/github/pytest-dev/pytest/main :alt: pre-commit.ci status -.. image:: https://img.shields.io/badge/code%20style-black-000000.svg - :target: https://github.com/psf/black - .. image:: https://www.codetriage.com/pytest-dev/pytest/badges/users.svg :target: https://www.codetriage.com/pytest-dev/pytest @@ -97,12 +94,12 @@ Features - `Modular fixtures `_ for managing small or parametrized long-lived test resources -- Can run `unittest `_ (or trial), - `nose `_ test suites out of the box +- Can run `unittest `_ (or trial) + test suites out of the box -- Python 3.7+ or PyPy3 +- Python 3.9+ or PyPy3 -- Rich plugin architecture, with over 850+ `external plugins `_ and thriving community +- Rich plugin architecture, with over 1300+ `external plugins `_ and thriving community Documentation diff --git a/RELEASING.rst b/RELEASING.rst index b018dc48932..76c41d83a58 100644 --- a/RELEASING.rst +++ b/RELEASING.rst @@ -133,14 +133,16 @@ Releasing Both automatic and manual processes described above follow the same steps from this point onward. -#. After all tests pass and the PR has been approved, tag the release commit - in the ``release-MAJOR.MINOR.PATCH`` branch and push it. This will publish to PyPI:: +#. After all tests pass and the PR has been approved, trigger the ``deploy`` job + in https://github.com/pytest-dev/pytest/actions/workflows/deploy.yml, using the ``release-MAJOR.MINOR.PATCH`` branch + as source. - git fetch upstream - git tag MAJOR.MINOR.PATCH upstream/release-MAJOR.MINOR.PATCH - git push upstream MAJOR.MINOR.PATCH + Using the command-line:: + + $ gh workflow run deploy.yml -R pytest-dev/pytest --ref=release-{VERSION} -f version={VERSION} - Wait for the deploy to complete, then make sure it is `available on PyPI `_. + This job will require approval from ``pytest-dev/core``, after which it will publish to PyPI + and tag the repository. #. Merge the PR. **Make sure it's not squash-merged**, so that the tagged commit ends up in the main branch. @@ -160,16 +162,17 @@ Both automatic and manual processes described above follow the same steps from t git tag MAJOR.{MINOR+1}.0.dev0 git push upstream MAJOR.{MINOR+1}.0.dev0 -#. For major and minor releases, change the default version in the `Read the Docs Settings `_ to the new branch. - #. Send an email announcement with the contents from:: doc/en/announce/release-.rst To the following mailing lists: - * pytest-dev@python.org (all releases) * python-announce-list@python.org (all releases) * testing-in-python@lists.idyll.org (only major/minor releases) - And announce it on `Twitter `_ with the ``#pytest`` hashtag. + And announce it with the ``#pytest`` hashtag on: + + * `Bluesky `_ + * `Fosstodon `_ + * `Twitter/X `_ diff --git a/TIDELIFT.rst b/TIDELIFT.rst index 2fe25841c3a..1ba246bd868 100644 --- a/TIDELIFT.rst +++ b/TIDELIFT.rst @@ -23,9 +23,9 @@ members of the `contributors team`_ interested in receiving funding. The current list of contributors receiving funding are: -* `@asottile`_ * `@nicoddemus`_ * `@The-Compiler`_ +* `@RonnyPfannschmidt`_ Contributors interested in receiving a part of the funds just need to submit a PR adding their name to the list. Contributors that want to stop receiving the funds should also submit a PR @@ -55,6 +55,6 @@ funds. Just drop a line to one of the `@pytest-dev/tidelift-admins`_ or use the .. _`@pytest-dev/tidelift-admins`: https://github.com/orgs/pytest-dev/teams/tidelift-admins/members .. _`agreement`: https://tidelift.com/docs/lifting/agreement -.. _`@asottile`: https://github.com/asottile .. _`@nicoddemus`: https://github.com/nicoddemus .. _`@The-Compiler`: https://github.com/The-Compiler +.. _`@RonnyPfannschmidt`: https://github.com/RonnyPfannschmidt diff --git a/bench/bench.py b/bench/bench.py index c40fc8636c0..139c292ecd8 100644 --- a/bench/bench.py +++ b/bench/bench.py @@ -1,12 +1,16 @@ +from __future__ import annotations + import sys + if __name__ == "__main__": import cProfile - import pytest # NOQA import pstats + import pytest # noqa: F401 + script = sys.argv[1:] if len(sys.argv) > 1 else ["empty.py"] - cProfile.run("pytest.cmdline.main(%r)" % script, "prof") + cProfile.run(f"pytest.cmdline.main({script!r})", "prof") p = pstats.Stats("prof") p.strip_dirs() p.sort_stats("cumulative") diff --git a/bench/bench_argcomplete.py b/bench/bench_argcomplete.py index 335733df72b..468c59217df 100644 --- a/bench/bench_argcomplete.py +++ b/bench/bench_argcomplete.py @@ -2,8 +2,11 @@ # 2.7.5 3.3.2 # FilesCompleter 75.1109 69.2116 # FastFilesCompleter 0.7383 1.0760 +from __future__ import annotations + import timeit + imports = [ "from argcomplete.completers import FilesCompleter as completer", "from _pytest._argcomplete import FastFilesCompleter as completer", diff --git a/bench/empty.py b/bench/empty.py index 4e7371b6f80..346b79d5e33 100644 --- a/bench/empty.py +++ b/bench/empty.py @@ -1,2 +1,5 @@ +from __future__ import annotations + + for i in range(1000): - exec("def test_func_%d(): pass" % i) + exec(f"def test_func_{i}(): pass") diff --git a/bench/manyparam.py b/bench/manyparam.py index 1226c73bd9c..579f7b2488d 100644 --- a/bench/manyparam.py +++ b/bench/manyparam.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import pytest diff --git a/bench/skip.py b/bench/skip.py index f0c9d1ddbef..9145cc0ceed 100644 --- a/bench/skip.py +++ b/bench/skip.py @@ -1,5 +1,8 @@ +from __future__ import annotations + import pytest + SKIP = True diff --git a/bench/unit_test.py b/bench/unit_test.py index ad52069dbfd..0f106e16b6c 100644 --- a/bench/unit_test.py +++ b/bench/unit_test.py @@ -1,5 +1,8 @@ +from __future__ import annotations + from unittest import TestCase # noqa: F401 + for i in range(15000): exec( f""" diff --git a/bench/xunit.py b/bench/xunit.py index 3a77dcdce42..31ab432441c 100644 --- a/bench/xunit.py +++ b/bench/xunit.py @@ -1,3 +1,6 @@ +from __future__ import annotations + + for i in range(5000): exec( f""" diff --git a/changelog/.gitignore b/changelog/.gitignore new file mode 100644 index 00000000000..3b34da34bc6 --- /dev/null +++ b/changelog/.gitignore @@ -0,0 +1,34 @@ +* +!.gitignore +!_template.rst +!README.rst +!*.bugfix +!*.bugfix.rst +!*.bugfix.*.rst +!*.breaking +!*.breaking.rst +!*.breaking.*.rst +!*.contrib +!*.contrib.rst +!*.contrib.*.rst +!*.deprecation +!*.deprecation.rst +!*.deprecation.*.rst +!*.doc +!*.doc.rst +!*.doc.*.rst +!*.feature +!*.feature.rst +!*.feature.*.rst +!*.improvement +!*.improvement.rst +!*.improvement.*.rst +!*.misc +!*.misc.rst +!*.misc.*.rst +!*.packaging +!*.packaging.rst +!*.packaging.*.rst +!*.vendor +!*.vendor.rst +!*.vendor.*.rst diff --git a/changelog/10831.bugfix.rst b/changelog/10831.bugfix.rst deleted file mode 100644 index ea641dee60e..00000000000 --- a/changelog/10831.bugfix.rst +++ /dev/null @@ -1 +0,0 @@ -Terminal Reporting: Fixed bug when running in ``--tb=line`` mode where ``pytest.fail(pytrace=False)`` tests report ``None``. diff --git a/changelog/10872.improvement.rst b/changelog/10872.improvement.rst deleted file mode 100644 index fe0c01a0224..00000000000 --- a/changelog/10872.improvement.rst +++ /dev/null @@ -1 +0,0 @@ -Update test log report annotation to named tuple and fixed inconsistency in docs for :hook:`pytest_report_teststatus` hook. diff --git a/changelog/10901.feature.rst b/changelog/10901.feature.rst deleted file mode 100644 index 0d99d66f67f..00000000000 --- a/changelog/10901.feature.rst +++ /dev/null @@ -1,2 +0,0 @@ -Added :func:`ExceptionInfo.from_exception() `, a simpler way to create an :class:`~pytest.ExceptionInfo` from an exception. -This can replace :func:`ExceptionInfo.from_exc_info() ` for most uses. diff --git a/changelog/10907.improvement.rst b/changelog/10907.improvement.rst deleted file mode 100644 index 7f011a827d3..00000000000 --- a/changelog/10907.improvement.rst +++ /dev/null @@ -1,5 +0,0 @@ -When an exception traceback to be displayed is completely filtered out (by mechanisms such as ``__tracebackhide__``, internal frames, and similar), now only the exception string and the following message are shown: - -"All traceback entries are hidden. Pass `--full-trace` to see hidden and internal frames.". - -Previously, the last frame of the traceback was shown, even though it was hidden. diff --git a/changelog/10940.improvement.rst b/changelog/10940.improvement.rst deleted file mode 100644 index f3e33cbc68d..00000000000 --- a/changelog/10940.improvement.rst +++ /dev/null @@ -1,3 +0,0 @@ -Improved verbose output (``-vv``) of ``skip`` and ``xfail`` reasons by performing text wrapping while leaving a clear margin for progress output. - -Added :func:`TerminalReporter.wrap_write() ` as a helper for that. diff --git a/changelog/10991.improvement.rst b/changelog/10991.improvement.rst deleted file mode 100644 index 768c08e554c..00000000000 --- a/changelog/10991.improvement.rst +++ /dev/null @@ -1 +0,0 @@ -Added handling of ``%f`` directive to print microseconds in log format options, such as ``log-date-format``. diff --git a/changelog/11005.improvement.rst b/changelog/11005.improvement.rst deleted file mode 100644 index 295252514a6..00000000000 --- a/changelog/11005.improvement.rst +++ /dev/null @@ -1 +0,0 @@ -Added underlying exception to cache provider path creation and write warning messages. diff --git a/changelog/11013.improvement.rst b/changelog/11013.improvement.rst deleted file mode 100644 index fe3ece93c23..00000000000 --- a/changelog/11013.improvement.rst +++ /dev/null @@ -1 +0,0 @@ -Added warning when :confval:`testpaths` is set, but paths are not found by glob. In this case, pytest will fall back to searching from the current directory. diff --git a/changelog/11031.trivial.rst b/changelog/11031.trivial.rst deleted file mode 100644 index fad2cf24d5a..00000000000 --- a/changelog/11031.trivial.rst +++ /dev/null @@ -1 +0,0 @@ -Enhanced the CLI flag for ``-c`` to now include ``--config-file`` to make it clear that this flag applies to the usage of a custom config file. diff --git a/changelog/11043.improvement.rst b/changelog/11043.improvement.rst deleted file mode 100644 index 1fe0361d7f9..00000000000 --- a/changelog/11043.improvement.rst +++ /dev/null @@ -1,3 +0,0 @@ -When `--confcutdir` is not specified, and there is no config file present, the conftest cutoff directory (`--confcutdir`) is now set to the :ref:`rootdir`. -Previously in such cases, `conftest.py` files would be probed all the way to the root directory of the filesystem. -If you are badly affected by this change, consider adding an empty config file to your desired cutoff directory, or explicitly set `--confcutdir`. diff --git a/changelog/11068.bugfix.rst b/changelog/11068.bugfix.rst deleted file mode 100644 index 45cdb105fb4..00000000000 --- a/changelog/11068.bugfix.rst +++ /dev/null @@ -1 +0,0 @@ -Fixed the ``--last-failed`` whole-file skipping functionality ("skipped N files") for :ref:`non-python test files `. diff --git a/changelog/11081.improvement.rst b/changelog/11081.improvement.rst deleted file mode 100644 index ccfaf6016ae..00000000000 --- a/changelog/11081.improvement.rst +++ /dev/null @@ -1,7 +0,0 @@ -The :confval:`norecursedir` check is now performed in a :hook:`pytest_ignore_collect` implementation, so plugins can affect it. - -If after updating to this version you see that your `norecursedir` setting is not being respected, -it means that a conftest or a plugin you use has a bad `pytest_ignore_collect` implementation. -Most likely, your hook returns `False` for paths it does not want to ignore, -which ends the processing and doesn't allow other plugins, including pytest itself, to ignore the path. -The fix is to return `None` instead of `False` for paths your hook doesn't want to ignore. diff --git a/changelog/11104.bugfix.rst b/changelog/11104.bugfix.rst deleted file mode 100644 index 10f0db92515..00000000000 --- a/changelog/11104.bugfix.rst +++ /dev/null @@ -1,3 +0,0 @@ -Fixed a regression in pytest 7.3.2 which caused to :confval:`testpaths` to be considered for loading initial conftests, -even when it was not utilized (e.g. when explicit paths were given on the command line). -Now the ``testpaths`` are only considered when they are in use. diff --git a/changelog/1904.bugfix.rst b/changelog/1904.bugfix.rst deleted file mode 100644 index 3e1a292152b..00000000000 --- a/changelog/1904.bugfix.rst +++ /dev/null @@ -1 +0,0 @@ -Fixed traceback entries hidden with ``__tracebackhide__ = True`` still being shown for chained exceptions (parts after "... the above exception ..." message). diff --git a/changelog/7781.bugfix.rst b/changelog/7781.bugfix.rst deleted file mode 100644 index 191fcd4daf4..00000000000 --- a/changelog/7781.bugfix.rst +++ /dev/null @@ -1 +0,0 @@ -Fix writing non-encodable text to log file when using ``--debug``. diff --git a/changelog/8711.improvement.rst b/changelog/8711.improvement.rst deleted file mode 100644 index 20805e9f94d..00000000000 --- a/changelog/8711.improvement.rst +++ /dev/null @@ -1,3 +0,0 @@ -:func:`_pytest.logging.LogCaptureFixture.set_level` and :func:`_pytest.logging.LogCaptureFixture.at_level` -will temporarily enable the requested ``level`` if ``level`` was disabled globally via -``logging.disable(LEVEL)``. diff --git a/changelog/9146.doc.rst b/changelog/9146.doc.rst deleted file mode 100644 index 95189b96d46..00000000000 --- a/changelog/9146.doc.rst +++ /dev/null @@ -1 +0,0 @@ -Improve Documentation for `caplog.set_level`. diff --git a/changelog/README.rst b/changelog/README.rst index 6d026f57ef3..fdaa573d427 100644 --- a/changelog/README.rst +++ b/changelog/README.rst @@ -14,16 +14,28 @@ Each file should be named like ``..rst``, where ```` is an issue number, and ```` is one of: * ``feature``: new user facing features, like new command-line options and new behavior. -* ``improvement``: improvement of existing functionality, usually without requiring user intervention (for example, new fields being written in ``--junitxml``, improved colors in terminal, etc). +* ``improvement``: improvement of existing functionality, usually without requiring user intervention (for example, new fields being written in ``--junit-xml``, improved colors in terminal, etc). * ``bugfix``: fixes a bug. * ``doc``: documentation improvement, like rewording an entire session or adding missing docs. * ``deprecation``: feature deprecation. * ``breaking``: a change which may break existing suites, such as feature removal or behavior change. * ``vendor``: changes in packages vendored in pytest. -* ``trivial``: fixing a small typo or internal change that might be noteworthy. +* ``packaging``: notes for downstreams about unobvious side effects + and tooling. changes in the test invocation considerations and + runtime assumptions. +* ``contrib``: stuff that affects the contributor experience. e.g. + Running tests, building the docs, setting up the development + environment. +* ``misc``: changes that are hard to assign to any of the above + categories. So for example: ``123.feature.rst``, ``456.bugfix.rst``. +.. tip:: + + See :file:`pyproject.toml` for all available categories + (``tool.towncrier.type``). + If your PR fixes an issue, use that number here. If there is no issue, then after you submit the PR and get the PR number you can add a changelog using that instead. diff --git a/codecov.yml b/codecov.yml index f1cc8697338..c37e5ec4a09 100644 --- a/codecov.yml +++ b/codecov.yml @@ -1,6 +1,13 @@ # reference: https://docs.codecov.io/docs/codecovyml-reference +--- + +codecov: + token: 1eca3b1f-31a2-4fb8-a8c3-138b441b50a7 #repo token + coverage: status: - patch: true + patch: + default: + target: 100% # require patches to be 100% project: false comment: false diff --git a/doc/en/_static/pytest-custom.css b/doc/en/_static/pytest-custom.css new file mode 100644 index 00000000000..bc9eef457f1 --- /dev/null +++ b/doc/en/_static/pytest-custom.css @@ -0,0 +1,21 @@ +/* Tweak how the sidebar logo is presented */ +.sidebar-logo { + width: 70%; +} +.sidebar-brand { + padding: 0; +} + +/* The landing pages' sidebar-in-content highlights */ +#features ul { + padding-left: 1rem; + list-style: none; +} +#features ul li { + margin-bottom: 0; +} +@media (min-width: 46em) { + #features { + width: 50%; + } +} diff --git a/doc/en/img/pytest1.png b/doc/en/_static/pytest1.png similarity index 100% rename from doc/en/img/pytest1.png rename to doc/en/_static/pytest1.png diff --git a/doc/en/_templates/globaltoc.html b/doc/en/_templates/globaltoc.html deleted file mode 100644 index 09d970b64ed..00000000000 --- a/doc/en/_templates/globaltoc.html +++ /dev/null @@ -1,31 +0,0 @@ -

Contents

- - - -

About the project

- - - -{%- if display_toc %} -
- {{ toc }} -{%- endif %} - -
diff --git a/doc/en/_templates/layout.html b/doc/en/_templates/layout.html deleted file mode 100644 index f7096eaaa5e..00000000000 --- a/doc/en/_templates/layout.html +++ /dev/null @@ -1,52 +0,0 @@ -{# - - Copied from: - - https://raw.githubusercontent.com/pallets/pallets-sphinx-themes/b0c6c41849b4e15cbf62cc1d95c05ef2b3e155c8/src/pallets_sphinx_themes/themes/pocoo/layout.html - - And removed the warning version (see #7331). - -#} - -{% extends "basic/layout.html" %} - -{% set metatags %} - {{- metatags }} - -{%- endset %} - -{% block extrahead %} - {%- if page_canonical_url %} - - {%- endif %} - - {{ super() }} -{%- endblock %} - -{% block sidebarlogo %} - {% if pagename != "index" or theme_index_sidebar_logo %} - {{ super() }} - {% endif %} -{% endblock %} - -{% block relbar2 %}{% endblock %} - -{% block sidebar2 %} - - {{- super() }} -{%- endblock %} - -{% block footer %} - {{ super() }} - {%- if READTHEDOCS and not readthedocs_docsearch %} - - {%- endif %} - {{ js_tag("_static/version_warning_offset.js") }} -{% endblock %} diff --git a/doc/en/_templates/relations.html b/doc/en/_templates/relations.html deleted file mode 100644 index 3bbcde85bb4..00000000000 --- a/doc/en/_templates/relations.html +++ /dev/null @@ -1,19 +0,0 @@ -

Related Topics

- diff --git a/doc/en/_templates/sidebarintro.html b/doc/en/_templates/sidebarintro.html deleted file mode 100644 index ae860c172f0..00000000000 --- a/doc/en/_templates/sidebarintro.html +++ /dev/null @@ -1,5 +0,0 @@ -

About pytest

-

- pytest is a mature full-featured Python testing tool that helps - you write better programs. -

diff --git a/doc/en/_templates/slim_searchbox.html b/doc/en/_templates/slim_searchbox.html deleted file mode 100644 index e98ad4ed905..00000000000 --- a/doc/en/_templates/slim_searchbox.html +++ /dev/null @@ -1,15 +0,0 @@ -{# - basic/searchbox.html with heading removed. -#} -{%- if pagename != "search" and builder != "singlehtml" %} - - -{%- endif %} diff --git a/doc/en/adopt.rst b/doc/en/adopt.rst index 13d82bf0116..b95a117debb 100644 --- a/doc/en/adopt.rst +++ b/doc/en/adopt.rst @@ -44,7 +44,7 @@ Partner projects, sign up here! (by 22 March) What does it mean to "adopt pytest"? ----------------------------------------- -There can be many different definitions of "success". Pytest can run many nose_ and unittest_ tests by default, so using pytest as your testrunner may be possible from day 1. Job done, right? +There can be many different definitions of "success". Pytest can run many unittest_ tests by default, so using pytest as your testrunner may be possible from day 1. Job done, right? Progressive success might look like: @@ -62,7 +62,6 @@ Progressive success might look like: It may be after the month is up, the partner project decides that pytest is not right for it. That's okay - hopefully the pytest team will also learn something about its weaknesses or deficiencies. -.. _nose: nose.html .. _unittest: unittest.html .. _assert: assert.html .. _pycmd: https://bitbucket.org/hpk42/pycmd/overview diff --git a/doc/en/announce/index.rst b/doc/en/announce/index.rst index bcc0669a62f..1cc8fbaf7c0 100644 --- a/doc/en/announce/index.rst +++ b/doc/en/announce/index.rst @@ -6,6 +6,31 @@ Release announcements :maxdepth: 2 + release-8.4.2 + release-8.4.1 + release-8.4.0 + release-8.3.5 + release-8.3.4 + release-8.3.3 + release-8.3.2 + release-8.3.1 + release-8.3.0 + release-8.2.2 + release-8.2.1 + release-8.2.0 + release-8.1.2 + release-8.1.1 + release-8.1.0 + release-8.0.2 + release-8.0.1 + release-8.0.0 + release-8.0.0rc2 + release-8.0.0rc1 + release-7.4.4 + release-7.4.3 + release-7.4.2 + release-7.4.1 + release-7.4.0 release-7.3.2 release-7.3.1 release-7.3.0 diff --git a/doc/en/announce/release-2.0.0.rst b/doc/en/announce/release-2.0.0.rst index ecb1a1db988..c2a9f6da4d5 100644 --- a/doc/en/announce/release-2.0.0.rst +++ b/doc/en/announce/release-2.0.0.rst @@ -62,7 +62,7 @@ New Features - new "-q" option which decreases verbosity and prints a more nose/unittest-style "dot" output. -- many many more detailed improvements details +- many, many, more detailed improvements details Fixes ----------------------- @@ -109,7 +109,7 @@ Important Notes in conftest.py files. They will cause nothing special. - removed support for calling the pre-1.0 collection API of "run()" and "join" - removed reading option values from conftest.py files or env variables. - This can now be done much much better and easier through the ini-file + This can now be done much, much, better and easier through the ini-file mechanism and the "addopts" entry in particular. - removed the "disabled" attribute in test classes. Use the skipping and pytestmark mechanism to skip or xfail a test class. diff --git a/doc/en/announce/release-2.2.2.rst b/doc/en/announce/release-2.2.2.rst index 22ef0bc7a16..510b35ee1d0 100644 --- a/doc/en/announce/release-2.2.2.rst +++ b/doc/en/announce/release-2.2.2.rst @@ -4,7 +4,7 @@ pytest-2.2.2: bug fixes pytest-2.2.2 (updated to 2.2.3 to fix packaging issues) is a minor backward-compatible release of the versatile py.test testing tool. It contains bug fixes and a few refinements particularly to reporting with -"--collectonly", see below for betails. +"--collectonly", see below for details. For general information see here: diff --git a/doc/en/announce/release-2.3.0.rst b/doc/en/announce/release-2.3.0.rst index 6905b77b923..c405073ef40 100644 --- a/doc/en/announce/release-2.3.0.rst +++ b/doc/en/announce/release-2.3.0.rst @@ -6,10 +6,10 @@ and parametrized testing in Python. It is now easier, more efficient and more predictable to re-run the same tests with different fixture instances. Also, you can directly declare the caching "scope" of fixtures so that dependent tests throughout your whole test suite can -re-use database or other expensive fixture objects with ease. Lastly, +reuse database or other expensive fixture objects with ease. Lastly, it's possible for fixture functions (formerly known as funcarg factories) to use other fixtures, allowing for a completely modular and -re-usable fixture design. +reusable fixture design. For detailed info and tutorial-style examples, see: diff --git a/doc/en/announce/release-2.4.0.rst b/doc/en/announce/release-2.4.0.rst index 138cc89576c..9b864329674 100644 --- a/doc/en/announce/release-2.4.0.rst +++ b/doc/en/announce/release-2.4.0.rst @@ -181,7 +181,7 @@ Bug fixes: partially failed (finalizers would not always be called before) - fix issue320 - fix class scope for fixtures when mixed with - module-level functions. Thanks Anatloy Bubenkoff. + module-level functions. Thanks Anatoly Bubenkoff. - you can specify "-q" or "-qq" to get different levels of "quieter" reporting (thanks Katarzyna Jachim) diff --git a/doc/en/announce/release-2.5.0.rst b/doc/en/announce/release-2.5.0.rst index c6cdcdd8a83..fe64f1b8668 100644 --- a/doc/en/announce/release-2.5.0.rst +++ b/doc/en/announce/release-2.5.0.rst @@ -83,7 +83,7 @@ holger krekel Thanks Ralph Schmitt for the precise failure example. - fix issue244 by implementing special index for parameters to only use - indices for paramentrized test ids + indices for parametrized test ids - fix issue287 by running all finalizers but saving the exception from the first failing finalizer and re-raising it so teardown will diff --git a/doc/en/announce/release-2.6.0.rst b/doc/en/announce/release-2.6.0.rst index 56fbd6cc1e4..c00df585738 100644 --- a/doc/en/announce/release-2.6.0.rst +++ b/doc/en/announce/release-2.6.0.rst @@ -73,7 +73,7 @@ holger krekel - cleanup setup.py a bit and specify supported versions. Thanks Jurko Gospodnetic for the PR. -- change XPASS colour to yellow rather then red when tests are run +- change XPASS colour to yellow rather than red when tests are run with -v. - fix issue473: work around mock putting an unbound method into a class diff --git a/doc/en/announce/release-2.7.0.rst b/doc/en/announce/release-2.7.0.rst index 2840178a07f..83cddb34157 100644 --- a/doc/en/announce/release-2.7.0.rst +++ b/doc/en/announce/release-2.7.0.rst @@ -55,7 +55,7 @@ holger krekel github. See https://pytest.org/en/stable/contributing.html . Thanks to Anatoly for pushing and initial work on this. -- fix issue650: new option ``--docttest-ignore-import-errors`` which +- fix issue650: new option ``--doctest-ignore-import-errors`` which will turn import errors in doctests into skips. Thanks Charles Cloud for the complete PR. diff --git a/doc/en/announce/release-2.9.0.rst b/doc/en/announce/release-2.9.0.rst index 3aea08cb225..753bb7bf6f0 100644 --- a/doc/en/announce/release-2.9.0.rst +++ b/doc/en/announce/release-2.9.0.rst @@ -45,7 +45,7 @@ The py.test Development Team **New Features** * New ``pytest.mark.skip`` mark, which unconditionally skips marked tests. - Thanks :user:`MichaelAquilina` for the complete PR (:pull:`1040`). + Thanks :user:`MichaelAquilina` for the complete PR (:pr:`1040`). * ``--doctest-glob`` may now be passed multiple times in the command-line. Thanks :user:`jab` and :user:`nicoddemus` for the PR. diff --git a/doc/en/announce/release-2.9.1.rst b/doc/en/announce/release-2.9.1.rst index 6a627ad3cd6..7a46d2ae690 100644 --- a/doc/en/announce/release-2.9.1.rst +++ b/doc/en/announce/release-2.9.1.rst @@ -44,7 +44,7 @@ The py.test Development Team Thanks :user:`nicoddemus` for the PR. * Fix (:issue:`469`): junit parses report.nodeid incorrectly, when params IDs - contain ``::``. Thanks :user:`tomviner` for the PR (:pull:`1431`). + contain ``::``. Thanks :user:`tomviner` for the PR (:pr:`1431`). * Fix (:issue:`578`): SyntaxErrors containing non-ascii lines at the point of failure generated an internal diff --git a/doc/en/announce/release-2.9.2.rst b/doc/en/announce/release-2.9.2.rst index 2dc82a1117b..3e75af7fe69 100644 --- a/doc/en/announce/release-2.9.2.rst +++ b/doc/en/announce/release-2.9.2.rst @@ -44,14 +44,14 @@ The py.test Development Team * Fix Xfail does not work with condition keyword argument. Thanks :user:`astraw38` for reporting the issue (:issue:`1496`) and :user:`tomviner` - for PR the (:pull:`1524`). + for PR the (:pr:`1524`). * Fix win32 path issue when putting custom config file with absolute path in ``pytest.main("-c your_absolute_path")``. * Fix maximum recursion depth detection when raised error class is not aware of unicode/encoded bytes. - Thanks :user:`prusse-martin` for the PR (:pull:`1506`). + Thanks :user:`prusse-martin` for the PR (:pr:`1506`). * Fix ``pytest.mark.skip`` mark when used in strict mode. Thanks :user:`pquentin` for the PR and :user:`RonnyPfannschmidt` for diff --git a/doc/en/announce/release-7.4.0.rst b/doc/en/announce/release-7.4.0.rst new file mode 100644 index 00000000000..5a0d18267d3 --- /dev/null +++ b/doc/en/announce/release-7.4.0.rst @@ -0,0 +1,49 @@ +pytest-7.4.0 +======================================= + +The pytest team is proud to announce the 7.4.0 release! + +This release contains new features, improvements, and bug fixes, +the full list of changes is available in the changelog: + + https://docs.pytest.org/en/stable/changelog.html + +For complete documentation, please visit: + + https://docs.pytest.org/en/stable/ + +As usual, you can upgrade from PyPI via: + + pip install -U pytest + +Thanks to all of the contributors to this release: + +* Adam J. Stewart +* Alessio Izzo +* Alex +* Alex Lambson +* Brian Larsen +* Bruno Oliveira +* Bryan Ricker +* Chris Mahoney +* Facundo Batista +* Florian Bruhin +* Jarrett Keifer +* Kenny Y +* Miro Hrončok +* Ran Benita +* Roberto Aldera +* Ronny Pfannschmidt +* Sergey Kim +* Stefanie Molin +* Vijay Arora +* Ville Skyttä +* Zac Hatfield-Dodds +* bzoracler +* leeyueh +* nondescryptid +* theirix + + +Happy testing, +The pytest Development Team diff --git a/doc/en/announce/release-7.4.1.rst b/doc/en/announce/release-7.4.1.rst new file mode 100644 index 00000000000..efadcf919e8 --- /dev/null +++ b/doc/en/announce/release-7.4.1.rst @@ -0,0 +1,20 @@ +pytest-7.4.1 +======================================= + +pytest 7.4.1 has just been released to PyPI. + +This is a bug-fix release, being a drop-in replacement. To upgrade:: + + pip install --upgrade pytest + +The full changelog is available at https://docs.pytest.org/en/stable/changelog.html. + +Thanks to all of the contributors to this release: + +* Bruno Oliveira +* Florian Bruhin +* Ran Benita + + +Happy testing, +The pytest Development Team diff --git a/doc/en/announce/release-7.4.2.rst b/doc/en/announce/release-7.4.2.rst new file mode 100644 index 00000000000..22191e7b4f9 --- /dev/null +++ b/doc/en/announce/release-7.4.2.rst @@ -0,0 +1,18 @@ +pytest-7.4.2 +======================================= + +pytest 7.4.2 has just been released to PyPI. + +This is a bug-fix release, being a drop-in replacement. To upgrade:: + + pip install --upgrade pytest + +The full changelog is available at https://docs.pytest.org/en/stable/changelog.html. + +Thanks to all of the contributors to this release: + +* Bruno Oliveira + + +Happy testing, +The pytest Development Team diff --git a/doc/en/announce/release-7.4.3.rst b/doc/en/announce/release-7.4.3.rst new file mode 100644 index 00000000000..0f319c1e7f0 --- /dev/null +++ b/doc/en/announce/release-7.4.3.rst @@ -0,0 +1,19 @@ +pytest-7.4.3 +======================================= + +pytest 7.4.3 has just been released to PyPI. + +This is a bug-fix release, being a drop-in replacement. To upgrade:: + + pip install --upgrade pytest + +The full changelog is available at https://docs.pytest.org/en/stable/changelog.html. + +Thanks to all of the contributors to this release: + +* Bruno Oliveira +* Marc Mueller + + +Happy testing, +The pytest Development Team diff --git a/doc/en/announce/release-7.4.4.rst b/doc/en/announce/release-7.4.4.rst new file mode 100644 index 00000000000..c9633678d2e --- /dev/null +++ b/doc/en/announce/release-7.4.4.rst @@ -0,0 +1,20 @@ +pytest-7.4.4 +======================================= + +pytest 7.4.4 has just been released to PyPI. + +This is a bug-fix release, being a drop-in replacement. To upgrade:: + + pip install --upgrade pytest + +The full changelog is available at https://docs.pytest.org/en/stable/changelog.html. + +Thanks to all of the contributors to this release: + +* Bruno Oliveira +* Ran Benita +* Zac Hatfield-Dodds + + +Happy testing, +The pytest Development Team diff --git a/doc/en/announce/release-8.0.0.rst b/doc/en/announce/release-8.0.0.rst new file mode 100644 index 00000000000..00f54fd8225 --- /dev/null +++ b/doc/en/announce/release-8.0.0.rst @@ -0,0 +1,26 @@ +pytest-8.0.0 +======================================= + +The pytest team is proud to announce the 8.0.0 release! + +This release contains new features, improvements, bug fixes, and breaking changes, so users +are encouraged to take a look at the CHANGELOG carefully: + + https://docs.pytest.org/en/stable/changelog.html + +For complete documentation, please visit: + + https://docs.pytest.org/en/stable/ + +As usual, you can upgrade from PyPI via: + + pip install -U pytest + +Thanks to all of the contributors to this release: + +* Bruno Oliveira +* Ran Benita + + +Happy testing, +The pytest Development Team diff --git a/doc/en/announce/release-8.0.0rc1.rst b/doc/en/announce/release-8.0.0rc1.rst new file mode 100644 index 00000000000..547c8cbc53b --- /dev/null +++ b/doc/en/announce/release-8.0.0rc1.rst @@ -0,0 +1,82 @@ +pytest-8.0.0rc1 +======================================= + +The pytest team is proud to announce the 8.0.0rc1 release! + +This release contains new features, improvements, bug fixes, and breaking changes, so users +are encouraged to take a look at the CHANGELOG carefully: + + https://docs.pytest.org/en/stable/changelog.html + +For complete documentation, please visit: + + https://docs.pytest.org/en/stable/ + +As usual, you can upgrade from PyPI via: + + pip install -U pytest + +Thanks to all of the contributors to this release: + +* Akhilesh Ramakrishnan +* Aleksandr Brodin +* Anthony Sottile +* Arthur Richard +* Avasam +* Benjamin Schubert +* Bruno Oliveira +* Carsten Grohmann +* Cheukting +* Chris Mahoney +* Christoph Anton Mitterer +* DetachHead +* Erik Hasse +* Florian Bruhin +* Fraser Stark +* Ha Pam +* Hugo van Kemenade +* Isaac Virshup +* Israel Fruchter +* Jens Tröger +* Jon Parise +* Kenny Y +* Lesnek +* Marc Mueller +* Michał Górny +* Mihail Milushev +* Milan Lesnek +* Miro Hrončok +* Patrick Lannigan +* Ran Benita +* Reagan Lee +* Ronny Pfannschmidt +* Sadra Barikbin +* Sean Malloy +* Sean Patrick Malloy +* Sharad Nair +* Simon Blanchard +* Sourabh Beniwal +* Stefaan Lippens +* Tanya Agarwal +* Thomas Grainger +* Tom Mortimer-Jones +* Tushar Sadhwani +* Tyler Smart +* Uday Kumar +* Warren Markham +* WarrenTheRabbit +* Zac Hatfield-Dodds +* Ziad Kermadi +* akhilramkee +* antosikv +* bowugit +* mickeypash +* neilmartin2000 +* pomponchik +* ryanpudd +* touilleWoman +* ubaumann + + +Happy testing, +The pytest Development Team diff --git a/doc/en/announce/release-8.0.0rc2.rst b/doc/en/announce/release-8.0.0rc2.rst new file mode 100644 index 00000000000..1a6444c5214 --- /dev/null +++ b/doc/en/announce/release-8.0.0rc2.rst @@ -0,0 +1,32 @@ +pytest-8.0.0rc2 +======================================= + +The pytest team is proud to announce the 8.0.0rc2 prerelease! + +This is a prerelease, not intended for production use, but to test the upcoming features and improvements +in order to catch any major problems before the final version is released to the major public. + +We appreciate your help testing this out before the final release, making sure to report any +regressions to our issue tracker: + +https://github.com/pytest-dev/pytest/issues + +When doing so, please include the string ``[prerelease]`` in the title. + +You can upgrade from PyPI via: + + pip install pytest==8.0.0rc2 + +Users are encouraged to take a look at the CHANGELOG carefully: + + https://docs.pytest.org/en/release-8.0.0rc2/changelog.html + +Thanks to all the contributors to this release: + +* Ben Brown +* Bruno Oliveira +* Ran Benita + + +Happy testing, +The pytest Development Team diff --git a/doc/en/announce/release-8.0.1.rst b/doc/en/announce/release-8.0.1.rst new file mode 100644 index 00000000000..7d828e55bd9 --- /dev/null +++ b/doc/en/announce/release-8.0.1.rst @@ -0,0 +1,21 @@ +pytest-8.0.1 +======================================= + +pytest 8.0.1 has just been released to PyPI. + +This is a bug-fix release, being a drop-in replacement. To upgrade:: + + pip install --upgrade pytest + +The full changelog is available at https://docs.pytest.org/en/stable/changelog.html. + +Thanks to all of the contributors to this release: + +* Bruno Oliveira +* Clément Robert +* Pierre Sassoulas +* Ran Benita + + +Happy testing, +The pytest Development Team diff --git a/doc/en/announce/release-8.0.2.rst b/doc/en/announce/release-8.0.2.rst new file mode 100644 index 00000000000..c42159c57cf --- /dev/null +++ b/doc/en/announce/release-8.0.2.rst @@ -0,0 +1,18 @@ +pytest-8.0.2 +======================================= + +pytest 8.0.2 has just been released to PyPI. + +This is a bug-fix release, being a drop-in replacement. To upgrade:: + + pip install --upgrade pytest + +The full changelog is available at https://docs.pytest.org/en/stable/changelog.html. + +Thanks to all of the contributors to this release: + +* Ran Benita + + +Happy testing, +The pytest Development Team diff --git a/doc/en/announce/release-8.1.0.rst b/doc/en/announce/release-8.1.0.rst new file mode 100644 index 00000000000..62cafdd78bb --- /dev/null +++ b/doc/en/announce/release-8.1.0.rst @@ -0,0 +1,54 @@ +pytest-8.1.0 +======================================= + +The pytest team is proud to announce the 8.1.0 release! + +This release contains new features, improvements, and bug fixes, +the full list of changes is available in the changelog: + + https://docs.pytest.org/en/stable/changelog.html + +For complete documentation, please visit: + + https://docs.pytest.org/en/stable/ + +As usual, you can upgrade from PyPI via: + + pip install -U pytest + +Thanks to all of the contributors to this release: + +* Ben Brown +* Ben Leith +* Bruno Oliveira +* Clément Robert +* Dave Hall +* Dương Quốc Khánh +* Eero Vaher +* Eric Larson +* Fabian Sturm +* Faisal Fawad +* Florian Bruhin +* Franck Charras +* Joachim B Haga +* John Litborn +* Loïc Estève +* Marc Bresson +* Patrick Lannigan +* Pierre Sassoulas +* Ran Benita +* Reagan Lee +* Ronny Pfannschmidt +* Russell Martin +* clee2000 +* donghui +* faph +* jakkdl +* mrbean-bremen +* robotherapist +* whysage +* woutdenolf + + +Happy testing, +The pytest Development Team diff --git a/doc/en/announce/release-8.1.1.rst b/doc/en/announce/release-8.1.1.rst new file mode 100644 index 00000000000..89b617b487d --- /dev/null +++ b/doc/en/announce/release-8.1.1.rst @@ -0,0 +1,18 @@ +pytest-8.1.1 +======================================= + +pytest 8.1.1 has just been released to PyPI. + +This is a bug-fix release, being a drop-in replacement. To upgrade:: + + pip install --upgrade pytest + +The full changelog is available at https://docs.pytest.org/en/stable/changelog.html. + +Thanks to all of the contributors to this release: + +* Ran Benita + + +Happy testing, +The pytest Development Team diff --git a/doc/en/announce/release-8.1.2.rst b/doc/en/announce/release-8.1.2.rst new file mode 100644 index 00000000000..19e41e0f7c5 --- /dev/null +++ b/doc/en/announce/release-8.1.2.rst @@ -0,0 +1,18 @@ +pytest-8.1.2 +======================================= + +pytest 8.1.2 has just been released to PyPI. + +This is a bug-fix release, being a drop-in replacement. To upgrade:: + + pip install --upgrade pytest + +The full changelog is available at https://docs.pytest.org/en/stable/changelog.html. + +Thanks to all of the contributors to this release: + +* Bruno Oliveira + + +Happy testing, +The pytest Development Team diff --git a/doc/en/announce/release-8.2.0.rst b/doc/en/announce/release-8.2.0.rst new file mode 100644 index 00000000000..2a63c8d8722 --- /dev/null +++ b/doc/en/announce/release-8.2.0.rst @@ -0,0 +1,43 @@ +pytest-8.2.0 +======================================= + +The pytest team is proud to announce the 8.2.0 release! + +This release contains new features, improvements, and bug fixes, +the full list of changes is available in the changelog: + + https://docs.pytest.org/en/stable/changelog.html + +For complete documentation, please visit: + + https://docs.pytest.org/en/stable/ + +As usual, you can upgrade from PyPI via: + + pip install -U pytest + +Thanks to all of the contributors to this release: + +* Bruno Oliveira +* Daniel Miller +* Florian Bruhin +* HolyMagician03-UMich +* John Litborn +* Levon Saldamli +* Linghao Zhang +* Manuel López-Ibáñez +* Pierre Sassoulas +* Ran Benita +* Ronny Pfannschmidt +* Sebastian Meyer +* Shekhar verma +* Tamir Duberstein +* Tobias Stoeckmann +* dj +* jakkdl +* poulami-sau +* tserg + + +Happy testing, +The pytest Development Team diff --git a/doc/en/announce/release-8.2.1.rst b/doc/en/announce/release-8.2.1.rst new file mode 100644 index 00000000000..4452edec110 --- /dev/null +++ b/doc/en/announce/release-8.2.1.rst @@ -0,0 +1,19 @@ +pytest-8.2.1 +======================================= + +pytest 8.2.1 has just been released to PyPI. + +This is a bug-fix release, being a drop-in replacement. To upgrade:: + + pip install --upgrade pytest + +The full changelog is available at https://docs.pytest.org/en/stable/changelog.html. + +Thanks to all of the contributors to this release: + +* Bruno Oliveira +* Ran Benita + + +Happy testing, +The pytest Development Team diff --git a/doc/en/announce/release-8.2.2.rst b/doc/en/announce/release-8.2.2.rst new file mode 100644 index 00000000000..3b1d93bd08b --- /dev/null +++ b/doc/en/announce/release-8.2.2.rst @@ -0,0 +1,19 @@ +pytest-8.2.2 +======================================= + +pytest 8.2.2 has just been released to PyPI. + +This is a bug-fix release, being a drop-in replacement. To upgrade:: + + pip install --upgrade pytest + +The full changelog is available at https://docs.pytest.org/en/stable/changelog.html. + +Thanks to all of the contributors to this release: + +* Bruno Oliveira +* Ran Benita + + +Happy testing, +The pytest Development Team diff --git a/doc/en/announce/release-8.3.0.rst b/doc/en/announce/release-8.3.0.rst new file mode 100644 index 00000000000..ec5cd3d0db9 --- /dev/null +++ b/doc/en/announce/release-8.3.0.rst @@ -0,0 +1,60 @@ +pytest-8.3.0 +======================================= + +The pytest team is proud to announce the 8.3.0 release! + +This release contains new features, improvements, and bug fixes, +the full list of changes is available in the changelog: + + https://docs.pytest.org/en/stable/changelog.html + +For complete documentation, please visit: + + https://docs.pytest.org/en/stable/ + +As usual, you can upgrade from PyPI via: + + pip install -U pytest + +Thanks to all of the contributors to this release: + +* Anita Hammer +* Ben Brown +* Brian Okken +* Bruno Oliveira +* Cornelius Riemenschneider +* Farbod Ahmadian +* Florian Bruhin +* Hynek Schlawack +* James Frost +* Jason R. Coombs +* Jelle Zijlstra +* Josh Soref +* Marc Bresson +* Michael Vogt +* Nathan Goldbaum +* Nicolas Simonds +* Oliver Bestwalter +* Pavel Březina +* Pierre Sassoulas +* Pradyun Gedam +* Ran Benita +* Ronny Pfannschmidt +* SOUBHIK KUMAR MITRA +* Sam Jirovec +* Stavros Ntentos +* Sviatoslav Sydorenko +* Sviatoslav Sydorenko (Святослав Сидоренко) +* Tomasz Kłoczko +* Virendra Patil +* Yutian Li +* Zach Snicker +* dj +* holger krekel +* joseph-sentry +* lovetheguitar +* neutraljump + + +Happy testing, +The pytest Development Team diff --git a/doc/en/announce/release-8.3.1.rst b/doc/en/announce/release-8.3.1.rst new file mode 100644 index 00000000000..0fb9b40d9c7 --- /dev/null +++ b/doc/en/announce/release-8.3.1.rst @@ -0,0 +1,19 @@ +pytest-8.3.1 +======================================= + +pytest 8.3.1 has just been released to PyPI. + +This is a bug-fix release, being a drop-in replacement. To upgrade:: + + pip install --upgrade pytest + +The full changelog is available at https://docs.pytest.org/en/stable/changelog.html. + +Thanks to all of the contributors to this release: + +* Bruno Oliveira +* Ran Benita + + +Happy testing, +The pytest Development Team diff --git a/doc/en/announce/release-8.3.2.rst b/doc/en/announce/release-8.3.2.rst new file mode 100644 index 00000000000..1e4a071692c --- /dev/null +++ b/doc/en/announce/release-8.3.2.rst @@ -0,0 +1,19 @@ +pytest-8.3.2 +======================================= + +pytest 8.3.2 has just been released to PyPI. + +This is a bug-fix release, being a drop-in replacement. To upgrade:: + + pip install --upgrade pytest + +The full changelog is available at https://docs.pytest.org/en/stable/changelog.html. + +Thanks to all of the contributors to this release: + +* Ran Benita +* Ronny Pfannschmidt + + +Happy testing, +The pytest Development Team diff --git a/doc/en/announce/release-8.3.3.rst b/doc/en/announce/release-8.3.3.rst new file mode 100644 index 00000000000..5e3eb36b921 --- /dev/null +++ b/doc/en/announce/release-8.3.3.rst @@ -0,0 +1,31 @@ +pytest-8.3.3 +======================================= + +pytest 8.3.3 has just been released to PyPI. + +This is a bug-fix release, being a drop-in replacement. To upgrade:: + + pip install --upgrade pytest + +The full changelog is available at https://docs.pytest.org/en/stable/changelog.html. + +Thanks to all of the contributors to this release: + +* Anthony Sottile +* Avasam +* Bruno Oliveira +* Christian Clauss +* Eugene Mwangi +* Florian Bruhin +* GTowers1 +* Nauman Ahmed +* Pierre Sassoulas +* Reagan Lee +* Ronny Pfannschmidt +* Stefaan Lippens +* Sviatoslav Sydorenko (Святослав Сидоренко) +* dongfangtianyu + + +Happy testing, +The pytest Development Team diff --git a/doc/en/announce/release-8.3.4.rst b/doc/en/announce/release-8.3.4.rst new file mode 100644 index 00000000000..f76d60396dc --- /dev/null +++ b/doc/en/announce/release-8.3.4.rst @@ -0,0 +1,30 @@ +pytest-8.3.4 +======================================= + +pytest 8.3.4 has just been released to PyPI. + +This is a bug-fix release, being a drop-in replacement. To upgrade:: + + pip install --upgrade pytest + +The full changelog is available at https://docs.pytest.org/en/stable/changelog.html. + +Thanks to all of the contributors to this release: + +* Bruno Oliveira +* Florian Bruhin +* Frank Hoffmann +* Jakob van Santen +* Leonardus Chen +* Pierre Sassoulas +* Pradeep Kumar +* Ran Benita +* Serge Smertin +* Stefaan Lippens +* Sviatoslav Sydorenko (Святослав Сидоренко) +* dongfangtianyu +* suspe + + +Happy testing, +The pytest Development Team diff --git a/doc/en/announce/release-8.3.5.rst b/doc/en/announce/release-8.3.5.rst new file mode 100644 index 00000000000..3de02c1d7a4 --- /dev/null +++ b/doc/en/announce/release-8.3.5.rst @@ -0,0 +1,26 @@ +pytest-8.3.5 +======================================= + +pytest 8.3.5 has just been released to PyPI. + +This is a bug-fix release, being a drop-in replacement. + +The full changelog is available at https://docs.pytest.org/en/stable/changelog.html. + +Thanks to all of the contributors to this release: + +* Bruno Oliveira +* Florian Bruhin +* John Litborn +* Kenny Y +* Ran Benita +* Sadra Barikbin +* Vincent (Wen Yu) Ge +* delta87 +* dongfangtianyu +* mwychung +* 🇺🇦 Sviatoslav Sydorenko (Святослав Сидоренко) + + +Happy testing, +The pytest Development Team diff --git a/doc/en/announce/release-8.4.0.rst b/doc/en/announce/release-8.4.0.rst new file mode 100644 index 00000000000..65e80a55919 --- /dev/null +++ b/doc/en/announce/release-8.4.0.rst @@ -0,0 +1,106 @@ +pytest-8.4.0 +======================================= + +The pytest team is proud to announce the 8.4.0 release! + +This release contains new features, improvements, and bug fixes, +the full list of changes is available in the changelog: + + https://docs.pytest.org/en/stable/changelog.html + +For complete documentation, please visit: + + https://docs.pytest.org/en/stable/ + +As usual, you can upgrade from PyPI via: + + pip install -U pytest + +Thanks to all of the contributors to this release: + +* Adam Johnson +* Ammar Askar +* Andrew Pikul +* Andy Freeland +* Anthony Sottile +* Anton Zhilin +* Arpit Gupta +* Ashley Whetter +* Avasam +* Bahram Farahmand +* Brigitta Sipőcz +* Bruno Oliveira +* Callum Scott +* Christian Clauss +* Christopher Head +* Daara +* Daniel Miller +* Deysha Rivera +* Emil Hjelm +* Eugene Mwangi +* Florian Bruhin +* Frank Hoffmann +* GTowers1 +* Guillaume Gauvrit +* Gupta Arpit +* Harmin Parra Rueda +* Jakob van Santen +* Jason N. White +* Jiajun Xu +* John Litborn +* Julian Valentin +* JulianJvn +* Kenny Y +* Leonardus Chen +* Marcelo Duarte Trevisani +* Marcin Augustynów +* Natalia Mokeeva +* Nathan Rousseau +* Nauman Ahmed +* Nick Murphy +* Oleksandr Zavertniev +* Pavel Zhukov +* Peter Gessler +* Pierre Sassoulas +* Pradeep Kumar +* Ran Benita +* Reagan Lee +* Rob Arrow +* Ronny Pfannschmidt +* Sadra Barikbin +* Sam Bull +* Samuel Bronson +* Sashko +* Serge Smertin +* Shaygan Hooshyari +* Stefaan Lippens +* Stefan Zimmermann +* Stephen McDowell +* Sviatoslav Sydorenko +* Sviatoslav Sydorenko (Святослав Сидоренко) +* Thomas Grainger +* TobiMcNamobi +* Tobias Alex-Petersen +* Tony Narlock +* Vincent (Wen Yu) Ge +* Virendra Patil +* Will Riley +* Yann Dirson +* Zac Hatfield-Dodds +* delta87 +* dongfangtianyu +* eitanwass +* fazeelghafoor +* ikappaki +* jakkdl +* maugu +* moajo +* mwychung +* polkapolka +* suspe +* sven +* 🇺🇦 Sviatoslav Sydorenko (Святослав Сидоренко) + + +Happy testing, +The pytest Development Team diff --git a/doc/en/announce/release-8.4.1.rst b/doc/en/announce/release-8.4.1.rst new file mode 100644 index 00000000000..07ee26187a7 --- /dev/null +++ b/doc/en/announce/release-8.4.1.rst @@ -0,0 +1,21 @@ +pytest-8.4.1 +======================================= + +pytest 8.4.1 has just been released to PyPI. + +This is a bug-fix release, being a drop-in replacement. + +The full changelog is available at https://docs.pytest.org/en/stable/changelog.html. + +Thanks to all of the contributors to this release: + +* Bruno Oliveira +* Iwithyou2025 +* John Litborn +* Martin Fischer +* Ran Benita +* SarahPythonista + + +Happy testing, +The pytest Development Team diff --git a/doc/en/announce/release-8.4.2.rst b/doc/en/announce/release-8.4.2.rst new file mode 100644 index 00000000000..58a842c4d4b --- /dev/null +++ b/doc/en/announce/release-8.4.2.rst @@ -0,0 +1,27 @@ +pytest-8.4.2 +======================================= + +pytest 8.4.2 has just been released to PyPI. + +This is a bug-fix release, being a drop-in replacement. + +The full changelog is available at https://docs.pytest.org/en/stable/changelog.html. + +Thanks to all of the contributors to this release: + +* AD +* Aditi De +* Bruno Oliveira +* Florian Bruhin +* John Litborn +* Liam DeVoe +* Marc Mueller +* NayeemJohn +* Olivier Grisel +* Ran Benita +* bengartner +* 🇺🇦 Sviatoslav Sydorenko (Святослав Сидоренко) + + +Happy testing, +The pytest Development Team diff --git a/doc/en/announce/sprint2016.rst b/doc/en/announce/sprint2016.rst index 8e706589876..8d47a205c71 100644 --- a/doc/en/announce/sprint2016.rst +++ b/doc/en/announce/sprint2016.rst @@ -49,7 +49,7 @@ place on 20th, 21st, 22nd, 24th and 25th. On the 23rd we took a break day for some hot hiking in the Black Forest. Sprint activity was organised heavily around pairing, with plenty of group -discusssions to take advantage of the high bandwidth, and lightning talks +discussions to take advantage of the high bandwidth, and lightning talks as well. diff --git a/doc/en/backwards-compatibility.rst b/doc/en/backwards-compatibility.rst index ea0c6a71a28..82f678b4dea 100644 --- a/doc/en/backwards-compatibility.rst +++ b/doc/en/backwards-compatibility.rst @@ -5,30 +5,26 @@ Backwards Compatibility Policy .. versionadded: 6.0 -pytest is actively evolving and is a project that has been decades in the making, -we keep learning about new and better structures to express different details about testing. +Pytest is an actively evolving project that has been decades in the making. +We keep learning about new and better structures to express different details about testing. -While we implement those modifications we try to ensure an easy transition and don't want to impose unnecessary churn on our users and community/plugin authors. +While we implement those modifications, we try to ensure an easy transition and don't want to impose unnecessary churn on our users and community/plugin authors. As of now, pytest considers multiple types of backward compatibility transitions: -a) trivial: APIs which trivially translate to the new mechanism, - and do not cause problematic changes. +a) trivial: APIs that trivially translate to the new mechanism and do not cause problematic changes. - We try to support those indefinitely while encouraging users to switch to newer/better mechanisms through documentation. + We try to support those indefinitely while encouraging users to switch to newer or better mechanisms through documentation. -b) transitional: the old and new API don't conflict - and we can help users transition by using warnings, while supporting both for a prolonged time. +b) transitional: the old and new APIs don't conflict, and we can help users transition by using warnings while supporting both for a prolonged period of time. - We will only start the removal of deprecated functionality in major releases (e.g. if we deprecate something in 3.0 we will start to remove it in 4.0), and keep it around for at least two minor releases (e.g. if we deprecate something in 3.9 and 4.0 is the next release, we start to remove it in 5.0, not in 4.0). + We will only start the removal of deprecated functionality in major releases (e.g., if we deprecate something in 3.0, we will start to remove it in 4.0), and keep it around for at least two minor releases (e.g., if we deprecate something in 3.9 and 4.0 is the next release, we start to remove it in 5.0, not in 4.0). - A deprecated feature scheduled to be removed in major version X will use the warning class `PytestRemovedInXWarning` (a subclass of :class:`~pytest.PytestDeprecationwarning`). + A deprecated feature scheduled to be removed in major version X will use the warning class `PytestRemovedInXWarning` (a subclass of :class:`~pytest.PytestDeprecationWarning`). - When the deprecation expires (e.g. 4.0 is released), we won't remove the deprecated functionality immediately, but will use the standard warning filters to turn `PytestRemovedInXWarning` (e.g. `PytestRemovedIn4Warning`) into **errors** by default. This approach makes it explicit that removal is imminent, and still gives you time to turn the deprecated feature into a warning instead of an error so it can be dealt with in your own time. In the next minor release (e.g. 4.1), the feature will be effectively removed. + When the deprecation expires (e.g., 4.0 is released), we won't remove the deprecated functionality immediately but will use the standard warning filters to turn `PytestRemovedInXWarning` (e.g., `PytestRemovedIn4Warning`) into **errors** by default. This approach makes it explicit that removal is imminent and still gives you time to turn the deprecated feature into a warning instead of an error so it can be dealt with in your own time. In the next minor release (e.g., 4.1), the feature will be effectively removed. - -c) true breakage: should only be considered when normal transition is unreasonably unsustainable and would offset important development/features by years. - In addition, they should be limited to APIs where the number of actual users is very small (for example only impacting some plugins), and can be coordinated with the community in advance. +c) True breakage should only be considered when a normal transition is unreasonably unsustainable and would offset important developments or features by years. In addition, they should be limited to APIs where the number of actual users is very small (for example, only impacting some plugins) and can be coordinated with the community in advance. Examples for such upcoming changes: @@ -62,11 +58,11 @@ Focus primary on smooth transition - stance (pre 6.0) Keeping backwards compatibility has a very high priority in the pytest project. Although we have deprecated functionality over the years, most of it is still supported. All deprecations in pytest were done because simpler or more efficient ways of accomplishing the same tasks have emerged, making the old way of doing things unnecessary. -With the pytest 3.0 release we introduced a clear communication scheme for when we will actually remove the old busted joint and politely ask you to use the new hotness instead, while giving you enough time to adjust your tests or raise concerns if there are valid reasons to keep deprecated functionality around. +With the pytest 3.0 release, we introduced a clear communication scheme for when we will actually remove the old busted joint and politely ask you to use the new hotness instead, while giving you enough time to adjust your tests or raise concerns if there are valid reasons to keep deprecated functionality around. -To communicate changes we issue deprecation warnings using a custom warning hierarchy (see :ref:`internal-warnings`). These warnings may be suppressed using the standard means: ``-W`` command-line flag or ``filterwarnings`` ini options (see :ref:`warnings`), but we suggest to use these sparingly and temporarily, and heed the warnings when possible. +To communicate changes, we issue deprecation warnings using a custom warning hierarchy (see :ref:`internal-warnings`). These warnings may be suppressed using the standard means: ``-W`` command-line flag or ``filterwarnings`` ini options (see :ref:`warnings`), but we suggest to use these sparingly and temporarily, and heed the warnings when possible. -We will only start the removal of deprecated functionality in major releases (e.g. if we deprecate something in 3.0 we will start to remove it in 4.0), and keep it around for at least two minor releases (e.g. if we deprecate something in 3.9 and 4.0 is the next release, we start to remove it in 5.0, not in 4.0). +We will only start the removal of deprecated functionality in major releases (e.g. if we deprecate something in 3.0, we will start to remove it in 4.0), and keep it around for at least two minor releases (e.g. if we deprecate something in 3.9 and 4.0 is the next release, we start to remove it in 5.0, not in 4.0). When the deprecation expires (e.g. 4.0 is released), we won't remove the deprecated functionality immediately, but will use the standard warning filters to turn them into **errors** by default. This approach makes it explicit that removal is imminent, and still gives you time to turn the deprecated feature into a warning instead of an error so it can be dealt with in your own time. In the next minor release (e.g. 4.1), the feature will be effectively removed. @@ -87,6 +83,8 @@ Released pytest versions support all Python versions that are actively maintaine ============== =================== pytest version min. Python version ============== =================== +8.4+ 3.9+ +8.0+ 3.8+ 7.1+ 3.7+ 6.2 - 7.0 3.6+ 5.0 - 6.1 3.5+ diff --git a/doc/en/broken-dep-constraints.txt b/doc/en/broken-dep-constraints.txt new file mode 100644 index 00000000000..1488e06fa23 --- /dev/null +++ b/doc/en/broken-dep-constraints.txt @@ -0,0 +1,2 @@ +# This file contains transitive dependencies that need to be pinned for some reason. +# Eventually this file will be empty, but in this case keep it around for future use. diff --git a/doc/en/builtin.rst b/doc/en/builtin.rst index 7ae185f6c7d..ca350c5f3dd 100644 --- a/doc/en/builtin.rst +++ b/doc/en/builtin.rst @@ -18,11 +18,11 @@ For information about fixtures, see :ref:`fixtures`. To see a complete list of a $ pytest --fixtures -v =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-7.x.y, pluggy-1.x.y -- $PYTHON_PREFIX/bin/python + platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y -- $PYTHON_PREFIX/bin/python cachedir: .pytest_cache rootdir: /home/sweet/project collected 0 items - cache -- .../_pytest/cacheprovider.py:510 + cache -- .../_pytest/cacheprovider.py:555 Return a cache object that can persist state between testing sessions. cache.get(key, default) @@ -33,7 +33,48 @@ For information about fixtures, see :ref:`fixtures`. To see a complete list of a Values can be any object handled by the json stdlib module. - capsysbinary -- .../_pytest/capture.py:1001 + capsys -- .../_pytest/capture.py:1000 + Enable text capturing of writes to ``sys.stdout`` and ``sys.stderr``. + + The captured output is made available via ``capsys.readouterr()`` method + calls, which return a ``(out, err)`` namedtuple. + ``out`` and ``err`` will be ``text`` objects. + + Returns an instance of :class:`CaptureFixture[str] `. + + Example: + + .. code-block:: python + + def test_output(capsys): + print("hello") + captured = capsys.readouterr() + assert captured.out == "hello\n" + + capteesys -- .../_pytest/capture.py:1028 + Enable simultaneous text capturing and pass-through of writes + to ``sys.stdout`` and ``sys.stderr`` as defined by ``--capture=``. + + + The captured output is made available via ``capteesys.readouterr()`` method + calls, which return a ``(out, err)`` namedtuple. + ``out`` and ``err`` will be ``text`` objects. + + The output is also passed-through, allowing it to be "live-printed", + reported, or both as defined by ``--capture=``. + + Returns an instance of :class:`CaptureFixture[str] `. + + Example: + + .. code-block:: python + + def test_output(capteesys): + print("hello") + captured = capteesys.readouterr() + assert captured.out == "hello\n" + + capsysbinary -- .../_pytest/capture.py:1063 Enable bytes capturing of writes to ``sys.stdout`` and ``sys.stderr``. The captured output is made available via ``capsysbinary.readouterr()`` @@ -51,7 +92,7 @@ For information about fixtures, see :ref:`fixtures`. To see a complete list of a captured = capsysbinary.readouterr() assert captured.out == b"hello\n" - capfd -- .../_pytest/capture.py:1029 + capfd -- .../_pytest/capture.py:1091 Enable text capturing of writes to file descriptors ``1`` and ``2``. The captured output is made available via ``capfd.readouterr()`` method @@ -69,7 +110,7 @@ For information about fixtures, see :ref:`fixtures`. To see a complete list of a captured = capfd.readouterr() assert captured.out == "hello\n" - capfdbinary -- .../_pytest/capture.py:1057 + capfdbinary -- .../_pytest/capture.py:1119 Enable bytes capturing of writes to file descriptors ``1`` and ``2``. The captured output is made available via ``capfd.readouterr()`` method @@ -87,25 +128,7 @@ For information about fixtures, see :ref:`fixtures`. To see a complete list of a captured = capfdbinary.readouterr() assert captured.out == b"hello\n" - capsys -- .../_pytest/capture.py:973 - Enable text capturing of writes to ``sys.stdout`` and ``sys.stderr``. - - The captured output is made available via ``capsys.readouterr()`` method - calls, which return a ``(out, err)`` namedtuple. - ``out`` and ``err`` will be ``text`` objects. - - Returns an instance of :class:`CaptureFixture[str] `. - - Example: - - .. code-block:: python - - def test_output(capsys): - print("hello") - captured = capsys.readouterr() - assert captured.out == "hello\n" - - doctest_namespace [session scope] -- .../_pytest/doctest.py:737 + doctest_namespace [session scope] -- .../_pytest/doctest.py:740 Fixture that returns a :py:class:`dict` that will be injected into the namespace of doctests. @@ -119,17 +142,17 @@ For information about fixtures, see :ref:`fixtures`. To see a complete list of a For more details: :ref:`doctest_namespace`. - pytestconfig [session scope] -- .../_pytest/fixtures.py:1360 + pytestconfig [session scope] -- .../_pytest/fixtures.py:1425 Session-scoped fixture that returns the session's :class:`pytest.Config` object. Example:: def test_foo(pytestconfig): - if pytestconfig.getoption("verbose") > 0: + if pytestconfig.get_verbosity() > 0: ... - record_property -- .../_pytest/junitxml.py:282 + record_property -- .../_pytest/junitxml.py:277 Add extra properties to the calling test. User properties become part of the test report and are available to the @@ -143,13 +166,13 @@ For information about fixtures, see :ref:`fixtures`. To see a complete list of a def test_function(record_property): record_property("example_key", 1) - record_xml_attribute -- .../_pytest/junitxml.py:305 + record_xml_attribute -- .../_pytest/junitxml.py:300 Add extra xml attributes to the tag for the calling test. The fixture is callable with ``name, value``. The value is automatically XML-encoded. - record_testsuite_property [session scope] -- .../_pytest/junitxml.py:343 + record_testsuite_property [session scope] -- .../_pytest/junitxml.py:338 Record a new ```` tag as child of the root ````. This is suitable to writing global information regarding the entire test @@ -174,20 +197,15 @@ For information about fixtures, see :ref:`fixtures`. To see a complete list of a `pytest-xdist `__ plugin. See :issue:`7767` for details. - tmpdir_factory [session scope] -- .../_pytest/legacypath.py:302 + tmpdir_factory [session scope] -- .../_pytest/legacypath.py:298 Return a :class:`pytest.TempdirFactory` instance for the test session. - tmpdir -- .../_pytest/legacypath.py:309 - Return a temporary directory path object which is unique to each test - function invocation, created as a sub directory of the base temporary - directory. - - By default, a new base temporary directory is created each test session, - and old bases are removed after 3 sessions, to aid in debugging. If - ``--basetemp`` is used then it is cleared each session. See :ref:`base - temporary directory`. - - The returned object is a `legacy_path`_ object. + tmpdir -- .../_pytest/legacypath.py:305 + Return a temporary directory (as `legacy_path`_ object) + which is unique to each test function invocation. + The temporary directory is created as a subdirectory + of the base temporary directory, with configurable retention, + as discussed in :ref:`temporary directory location and retention`. .. note:: These days, it is preferred to use ``tmp_path``. @@ -196,7 +214,7 @@ For information about fixtures, see :ref:`fixtures`. To see a complete list of a .. _legacy_path: https://py.readthedocs.io/en/latest/path.html - caplog -- .../_pytest/logging.py:498 + caplog -- .../_pytest/logging.py:596 Access and control log capturing. Captured logs are available through the following properties/methods:: @@ -207,7 +225,7 @@ For information about fixtures, see :ref:`fixtures`. To see a complete list of a * caplog.record_tuples -> list of (logger_name, level, message) tuples * caplog.clear() -> clear captured records and formatted log output string - monkeypatch -- .../_pytest/monkeypatch.py:30 + monkeypatch -- .../_pytest/monkeypatch.py:31 A convenient fixture for monkey-patching. The fixture provides these methods to modify objects, dictionaries, or @@ -231,28 +249,20 @@ For information about fixtures, see :ref:`fixtures`. To see a complete list of a To undo modifications done by the fixture in a contained scope, use :meth:`context() `. - recwarn -- .../_pytest/recwarn.py:30 + recwarn -- .../_pytest/recwarn.py:34 Return a :class:`WarningsRecorder` instance that records all warnings emitted by test functions. - See https://docs.pytest.org/en/latest/how-to/capture-warnings.html for information - on warning categories. + See :ref:`warnings` for information on warning categories. - tmp_path_factory [session scope] -- .../_pytest/tmpdir.py:245 + tmp_path_factory [session scope] -- .../_pytest/tmpdir.py:240 Return a :class:`pytest.TempPathFactory` instance for the test session. - tmp_path -- .../_pytest/tmpdir.py:260 - Return a temporary directory path object which is unique to each test - function invocation, created as a sub directory of the base temporary - directory. - - By default, a new base temporary directory is created each test session, - and old bases are removed after 3 sessions, to aid in debugging. - This behavior can be configured with :confval:`tmp_path_retention_count` and - :confval:`tmp_path_retention_policy`. - If ``--basetemp`` is used then it is cleared each session. See :ref:`base - temporary directory`. - - The returned object is a :class:`pathlib.Path` object. + tmp_path -- .../_pytest/tmpdir.py:255 + Return a temporary directory (as :class:`pathlib.Path` object) + which is unique to each test function invocation. + The temporary directory is created as a subdirectory + of the base temporary directory, with configurable retention, + as discussed in :ref:`temporary directory location and retention`. ========================== no tests ran in 0.12s =========================== diff --git a/doc/en/changelog.rst b/doc/en/changelog.rst index cdc9e66f68b..89f1c4e61b3 100644 --- a/doc/en/changelog.rst +++ b/doc/en/changelog.rst @@ -19,15 +19,1837 @@ with advance notice in the **Deprecations** section of releases. we named the news folder changelog -.. only:: changelog_towncrier_draft +.. only:: not is_release - .. The 'changelog_towncrier_draft' tag is included by our 'tox -e docs', - but not on readthedocs. + To be included in v\ |release| (if present) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - .. include:: _changelog_towncrier_draft.rst + .. towncrier-draft-entries:: |release| [UNRELEASED DRAFT] + + Released versions + ^^^^^^^^^^^^^^^^^ .. towncrier release notes start +pytest 8.4.2 (2025-09-03) +========================= + +Bug fixes +--------- + +- `#13478 `_: Fixed a crash when using :confval:`console_output_style` with ``times`` and a module is skipped. + + +- `#13530 `_: Fixed a crash when using :func:`pytest.approx` and :class:`decimal.Decimal` instances with the :class:`decimal.FloatOperation` trap set. + + +- `#13549 `_: No longer evaluate type annotations in Python ``3.14`` when inspecting function signatures. + + This prevents crashes during module collection when modules do not explicitly use ``from __future__ import annotations`` and import types for annotations within a ``if TYPE_CHECKING:`` block. + + +- `#13559 `_: Added missing `int` and `float` variants to the `Literal` type annotation of the `type` parameter in :meth:`pytest.Parser.addini`. + + +- `#13563 `_: :func:`pytest.approx` now only imports ``numpy`` if NumPy is already in ``sys.modules``. This fixes unconditional import behavior introduced in `8.4.0`. + + + +Improved documentation +---------------------- + +- `#13577 `_: Clarify that ``pytest_generate_tests`` is discovered in test modules/classes; other hooks must be in ``conftest.py`` or plugins. + + + +Contributor-facing changes +-------------------------- + +- `#13480 `_: Self-testing: fixed a few test failures when run with ``-Wdefault`` or a similar override. + + +- `#13547 `_: Self-testing: corrected expected message for ``test_doctest_unexpected_exception`` in Python ``3.14``. + + +- `#13684 `_: Make pytest's own testsuite insensitive to the presence of the ``CI`` environment variable -- by :user:`ogrisel`. + + +pytest 8.4.1 (2025-06-17) +========================= + +Bug fixes +--------- + +- `#13461 `_: Corrected ``_pytest.terminal.TerminalReporter.isatty`` to support + being called as a method. Before it was just a boolean which could + break correct code when using ``-o log_cli=true``). + + +- `#13477 `_: Reintroduced :class:`pytest.PytestReturnNotNoneWarning` which was removed by accident in pytest `8.4`. + + This warning is raised when a test functions returns a value other than ``None``, which is often a mistake made by beginners. + + See :ref:`return-not-none` for more information. + + +- `#13497 `_: Fixed compatibility with ``Twisted 25+``. + + + +Improved documentation +---------------------- + +- `#13492 `_: Fixed outdated warning about ``faulthandler`` not working on Windows. + + +pytest 8.4.0 (2025-06-02) +========================= + +Removals and backward incompatible breaking changes +--------------------------------------------------- + +- `#11372 `_: Async tests will now fail, instead of warning+skipping, if you don't have any suitable plugin installed. + + +- `#12346 `_: Tests will now fail, instead of raising a warning, if they return any value other than None. + + +- `#12874 `_: We dropped support for Python 3.8 following its end of life (2024-10-07). + + +- `#12960 `_: Test functions containing a yield now cause an explicit error. They have not been run since pytest 4.0, and were previously marked as an expected failure and deprecation warning. + + See :ref:`the docs ` for more information. + + + +Deprecations (removal in next major release) +-------------------------------------------- + +- `#10839 `_: Requesting an asynchronous fixture without a `pytest_fixture_setup` hook that resolves it will now give a DeprecationWarning. This most commonly happens if a sync test requests an async fixture. This should have no effect on a majority of users with async tests or fixtures using async pytest plugins, but may affect non-standard hook setups or ``autouse=True``. For guidance on how to work around this warning see :ref:`sync-test-async-fixture`. + + + +New features +------------ + +- `#11538 `_: Added :class:`pytest.RaisesGroup` as an equivalent to :func:`pytest.raises` for expecting :exc:`ExceptionGroup`. Also adds :class:`pytest.RaisesExc` which is now the logic behind :func:`pytest.raises` and used as parameter to :class:`pytest.RaisesGroup`. ``RaisesGroup`` includes the ability to specify multiple different expected exceptions, the structure of nested exception groups, and flags for emulating :ref:`except* `. See :ref:`assert-matching-exception-groups` and docstrings for more information. + + +- `#12081 `_: Added :fixture:`capteesys` to capture AND pass output to next handler set by ``--capture=``. + + +- `#12504 `_: :func:`pytest.mark.xfail` now accepts :class:`pytest.RaisesGroup` for the ``raises`` parameter when you expect an exception group. You can also pass a :class:`pytest.RaisesExc` if you e.g. want to make use of the ``check`` parameter. + + +- `#12713 `_: New `--force-short-summary` option to force condensed summary output regardless of verbosity level. + + This lets users still see condensed summary output of failures for quick reference in log files from job outputs, being especially useful if non-condensed output is very verbose. + + +- `#12749 `_: pytest traditionally collects classes/functions in the test module namespace even if they are imported from another file. + + For example: + + .. code-block:: python + + # contents of src/domain.py + class Testament: ... + + + # contents of tests/test_testament.py + from domain import Testament + + + def test_testament(): ... + + In this scenario with the default options, pytest will collect the class `Testament` from `tests/test_testament.py` because it starts with `Test`, even though in this case it is a production class being imported in the test module namespace. + + This behavior can now be prevented by setting the new :confval:`collect_imported_tests` configuration option to ``false``, which will make pytest collect classes/functions from test files **only** if they are defined in that file. + + -- by :user:`FreerGit` + + +- `#12765 `_: Thresholds to trigger snippet truncation can now be set with :confval:`truncation_limit_lines` and :confval:`truncation_limit_chars`. + + See :ref:`truncation-params` for more information. + + +- `#13125 `_: :confval:`console_output_style` now supports ``times`` to show execution time of each test. + + +- `#13192 `_: :func:`pytest.raises` will now raise a warning when passing an empty string to ``match``, as this will match against any value. Use ``match="^$"`` if you want to check that an exception has no message. + + +- `#13192 `_: :func:`pytest.raises` will now print a helpful string diff if matching fails and the match parameter has ``^`` and ``$`` and is otherwise escaped. + + +- `#13192 `_: You can now pass :func:`with pytest.raises(check=fn): `, where ``fn`` is a function which takes a raised exception and returns a boolean. The ``raises`` fails if no exception was raised (as usual), passes if an exception is raised and ``fn`` returns ``True`` (as well as ``match`` and the type matching, if specified, which are checked before), and propagates the exception if ``fn`` returns ``False`` (which likely also fails the test). + + +- `#13228 `_: :ref:`hidden-param` can now be used in ``id`` of :func:`pytest.param` or in + ``ids`` of :py:func:`Metafunc.parametrize `. + It hides the parameter set from the test name. + + +- `#13253 `_: New flag: :ref:`--disable-plugin-autoload ` which works as an alternative to :envvar:`PYTEST_DISABLE_PLUGIN_AUTOLOAD` when setting environment variables is inconvenient; and allows setting it in config files with :confval:`addopts`. + + + +Improvements in existing functionality +-------------------------------------- + +- `#10224 `_: pytest's ``short`` and ``long`` traceback styles (:ref:`how-to-modifying-python-tb-printing`) + now have partial :pep:`657` support and will show specific code segments in the + traceback. + + .. code-block:: pytest + + ================================= FAILURES ================================= + _______________________ test_gets_correct_tracebacks _______________________ + + test_tracebacks.py:12: in test_gets_correct_tracebacks + assert manhattan_distance(p1, p2) == 1 + ^^^^^^^^^^^^^^^^^^^^^^^^^^ + test_tracebacks.py:6: in manhattan_distance + return abs(point_1.x - point_2.x) + abs(point_1.y - point_2.y) + ^^^^^^^^^ + E AttributeError: 'NoneType' object has no attribute 'x' + + -- by :user:`ammaraskar` + + +- `#11118 `_: Now :confval:`pythonpath` configures `$PYTHONPATH` earlier than before during the initialization process, which now also affects plugins loaded via the `-p` command-line option. + + -- by :user:`millerdev` + + +- `#11381 `_: The ``type`` parameter of the ``parser.addini`` method now accepts `"int"` and ``"float"`` parameters, facilitating the parsing of configuration values in the configuration file. + + Example: + + .. code-block:: python + + def pytest_addoption(parser): + parser.addini("int_value", type="int", default=2, help="my int value") + parser.addini("float_value", type="float", default=4.2, help="my float value") + + The `pytest.ini` file: + + .. code-block:: ini + + [pytest] + int_value = 3 + float_value = 5.4 + + +- `#11525 `_: Fixtures are now clearly represented in the output as a "fixture object", not as a normal function as before, making it easy for beginners to catch mistakes such as referencing a fixture declared in the same module but not requested in the test function. + + -- by :user:`the-compiler` and :user:`glyphack` + + +- `#12426 `_: A warning is now issued when :ref:`pytest.mark.usefixtures ref` is used without specifying any fixtures. Previously, empty usefixtures markers were silently ignored. + + +- `#12707 `_: Exception chains can be navigated when dropped into Pdb in Python 3.13+. + + +- `#12736 `_: Added a new attribute `name` with the fixed value `"pytest tests"` to the root tag `testsuites` of the junit-xml generated by pytest. + + This attribute is part of many junit-xml specifications and is even part of the `junit-10.xsd` specification that pytest's implementation is based on. + + +- `#12943 `_: If a test fails with an exceptiongroup with a single exception, the contained exception will now be displayed in the short test summary info. + + +- `#12958 `_: A number of :ref:`unraisable ` enhancements: + + * Set the unraisable hook as early as possible and unset it as late as possible, to collect the most possible number of unraisable exceptions. + * Call the garbage collector just before unsetting the unraisable hook, to collect any straggling exceptions. + * Collect multiple unraisable exceptions per test phase. + * Report the :mod:`tracemalloc` allocation traceback (if available). + * Avoid using a generator based hook to allow handling :class:`StopIteration` in test failures. + * Report the unraisable exception as the cause of the :class:`pytest.PytestUnraisableExceptionWarning` exception if raised. + * Compute the ``repr`` of the unraisable object in the unraisable hook so you get the latest information if available, and should help with resurrection of the object. + + +- `#13010 `_: :func:`pytest.approx` now can compare collections that contain numbers and non-numbers mixed. + + +- `#13016 `_: A number of :ref:`threadexception ` enhancements: + + * Set the excepthook as early as possible and unset it as late as possible, to collect the most possible number of unhandled exceptions from threads. + * Collect multiple thread exceptions per test phase. + * Report the :mod:`tracemalloc` allocation traceback (if available). + * Avoid using a generator based hook to allow handling :class:`StopIteration` in test failures. + * Report the thread exception as the cause of the :class:`pytest.PytestUnhandledThreadExceptionWarning` exception if raised. + * Extract the ``name`` of the thread object in the excepthook which should help with resurrection of the thread. + + +- `#13031 `_: An empty parameter set as in ``pytest.mark.parametrize([], ids=idfunc)`` will no longer trigger a call to ``idfunc`` with internal objects. + + +- `#13115 `_: Allows supplying ``ExceptionGroup[Exception]`` and ``BaseExceptionGroup[BaseException]`` to ``pytest.raises`` to keep full typing on :class:`ExceptionInfo `: + + .. code-block:: python + + with pytest.raises(ExceptionGroup[Exception]) as exc_info: + some_function() + + Parametrizing with other exception types remains an error - we do not check the types of child exceptions and thus do not permit code that might look like we do. + + +- `#13122 `_: The ``--stepwise`` mode received a number of improvements: + + * It no longer forgets the last failed test in case pytest is executed later without the flag. + + This enables the following workflow: + + 1. Execute pytest with ``--stepwise``, pytest then stops at the first failing test; + 2. Iteratively update the code and run the test in isolation, without the ``--stepwise`` flag (for example in an IDE), until it is fixed. + 3. Execute pytest with ``--stepwise`` again and pytest will continue from the previously failed test, and if it passes, continue on to the next tests. + + Previously, at step 3, pytest would start from the beginning, forgetting the previously failed test. + + This change however might cause issues if the ``--stepwise`` mode is used far apart in time, as the state might get stale, so the internal state will be reset automatically in case the test suite changes (for now only the number of tests are considered for this, we might change/improve this on the future). + + * New ``--stepwise-reset``/``--sw-reset`` flag, allowing the user to explicitly reset the stepwise state and restart the workflow from the beginning. + + +- `#13308 `_: Added official support for Python 3.14. + + +- `#13380 `_: Fix :class:`ExceptionGroup` traceback filtering to exclude pytest internals. + + +- `#13415 `_: The author metadata of the BibTex example is now correctly formatted with last names following first names. + An example of BibLaTex has been added. + BibTex and BibLaTex examples now clearly indicate that what is cited is software. + + -- by :user:`willynilly` + + +- `#13420 `_: Improved test collection performance by optimizing path resolution used in ``FSCollector``. + + +- `#13457 `_: The error message about duplicate parametrization no longer displays an internal stack trace. + + +- `#4112 `_: Using :ref:`pytest.mark.usefixtures ` on :func:`pytest.param` now produces an error instead of silently doing nothing. + + +- `#5473 `_: Replace `:` with `;` in the assertion rewrite warning message so it can be filtered using standard Python warning filters before calling :func:`pytest.main`. + + +- `#6985 `_: Improved :func:`pytest.approx` to enhance the readability of value ranges and tolerances between 0.001 and 1000. + * The `repr` method now provides clearer output for values within those ranges, making it easier to interpret the results. + * Previously, the output for those ranges of values and tolerances was displayed in scientific notation (e.g., `42 ± 1.0e+00`). The updated method now presents the tolerance as a decimal for better readability (e.g., `42 ± 1`). + + Example: + + **Previous Output:** + + .. code-block:: console + + >>> pytest.approx(42, abs=1) + 42 ± 1.0e+00 + + **Current Output:** + + .. code-block:: console + + >>> pytest.approx(42, abs=1) + 42 ± 1 + + -- by :user:`fazeelghafoor` + + +- `#7683 `_: The formerly optional ``pygments`` dependency is now required, causing output always to be source-highlighted (unless disabled via the ``--code-highlight=no`` CLI option). + + + +Bug fixes +--------- + +- `#10404 `_: Apply filterwarnings from config/cli as soon as possible, and revert them as late as possible + so that warnings as errors are collected throughout the pytest run and before the + unraisable and threadexcept hooks are removed. + + This allows very late warnings and unraisable/threadexcept exceptions to fail the test suite. + + This also changes the warning that the lsof plugin issues from PytestWarning to the new warning PytestFDWarning so it can be more easily filtered. + + +- `#11067 `_: The test report is now consistent regardless if the test xfailed via :ref:`pytest.mark.xfail ` or :func:`pytest.fail`. + + Previously, *xfailed* tests via the marker would have the string ``"reason: "`` prefixed to the message, while those *xfailed* via the function did not. The prefix has been removed. + + +- `#12008 `_: In :pr:`11220`, an unintended change in reordering was introduced by changing the way indices were assigned to direct params. More specifically, before that change, the indices of direct params to metafunc's callspecs were assigned after all parametrizations took place. Now, that change is reverted. + + +- `#12863 `_: Fix applying markers, including :ref:`pytest.mark.parametrize ` when placed above `@staticmethod` or `@classmethod`. + + +- `#12929 `_: Handle StopIteration from test cases, setup and teardown correctly. + + +- `#12938 `_: Fixed ``--durations-min`` argument not respected if ``-vv`` is used. + + +- `#12946 `_: Fixed missing help for :mod:`pdb` commands wrapped by pytest -- by :user:`adamchainz`. + + +- `#12981 `_: Prevent exceptions in :func:`pytest.Config.add_cleanup` callbacks preventing further cleanups. + + +- `#13047 `_: Restore :func:`pytest.approx` handling of equality checks between `bool` and `numpy.bool_` types. + + Comparing `bool` and `numpy.bool_` using :func:`pytest.approx` accidentally changed in version `8.3.4` and `8.3.5` to no longer match: + + .. code-block:: pycon + + >>> import numpy as np + >>> from pytest import approx + >>> [np.True_, np.True_] == pytest.approx([True, True]) + False + + This has now been fixed: + + .. code-block:: pycon + + >>> [np.True_, np.True_] == pytest.approx([True, True]) + True + + +- `#13119 `_: Improved handling of invalid regex patterns for filter warnings by providing a clear error message. + + +- `#13175 `_: The diff is now also highlighted correctly when comparing two strings. + + +- `#13248 `_: Fixed an issue where passing a ``scope`` in :py:func:`Metafunc.parametrize ` with ``indirect=True`` + could result in other fixtures being unable to depend on the parametrized fixture. + + +- `#13291 `_: Fixed ``repr`` of ``attrs`` objects in assertion failure messages when using ``attrs>=25.2``. + + +- `#13312 `_: Fixed a possible ``KeyError`` crash on PyPy during collection of tests involving higher-scoped parameters. + + +- `#13345 `_: Fix type hints for :attr:`pytest.TestReport.when` and :attr:`pytest.TestReport.location`. + + +- `#13377 `_: Fixed handling of test methods with positional-only parameter syntax. + + Now, methods are supported that formally define ``self`` as positional-only + and/or fixture parameters as keyword-only, e.g.: + + .. code-block:: python + + class TestClass: + + def test_method(self, /, *, fixture): ... + + Before, this caused an internal error in pytest. + + +- `#13384 `_: Fixed an issue where pytest could report negative durations. + + +- `#13420 `_: Added ``lru_cache`` to ``nodes._check_initialpaths_for_relpath``. + + +- `#9037 `_: Honor :confval:`disable_test_id_escaping_and_forfeit_all_rights_to_community_support` when escaping ids in parametrized tests. + + + +Improved documentation +---------------------- + +- `#12535 `_: `This + example` + showed ``print`` statements that do not exactly reflect what the + different branches actually do. The fix makes the example more precise. + + +- `#13218 `_: Pointed out in the :func:`pytest.approx` documentation that it considers booleans unequal to numeric zero or one. + + +- `#13221 `_: Improved grouping of CLI options in the ``--help`` output. + + +- `#6649 `_: Added :class:`~pytest.TerminalReporter` to the :ref:`api-reference` documentation page. + + +- `#8612 `_: Add a recipe for handling abstract test classes in the documentation. + + A new example has been added to the documentation to demonstrate how to use a mixin class to handle abstract + test classes without manually setting the ``__test__`` attribute for subclasses. + This ensures that subclasses of abstract test classes are automatically collected by pytest. + + + +Packaging updates and notes for downstreams +------------------------------------------- + +- `#13317 `_: Specified minimum allowed versions of ``colorama``, ``iniconfig``, + and ``packaging``; and bumped the minimum allowed version + of ``exceptiongroup`` for ``python_version<'3.11'`` from a release + candidate to a full release. + + + +Contributor-facing changes +-------------------------- + +- `#12017 `_: Mixed internal improvements: + + * Migrate formatting to f-strings in some tests. + * Use type-safe constructs in JUnitXML tests. + * Moved`` MockTiming`` into ``_pytest.timing``. + + -- by :user:`RonnyPfannschmidt` + + +- `#12647 `_: Fixed running the test suite with the ``hypothesis`` pytest plugin. + + + +Miscellaneous internal changes +------------------------------ + +- `#6649 `_: Added :class:`~pytest.TerminalReporter` to the public pytest API, as it is part of the signature of the :hook:`pytest_terminal_summary` hook. + + +pytest 8.3.5 (2025-03-02) +========================= + +Bug fixes +--------- + +- `#11777 `_: Fixed issue where sequences were still being shortened even with ``-vv`` verbosity. + + +- `#12888 `_: Fixed broken input when using Python 3.13+ and a ``libedit`` build of Python, such as on macOS or with uv-managed Python binaries from the ``python-build-standalone`` project. This could manifest e.g. by a broken prompt when using ``Pdb``, or seeing empty inputs with manual usage of ``input()`` and suspended capturing. + + +- `#13026 `_: Fixed :class:`AttributeError` crash when using ``--import-mode=importlib`` when top-level directory same name as another module of the standard library. + + +- `#13053 `_: Fixed a regression in pytest 8.3.4 where, when using ``--import-mode=importlib``, a directory containing py file with the same name would cause an ``ImportError`` + + +- `#13083 `_: Fixed issue where pytest could crash if one of the collected directories got removed during collection. + + + +Improved documentation +---------------------- + +- `#12842 `_: Added dedicated page about using types with pytest. + + See :ref:`types` for detailed usage. + + + +Contributor-facing changes +-------------------------- + +- `#13112 `_: Fixed selftest failures in ``test_terminal.py`` with Pygments >= 2.19.0 + + +- `#13256 `_: Support for Towncrier versions released in 2024 has been re-enabled + when building Sphinx docs -- by :user:`webknjaz`. + + +pytest 8.3.4 (2024-12-01) +========================= + +Bug fixes +--------- + +- `#12592 `_: Fixed :class:`KeyError` crash when using ``--import-mode=importlib`` in a directory layout where a directory contains a child directory with the same name. + + +- `#12818 `_: Assertion rewriting now preserves the source ranges of the original instructions, making it play well with tools that deal with the ``AST``, like `executing `__. + + +- `#12849 `_: ANSI escape codes for colored output now handled correctly in :func:`pytest.fail` with `pytrace=False`. + + +- `#9353 `_: :func:`pytest.approx` now uses strict equality when given booleans. + + + +Improved documentation +---------------------- + +- `#10558 `_: Fix ambiguous docstring of :func:`pytest.Config.getoption`. + + +- `#10829 `_: Improve documentation on the current handling of the ``--basetemp`` option and its lack of retention functionality (:ref:`temporary directory location and retention`). + + +- `#12866 `_: Improved cross-references concerning the :fixture:`recwarn` fixture. + + +- `#12966 `_: Clarify :ref:`filterwarnings` docs on filter precedence/order when using multiple :ref:`@pytest.mark.filterwarnings ` marks. + + + +Contributor-facing changes +-------------------------- + +- `#12497 `_: Fixed two failing pdb-related tests on Python 3.13. + + +pytest 8.3.3 (2024-09-09) +========================= + +Bug fixes +--------- + +- `#12446 `_: Avoid calling ``@property`` (and other instance descriptors) during fixture discovery -- by :user:`asottile` + + +- `#12659 `_: Fixed the issue of not displaying assertion failure differences when using the parameter ``--import-mode=importlib`` in pytest>=8.1. + + +- `#12667 `_: Fixed a regression where type change in `ExceptionInfo.errisinstance` caused `mypy` to fail. + + +- `#12744 `_: Fixed typing compatibility with Python 3.9 or less -- replaced `typing.Self` with `typing_extensions.Self` -- by :user:`Avasam` + + +- `#12745 `_: Fixed an issue with backslashes being incorrectly converted in nodeid paths on Windows, ensuring consistent path handling across environments. + + +- `#6682 `_: Fixed bug where the verbosity levels where not being respected when printing the "msg" part of failed assertion (as in ``assert condition, msg``). + + +- `#9422 `_: Fix bug where disabling the terminal plugin via ``-p no:terminal`` would cause crashes related to missing the ``verbose`` option. + + -- by :user:`GTowers1` + + + +Improved documentation +---------------------- + +- `#12663 `_: Clarify that the `pytest_deselected` hook should be called from `pytest_collection_modifyitems` hook implementations when items are deselected. + + +- `#12678 `_: Remove erroneous quotes from `tmp_path_retention_policy` example in docs. + + + +Miscellaneous internal changes +------------------------------ + +- `#12769 `_: Fix typos discovered by codespell and add codespell to pre-commit hooks. + + +pytest 8.3.2 (2024-07-24) +========================= + +Bug fixes +--------- + +- `#12652 `_: Resolve regression `conda` environments where no longer being automatically detected. + + -- by :user:`RonnyPfannschmidt` + + +pytest 8.3.1 (2024-07-20) +========================= + +The 8.3.0 release failed to include the change notes and docs for the release. This patch release remedies this. There are no other changes. + + +pytest 8.3.0 (2024-07-20) +========================= + +New features +------------ + +- `#12231 `_: Added `--xfail-tb` flag, which turns on traceback output for XFAIL results. + + * If the `--xfail-tb` flag is not given, tracebacks for XFAIL results are NOT shown. + * The style of traceback for XFAIL is set with `--tb`, and can be `auto|long|short|line|native|no`. + * Note: Even if you have `--xfail-tb` set, you won't see them if `--tb=no`. + + Some history: + + With pytest 8.0, `-rx` or `-ra` would not only turn on summary reports for xfail, but also report the tracebacks for xfail results. This caused issues with some projects that utilize xfail, but don't want to see all of the xfail tracebacks. + + This change detaches xfail tracebacks from `-rx`, and now we turn on xfail tracebacks with `--xfail-tb`. With this, the default `-rx`/ `-ra` behavior is identical to pre-8.0 with respect to xfail tracebacks. While this is a behavior change, it brings default behavior back to pre-8.0.0 behavior, which ultimately was considered the better course of action. + + -- by :user:`okken` + + +- `#12281 `_: Added support for keyword matching in marker expressions. + + Now tests can be selected by marker keyword arguments. + Supported values are :class:`int`, (unescaped) :class:`str`, :class:`bool` & :data:`None`. + + See :ref:`marker examples ` for more information. + + -- by :user:`lovetheguitar` + + +- `#12567 `_: Added ``--no-fold-skipped`` command line option. + + If this option is set, then skipped tests in short summary are no longer grouped + by reason but all tests are printed individually with their nodeid in the same + way as other statuses. + + -- by :user:`pbrezina` + + + +Improvements in existing functionality +-------------------------------------- + +- `#12469 `_: The console output now uses the "third-party plugins" terminology, + replacing the previously established but confusing and outdated + reference to :std:doc:`setuptools ` + -- by :user:`webknjaz`. + + +- `#12544 `_, `#12545 `_: Python virtual environment detection was improved by + checking for a :file:`pyvenv.cfg` file, ensuring reliable detection on + various platforms -- by :user:`zachsnickers`. + + +- `#2871 `_: Do not truncate arguments to functions in output when running with `-vvv`. + + +- `#389 `_: The readability of assertion introspection of bound methods has been enhanced + -- by :user:`farbodahm`, :user:`webknjaz`, :user:`obestwalter`, :user:`flub` + and :user:`glyphack`. + + Earlier, it was like: + + .. code-block:: console + + =================================== FAILURES =================================== + _____________________________________ test _____________________________________ + + def test(): + > assert Help().fun() == 2 + E assert 1 == 2 + E + where 1 = >() + E + where > = .fun + E + where = Help() + + example.py:7: AssertionError + =========================== 1 failed in 0.03 seconds =========================== + + + And now it's like: + + .. code-block:: console + + =================================== FAILURES =================================== + _____________________________________ test _____________________________________ + + def test(): + > assert Help().fun() == 2 + E assert 1 == 2 + E + where 1 = fun() + E + where fun = .fun + E + where = Help() + + test_local.py:13: AssertionError + =========================== 1 failed in 0.03 seconds =========================== + + +- `#7662 `_: Added timezone information to the testsuite timestamp in the JUnit XML report. + + + +Bug fixes +--------- + +- `#11706 `_: Fixed reporting of teardown errors in higher-scoped fixtures when using `--maxfail` or `--stepwise`. + + Originally added in pytest 8.0.0, but reverted in 8.0.2 due to a regression in pytest-xdist. + This regression was fixed in pytest-xdist 3.6.1. + + +- `#11797 `_: :func:`pytest.approx` now correctly handles :class:`Sequence `-like objects. + + +- `#12204 `_, `#12264 `_: Fixed a regression in pytest 8.0 where tracebacks get longer and longer when multiple + tests fail due to a shared higher-scope fixture which raised -- by :user:`bluetech`. + + Also fixed a similar regression in pytest 5.4 for collectors which raise during setup. + + The fix necessitated internal changes which may affect some plugins: + + * ``FixtureDef.cached_result[2]`` is now a tuple ``(exc, tb)`` + instead of ``exc``. + * ``SetupState.stack`` failures are now a tuple ``(exc, tb)`` + instead of ``exc``. + + +- `#12275 `_: Fixed collection error upon encountering an :mod:`abstract ` class, including abstract `unittest.TestCase` subclasses. + + +- `#12328 `_: Fixed a regression in pytest 8.0.0 where package-scoped parameterized items were not correctly reordered to minimize setups/teardowns in some cases. + + +- `#12424 `_: Fixed crash with `assert testcase is not None` assertion failure when re-running unittest tests using plugins like pytest-rerunfailures. Regressed in 8.2.2. + + +- `#12472 `_: Fixed a crash when returning category ``"error"`` or ``"failed"`` with a custom test status from :hook:`pytest_report_teststatus` hook -- :user:`pbrezina`. + + +- `#12505 `_: Improved handling of invalid regex patterns in :func:`pytest.raises(match=r'...') ` by providing a clear error message. + + +- `#12580 `_: Fixed a crash when using the cache class on Windows and the cache directory was created concurrently. + + +- `#6962 `_: Parametrization parameters are now compared using `==` instead of `is` (`is` is still used as a fallback if the parameter does not support `==`). + This fixes use of parameters such as lists, which have a different `id` but compare equal, causing fixtures to be re-computed instead of being cached. + + +- `#7166 `_: Fixed progress percentages (the ``[ 87%]`` at the edge of the screen) sometimes not aligning correctly when running with pytest-xdist ``-n``. + + + +Improved documentation +---------------------- + +- `#12153 `_: Documented using :envvar:`PYTEST_VERSION` to detect if code is running from within a pytest run. + + +- `#12469 `_: The external plugin mentions in the documentation now avoid mentioning + :std:doc:`setuptools entry-points ` as the concept is + much more generic nowadays. Instead, the terminology of "external", + "installed", or "third-party" plugins (or packages) replaces that. + + -- by :user:`webknjaz` + + +- `#12577 `_: `CI` and `BUILD_NUMBER` environment variables role is described in + the reference doc. They now also appear when doing `pytest -h` + -- by :user:`MarcBresson`. + + + +Contributor-facing changes +-------------------------- + +- `#12467 `_: Migrated all internal type-annotations to the python3.10+ style by using the `annotations` future import. + + -- by :user:`RonnyPfannschmidt` + + +- `#11771 `_, `#12557 `_: The PyPy runtime version has been updated to 3.9 from 3.8 that introduced + a flaky bug at the garbage collector which was not expected to fix there + as the 3.8 is EoL. + + -- by :user:`x612skm` + + +- `#12493 `_: The change log draft preview integration has been refactored to use a + third party extension ``sphinxcontib-towncrier``. The previous in-repo + script was putting the change log preview file at + :file:`doc/en/_changelog_towncrier_draft.rst`. Said file is no longer + ignored in Git and might show up among untracked files in the + development environments of the contributors. To address that, the + contributors can run the following command that will clean it up: + + .. code-block:: console + + $ git clean -x -i -- doc/en/_changelog_towncrier_draft.rst + + -- by :user:`webknjaz` + + +- `#12498 `_: All the undocumented ``tox`` environments now have descriptions. + They can be listed in one's development environment by invoking + ``tox -av`` in a terminal. + + -- by :user:`webknjaz` + + +- `#12501 `_: The changelog configuration has been updated to introduce more accurate + audience-tailored categories. Previously, there was a ``trivial`` + change log fragment type with an unclear and broad meaning. It was + removed and we now have ``contrib``, ``misc`` and ``packaging`` in + place of it. + + The new change note types target the readers who are downstream + packagers and project contributors. Additionally, the miscellaneous + section is kept for unspecified updates that do not fit anywhere else. + + -- by :user:`webknjaz` + + +- `#12502 `_: The UX of the GitHub automation making pull requests to update the + plugin list has been updated. Previously, the maintainers had to close + the automatically created pull requests and re-open them to trigger the + CI runs. From now on, they only need to click the `Ready for review` + button instead. + + -- by :user:`webknjaz` + + +- `#12522 `_: The ``:pull:`` RST role has been replaced with a shorter + ``:pr:`` due to starting to use the implementation from + the third-party :pypi:`sphinx-issues` Sphinx extension + -- by :user:`webknjaz`. + + +- `#12531 `_: The coverage reporting configuration has been updated to exclude + pytest's own tests marked as expected to fail from the coverage + report. This has an effect of reducing the influence of flaky + tests on the resulting number. + + -- by :user:`webknjaz` + + +- `#12533 `_: The ``extlinks`` Sphinx extension is no longer enabled. The ``:bpo:`` + role it used to declare has been removed with that. BPO itself has + migrated to GitHub some years ago and it is possible to link the + respective issues by using their GitHub issue numbers and the + ``:issue:`` role that the ``sphinx-issues`` extension implements. + + -- by :user:`webknjaz` + + +- `#12562 `_: Possible typos in using the ``:user:`` RST role is now being linted + through the pre-commit tool integration -- by :user:`webknjaz`. + + +pytest 8.2.2 (2024-06-04) +========================= + +Bug Fixes +--------- + +- `#12355 `_: Fix possible catastrophic performance slowdown on a certain parametrization pattern involving many higher-scoped parameters. + + +- `#12367 `_: Fix a regression in pytest 8.2.0 where unittest class instances (a fresh one is created for each test) were not released promptly on test teardown but only on session teardown. + + +- `#12381 `_: Fix possible "Directory not empty" crashes arising from concurrent cache dir (``.pytest_cache``) creation. Regressed in pytest 8.2.0. + + + +Improved Documentation +---------------------- + +- `#12290 `_: Updated Sphinx theme to use Furo instead of Flask, enabling Dark mode theme. + + +- `#12356 `_: Added a subsection to the documentation for debugging flaky tests to mention + lack of thread safety in pytest as a possible source of flakiness. + + +- `#12363 `_: The documentation webpages now links to a canonical version to reduce outdated documentation in search engine results. + + +pytest 8.2.1 (2024-05-19) +========================= + +Improvements +------------ + +- `#12334 `_: Support for Python 3.13 (beta1 at the time of writing). + + + +Bug Fixes +--------- + +- `#12120 `_: Fix `PermissionError` crashes arising from directories which are not selected on the command-line. + + +- `#12191 `_: Keyboard interrupts and system exits are now properly handled during the test collection. + + +- `#12300 `_: Fixed handling of 'Function not implemented' error under squashfuse_ll, which is a different way to say that the mountpoint is read-only. + + +- `#12308 `_: Fix a regression in pytest 8.2.0 where the permissions of automatically-created ``.pytest_cache`` directories became ``rwx------`` instead of the expected ``rwxr-xr-x``. + + + +Trivial/Internal Changes +------------------------ + +- `#12333 `_: pytest releases are now attested using the recent `Artifact Attestation `_ support from GitHub, allowing users to verify the provenance of pytest's sdist and wheel artifacts. + + +pytest 8.2.0 (2024-04-27) +========================= + +Breaking Changes +---------------- + +- `#12089 `_: pytest now requires that :class:`unittest.TestCase` subclasses can be instantiated freely using ``MyTestCase('runTest')``. + + If the class doesn't allow this, you may see an error during collection such as ``AttributeError: 'MyTestCase' object has no attribute 'runTest'``. + + Classes which do not override ``__init__``, or do not access the test method in ``__init__`` using ``getattr`` or similar, are unaffected. + + Classes which do should take care to not crash when ``"runTest"`` is given, as is shown in `unittest.TestCases's implementation `_. + Alternatively, consider using :meth:`setUp ` instead of ``__init__``. + + If you run into this issue using ``tornado.AsyncTestCase``, please see `issue 12263 `_. + + If you run into this issue using an abstract ``TestCase`` subclass, please see `issue 12275 `_. + + Historical note: the effect of this change on custom TestCase implementations was not properly considered initially, this is why it was done in a minor release. We apologize for the inconvenience. + +Deprecations +------------ + +- `#12069 `_: A deprecation warning is now raised when implementations of one of the following hooks request a deprecated ``py.path.local`` parameter instead of the ``pathlib.Path`` parameter which replaced it: + + - :hook:`pytest_ignore_collect` - the ``path`` parameter - use ``collection_path`` instead. + - :hook:`pytest_collect_file` - the ``path`` parameter - use ``file_path`` instead. + - :hook:`pytest_pycollect_makemodule` - the ``path`` parameter - use ``module_path`` instead. + - :hook:`pytest_report_header` - the ``startdir`` parameter - use ``start_path`` instead. + - :hook:`pytest_report_collectionfinish` - the ``startdir`` parameter - use ``start_path`` instead. + + The replacement parameters are available since pytest 7.0.0. + The old parameters will be removed in pytest 9.0.0. + + See :ref:`legacy-path-hooks-deprecated` for more details. + + + +Features +-------- + +- `#11871 `_: Added support for reading command line arguments from a file using the prefix character ``@``, like e.g.: ``pytest @tests.txt``. The file must have one argument per line. + + See :ref:`Read arguments from file ` for details. + + + +Improvements +------------ + +- `#11523 `_: :func:`pytest.importorskip` will now issue a warning if the module could be found, but raised :class:`ImportError` instead of :class:`ModuleNotFoundError`. + + The warning can be suppressed by passing ``exc_type=ImportError`` to :func:`pytest.importorskip`. + + See :ref:`import-or-skip-import-error` for details. + + +- `#11728 `_: For ``unittest``-based tests, exceptions during class cleanup (as raised by functions registered with :meth:`TestCase.addClassCleanup `) are now reported instead of silently failing. + + +- `#11777 `_: Text is no longer truncated in the ``short test summary info`` section when ``-vv`` is given. + + +- `#12112 `_: Improved namespace packages detection when :confval:`consider_namespace_packages` is enabled, covering more situations (like editable installs). + + +- `#9502 `_: Added :envvar:`PYTEST_VERSION` environment variable which is defined at the start of the pytest session and undefined afterwards. It contains the value of ``pytest.__version__``, and among other things can be used to easily check if code is running from within a pytest run. + + + +Bug Fixes +--------- + +- `#12065 `_: Fixed a regression in pytest 8.0.0 where test classes containing ``setup_method`` and tests using ``@staticmethod`` or ``@classmethod`` would crash with ``AttributeError: 'NoneType' object has no attribute 'setup_method'``. + + Now the :attr:`request.instance ` attribute of tests using ``@staticmethod`` and ``@classmethod`` is no longer ``None``, but a fresh instance of the class, like in non-static methods. + Previously it was ``None``, and all fixtures of such tests would share a single ``self``. + + +- `#12135 `_: Fixed issue where fixtures adding their finalizer multiple times to fixtures they request would cause unreliable and non-intuitive teardown ordering in some instances. + + +- `#12194 `_: Fixed a bug with ``--importmode=importlib`` and ``--doctest-modules`` where child modules did not appear as attributes in parent modules. + + +- `#1489 `_: Fixed some instances where teardown of higher-scoped fixtures was not happening in the reverse order they were initialized in. + + + +Trivial/Internal Changes +------------------------ + +- `#12069 `_: ``pluggy>=1.5.0`` is now required. + + +- `#12167 `_: :ref:`cache `: create supporting files (``CACHEDIR.TAG``, ``.gitignore``, etc.) in a temporary directory to provide atomic semantics. + + +pytest 8.1.2 (2024-04-26) +========================= + +Bug Fixes +--------- + +- `#12114 `_: Fixed error in :func:`pytest.approx` when used with `numpy` arrays and comparing with other types. + + +pytest 8.1.1 (2024-03-08) +========================= + +.. note:: + + This release is not a usual bug fix release -- it contains features and improvements, being a follow up + to ``8.1.0``, which has been yanked from PyPI. + +Features +-------- + +- `#11475 `_: Added the new :confval:`consider_namespace_packages` configuration option, defaulting to ``False``. + + If set to ``True``, pytest will attempt to identify modules that are part of `namespace packages `__ when importing modules. + + +- `#11653 `_: Added the new :confval:`verbosity_test_cases` configuration option for fine-grained control of test execution verbosity. + See :ref:`Fine-grained verbosity ` for more details. + + + +Improvements +------------ + +- `#10865 `_: :func:`pytest.warns` now validates that :func:`warnings.warn` was called with a `str` or a `Warning`. + Currently in Python it is possible to use other types, however this causes an exception when :func:`warnings.filterwarnings` is used to filter those warnings (see `CPython #103577 `__ for a discussion). + While this can be considered a bug in CPython, we decided to put guards in pytest as the error message produced without this check in place is confusing. + + +- `#11311 `_: When using ``--override-ini`` for paths in invocations without a configuration file defined, the current working directory is used + as the relative directory. + + Previously this would raise an :class:`AssertionError`. + + +- `#11475 `_: :ref:`--import-mode=importlib ` now tries to import modules using the standard import mechanism (but still without changing :py:data:`sys.path`), falling back to importing modules directly only if that fails. + + This means that installed packages will be imported under their canonical name if possible first, for example ``app.core.models``, instead of having the module name always be derived from their path (for example ``.env310.lib.site_packages.app.core.models``). + + +- `#11801 `_: Added the :func:`iter_parents() <_pytest.nodes.Node.iter_parents>` helper method on nodes. + It is similar to :func:`listchain <_pytest.nodes.Node.listchain>`, but goes from bottom to top, and returns an iterator, not a list. + + +- `#11850 `_: Added support for :data:`sys.last_exc` for post-mortem debugging on Python>=3.12. + + +- `#11962 `_: In case no other suitable candidates for configuration file are found, a ``pyproject.toml`` (even without a ``[tool.pytest.ini_options]`` table) will be considered as the configuration file and define the ``rootdir``. + + +- `#11978 `_: Add ``--log-file-mode`` option to the logging plugin, enabling appending to log-files. This option accepts either ``"w"`` or ``"a"`` and defaults to ``"w"``. + + Previously, the mode was hard-coded to be ``"w"`` which truncates the file before logging. + + +- `#12047 `_: When multiple finalizers of a fixture raise an exception, now all exceptions are reported as an exception group. + Previously, only the first exception was reported. + + + +Bug Fixes +--------- + +- `#11475 `_: Fixed regression where ``--importmode=importlib`` would import non-test modules more than once. + + +- `#11904 `_: Fixed a regression in pytest 8.0.0 that would cause test collection to fail due to permission errors when using ``--pyargs``. + + This change improves the collection tree for tests specified using ``--pyargs``, see :pr:`12043` for a comparison with pytest 8.0 and <8. + + +- `#12011 `_: Fixed a regression in 8.0.1 whereby ``setup_module`` xunit-style fixtures are not executed when ``--doctest-modules`` is passed. + + +- `#12014 `_: Fix the ``stacklevel`` used when warning about marks used on fixtures. + + +- `#12039 `_: Fixed a regression in ``8.0.2`` where tests created using :fixture:`tmp_path` have been collected multiple times in CI under Windows. + + +Improved Documentation +---------------------- + +- `#11790 `_: Documented the retention of temporary directories created using the ``tmp_path`` fixture in more detail. + + + +Trivial/Internal Changes +------------------------ + +- `#11785 `_: Some changes were made to private functions which may affect plugins which access them: + + - ``FixtureManager._getautousenames()`` now takes a ``Node`` itself instead of the nodeid. + - ``FixtureManager.getfixturedefs()`` now takes the ``Node`` itself instead of the nodeid. + - The ``_pytest.nodes.iterparentnodeids()`` function is removed without replacement. + Prefer to traverse the node hierarchy itself instead. + If you really need to, copy the function from the previous pytest release. + + +- `#12069 `_: Delayed the deprecation of the following features to ``9.0.0``: + + * :ref:`node-ctor-fspath-deprecation`. + * :ref:`legacy-path-hooks-deprecated`. + + It was discovered after ``8.1.0`` was released that the warnings about the impeding removal were not being displayed, so the team decided to revert the removal. + + This is the reason for ``8.1.0`` being yanked. + + +pytest 8.1.0 (YANKED) +===================== + + +.. note:: + + This release has been **yanked**: it broke some plugins without the proper warning period, due to + some warnings not showing up as expected. + + See `#12069 `__. + + +pytest 8.0.2 (2024-02-24) +========================= + +Bug Fixes +--------- + +- `#11895 `_: Fix collection on Windows where initial paths contain the short version of a path (for example ``c:\PROGRA~1\tests``). + + +- `#11953 `_: Fix an ``IndexError`` crash raising from ``getstatementrange_ast``. + + +- `#12021 `_: Reverted a fix to `--maxfail` handling in pytest 8.0.0 because it caused a regression in pytest-xdist whereby session fixture teardowns may get executed multiple times when the max-fails is reached. + + +pytest 8.0.1 (2024-02-16) +========================= + +Bug Fixes +--------- + +- `#11875 `_: Correctly handle errors from :func:`getpass.getuser` in Python 3.13. + + +- `#11879 `_: Fix an edge case where ``ExceptionInfo._stringify_exception`` could crash :func:`pytest.raises`. + + +- `#11906 `_: Fix regression with :func:`pytest.warns` using custom warning subclasses which have more than one parameter in their `__init__`. + + +- `#11907 `_: Fix a regression in pytest 8.0.0 whereby calling :func:`pytest.skip` and similar control-flow exceptions within a :func:`pytest.warns()` block would get suppressed instead of propagating. + + +- `#11929 `_: Fix a regression in pytest 8.0.0 whereby autouse fixtures defined in a module get ignored by the doctests in the module. + + +- `#11937 `_: Fix a regression in pytest 8.0.0 whereby items would be collected in reverse order in some circumstances. + + +pytest 8.0.0 (2024-01-27) +========================= + +Bug Fixes +--------- + +- `#11842 `_: Properly escape the ``reason`` of a :ref:`skip ` mark when writing JUnit XML files. + + +- `#11861 `_: Avoid microsecond exceeds ``1_000_000`` when using ``log-date-format`` with ``%f`` specifier, which might cause the test suite to crash. + + +pytest 8.0.0rc2 (2024-01-17) +============================ + + +Improvements +------------ + +- `#11233 `_: Improvements to ``-r`` for xfailures and xpasses: + + * Report tracebacks for xfailures when ``-rx`` is set. + * Report captured output for xpasses when ``-rX`` is set. + * For xpasses, add ``-`` in summary between test name and reason, to match how xfail is displayed. + +- `#11825 `_: The :hook:`pytest_plugin_registered` hook has a new ``plugin_name`` parameter containing the name by which ``plugin`` is registered. + + +Bug Fixes +--------- + +- `#11706 `_: Fix reporting of teardown errors in higher-scoped fixtures when using `--maxfail` or `--stepwise`. + + NOTE: This change was reverted in pytest 8.0.2 to fix a `regression `_ it caused in pytest-xdist. + + +- `#11758 `_: Fixed ``IndexError: string index out of range`` crash in ``if highlighted[-1] == "\n" and source[-1] != "\n"``. + This bug was introduced in pytest 8.0.0rc1. + + +- `#9765 `_, `#11816 `_: Fixed a frustrating bug that afflicted some users with the only error being ``assert mod not in mods``. The issue was caused by the fact that ``str(Path(mod))`` and ``mod.__file__`` don't necessarily produce the same string, and was being erroneously used interchangeably in some places in the code. + + This fix also broke the internal API of ``PytestPluginManager.consider_conftest`` by introducing a new parameter -- we mention this in case it is being used by external code, even if marked as *private*. + + +pytest 8.0.0rc1 (2023-12-30) +============================ + +Breaking Changes +---------------- + +Old Deprecations Are Now Errors +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +- `#7363 `_: **PytestRemovedIn8Warning deprecation warnings are now errors by default.** + + Following our plan to remove deprecated features with as little disruption as + possible, all warnings of type ``PytestRemovedIn8Warning`` now generate errors + instead of warning messages by default. + + **The affected features will be effectively removed in pytest 8.1**, so please consult the + :ref:`deprecations` section in the docs for directions on how to update existing code. + + In the pytest ``8.0.X`` series, it is possible to change the errors back into warnings as a + stopgap measure by adding this to your ``pytest.ini`` file: + + .. code-block:: ini + + [pytest] + filterwarnings = + ignore::pytest.PytestRemovedIn8Warning + + But this will stop working when pytest ``8.1`` is released. + + **If you have concerns** about the removal of a specific feature, please add a + comment to :issue:`7363`. + + +Version Compatibility +^^^^^^^^^^^^^^^^^^^^^ + +- `#11151 `_: Dropped support for Python 3.7, which `reached end-of-life on 2023-06-27 `__. + + +- ``pluggy>=1.3.0`` is now required. + + +Collection Changes +^^^^^^^^^^^^^^^^^^ + +In this version we've made several breaking changes to pytest's collection phase, +particularly around how filesystem directories and Python packages are collected, +fixing deficiencies and allowing for cleanups and improvements to pytest's internals. +A deprecation period for these changes was not possible. + + +- `#7777 `_: Files and directories are now collected in alphabetical order jointly, unless changed by a plugin. + Previously, files were collected before directories. + See below for an example. + + +- `#8976 `_: Running `pytest pkg/__init__.py` now collects the `pkg/__init__.py` file (module) only. + Previously, it collected the entire `pkg` package, including other test files in the directory, but excluding tests in the `__init__.py` file itself + (unless :confval:`python_files` was changed to allow `__init__.py` file). + + To collect the entire package, specify just the directory: `pytest pkg`. + + +- `#11137 `_: :class:`pytest.Package` is no longer a :class:`pytest.Module` or :class:`pytest.File`. + + The ``Package`` collector node designates a Python package, that is, a directory with an `__init__.py` file. + Previously ``Package`` was a subtype of ``pytest.Module`` (which represents a single Python module), + the module being the `__init__.py` file. + This has been deemed a design mistake (see :issue:`11137` and :issue:`7777` for details). + + The ``path`` property of ``Package`` nodes now points to the package directory instead of the ``__init__.py`` file. + + Note that a ``Module`` node for ``__init__.py`` (which is not a ``Package``) may still exist, + if it is picked up during collection (e.g. if you configured :confval:`python_files` to include ``__init__.py`` files). + + +- `#7777 `_: Added a new :class:`pytest.Directory` base collection node, which all collector nodes for filesystem directories are expected to subclass. + This is analogous to the existing :class:`pytest.File` for file nodes. + + Changed :class:`pytest.Package` to be a subclass of :class:`pytest.Directory`. + A ``Package`` represents a filesystem directory which is a Python package, + i.e. contains an ``__init__.py`` file. + + :class:`pytest.Package` now only collects files in its own directory; previously it collected recursively. + Sub-directories are collected as their own collector nodes, which then collect themselves, thus creating a collection tree which mirrors the filesystem hierarchy. + + Added a new :class:`pytest.Dir` concrete collection node, a subclass of :class:`pytest.Directory`. + This node represents a filesystem directory, which is not a :class:`pytest.Package`, + that is, does not contain an ``__init__.py`` file. + Similarly to ``Package``, it only collects the files in its own directory. + + :class:`pytest.Session` now only collects the initial arguments, without recursing into directories. + This work is now done by the :func:`recursive expansion process ` of directory collector nodes. + + :attr:`session.name ` is now ``""``; previously it was the rootdir directory name. + This matches :attr:`session.nodeid <_pytest.nodes.Node.nodeid>` which has always been `""`. + + The collection tree now contains directories/packages up to the :ref:`rootdir `, + for initial arguments that are found within the rootdir. + For files outside the rootdir, only the immediate directory/package is collected -- + note however that collecting from outside the rootdir is discouraged. + + As an example, given the following filesystem tree:: + + myroot/ + pytest.ini + top/ + ├── aaa + │ └── test_aaa.py + ├── test_a.py + ├── test_b + │ ├── __init__.py + │ └── test_b.py + ├── test_c.py + └── zzz + ├── __init__.py + └── test_zzz.py + + the collection tree, as shown by `pytest --collect-only top/` but with the otherwise-hidden :class:`~pytest.Session` node added for clarity, + is now the following:: + + + + + + + + + + + + + + + + + + + Previously, it was:: + + + + + + + + + + + + + + + + Code/plugins which rely on a specific shape of the collection tree might need to update. + + +- `#11676 `_: The classes :class:`~_pytest.nodes.Node`, :class:`~pytest.Collector`, :class:`~pytest.Item`, :class:`~pytest.File`, :class:`~_pytest.nodes.FSCollector` are now marked abstract (see :mod:`abc`). + + We do not expect this change to affect users and plugin authors, it will only cause errors when the code is already wrong or problematic. + + +Other breaking changes +^^^^^^^^^^^^^^^^^^^^^^ + +These are breaking changes where deprecation was not possible. + + +- `#11282 `_: Sanitized the handling of the ``default`` parameter when defining configuration options. + + Previously if ``default`` was not supplied for :meth:`parser.addini ` and the configuration option value was not defined in a test session, then calls to :func:`config.getini ` returned an *empty list* or an *empty string* depending on whether ``type`` was supplied or not respectively, which is clearly incorrect. Also, ``None`` was not honored even if ``default=None`` was used explicitly while defining the option. + + Now the behavior of :meth:`parser.addini ` is as follows: + + * If ``default`` is NOT passed but ``type`` is provided, then a type-specific default will be returned. For example ``type=bool`` will return ``False``, ``type=str`` will return ``""``, etc. + * If ``default=None`` is passed and the option is not defined in a test session, then ``None`` will be returned, regardless of the ``type``. + * If neither ``default`` nor ``type`` are provided, assume ``type=str`` and return ``""`` as default (this is as per previous behavior). + + The team decided to not introduce a deprecation period for this change, as doing so would be complicated both in terms of communicating this to the community as well as implementing it, and also because the team believes this change should not break existing plugins except in rare cases. + + +- `#11667 `_: pytest's ``setup.py`` file is removed. + If you relied on this file, e.g. to install pytest using ``setup.py install``, + please see `Why you shouldn't invoke setup.py directly `_ for alternatives. + + +- `#9288 `_: :func:`~pytest.warns` now re-emits unmatched warnings when the context + closes -- previously it would consume all warnings, hiding those that were not + matched by the function. + + While this is a new feature, we announce it as a breaking change + because many test suites are configured to error-out on warnings, and will + therefore fail on the newly-re-emitted warnings. + + +- The internal ``FixtureManager.getfixtureclosure`` method has changed. Plugins which use this method or + which subclass ``FixtureManager`` and overwrite that method will need to adapt to the change. + + + +Deprecations +------------ + +- `#10465 `_: Test functions returning a value other than ``None`` will now issue a :class:`pytest.PytestWarning` instead of ``pytest.PytestRemovedIn8Warning``, meaning this will stay a warning instead of becoming an error in the future. + + +- `#3664 `_: Applying a mark to a fixture function now issues a warning: marks in fixtures never had any effect, but it is a common user error to apply a mark to a fixture (for example ``usefixtures``) and expect it to work. + + This will become an error in pytest 9.0. + + + +Features and Improvements +------------------------- + +Improved Diffs +^^^^^^^^^^^^^^ + +These changes improve the diffs that pytest prints when an assertion fails. +Note that syntax highlighting requires the ``pygments`` package. + + +- `#11520 `_: The very verbose (``-vv``) diff output is now colored as a diff instead of a big chunk of red. + + Python code in error reports is now syntax-highlighted as Python. + + The sections in the error reports are now better separated. + + +- `#1531 `_: The very verbose diff (``-vv``) for every standard library container type is improved. The indentation is now consistent and the markers are on their own separate lines, which should reduce the diffs shown to users. + + Previously, the standard Python pretty printer was used to generate the output, which puts opening and closing + markers on the same line as the first/last entry, in addition to not having consistent indentation. + + +- `#10617 `_: Added more comprehensive set assertion rewrites for comparisons other than equality ``==``, with + the following operations now providing better failure messages: ``!=``, ``<=``, ``>=``, ``<``, and ``>``. + + +Separate Control For Assertion Verbosity +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +- `#11387 `_: Added the new :confval:`verbosity_assertions` configuration option for fine-grained control of failed assertions verbosity. + + If you've ever wished that pytest always show you full diffs, but without making everything else verbose, this is for you. + + See :ref:`Fine-grained verbosity ` for more details. + + For plugin authors, :attr:`config.get_verbosity ` can be used to retrieve the verbosity level for a specific verbosity type. + + +Additional Support For Exception Groups and ``__notes__`` +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +These changes improve pytest's support for exception groups. + + +- `#10441 `_: Added :func:`ExceptionInfo.group_contains() `, an assertion helper that tests if an :class:`ExceptionGroup` contains a matching exception. + + See :ref:`assert-matching-exception-groups` for an example. + + +- `#11227 `_: Allow :func:`pytest.raises` ``match`` argument to match against `PEP-678 ` ``__notes__``. + + +Custom Directory collectors +^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +- `#7777 `_: Added a new hook :hook:`pytest_collect_directory`, + which is called by filesystem-traversing collector nodes, + such as :class:`pytest.Session`, :class:`pytest.Dir` and :class:`pytest.Package`, + to create a collector node for a sub-directory. + It is expected to return a subclass of :class:`pytest.Directory`. + This hook allows plugins to :ref:`customize the collection of directories `. + + +"New-style" Hook Wrappers +^^^^^^^^^^^^^^^^^^^^^^^^^ + +- `#11122 `_: pytest now uses "new-style" hook wrappers internally, available since pluggy 1.2.0. + See `pluggy's 1.2.0 changelog `_ and the :ref:`updated docs ` for details. + + Plugins which want to use new-style wrappers can do so if they require ``pytest>=8``. + + +Other Improvements +^^^^^^^^^^^^^^^^^^ + +- `#11216 `_: If a test is skipped from inside an :ref:`xunit setup fixture `, the test summary now shows the test location instead of the fixture location. + + +- `#11314 `_: Logging to a file using the ``--log-file`` option will use ``--log-level``, ``--log-format`` and ``--log-date-format`` as fallback + if ``--log-file-level``, ``--log-file-format`` and ``--log-file-date-format`` are not provided respectively. + + +- `#11610 `_: Added the :func:`LogCaptureFixture.filtering() ` context manager which + adds a given :class:`logging.Filter` object to the :fixture:`caplog` fixture. + + +- `#11447 `_: :func:`pytest.deprecated_call` now also considers warnings of type :class:`FutureWarning`. + + +- `#11600 `_: Improved the documentation and type signature for :func:`pytest.mark.xfail `'s ``condition`` param to use ``False`` as the default value. + + +- `#7469 `_: :class:`~pytest.FixtureDef` is now exported as ``pytest.FixtureDef`` for typing purposes. + + +- `#11353 `_: Added typing to :class:`~pytest.PytestPluginManager`. + + +Bug Fixes +--------- + +- `#10701 `_: :meth:`pytest.WarningsRecorder.pop` will return the most-closely-matched warning in the list, + rather than the first warning which is an instance of the requested type. + + +- `#11255 `_: Fixed crash on `parametrize(..., scope="package")` without a package present. + + +- `#11277 `_: Fixed a bug that when there are multiple fixtures for an indirect parameter, + the scope of the highest-scope fixture is picked for the parameter set, instead of that of the one with the narrowest scope. + + +- `#11456 `_: Parametrized tests now *really do* ensure that the ids given to each input are unique - for + example, ``a, a, a0`` now results in ``a1, a2, a0`` instead of the previous (buggy) ``a0, a1, a0``. + This necessarily means changing nodeids where these were previously colliding, and for + readability adds an underscore when non-unique ids end in a number. + + +- `#11563 `_: Fixed a crash when using an empty string for the same parametrized value more than once. + + +- `#11712 `_: Fixed handling ``NO_COLOR`` and ``FORCE_COLOR`` to ignore an empty value. + + +- `#9036 `_: ``pytest.warns`` and similar functions now capture warnings when an exception is raised inside a ``with`` block. + + + +Improved Documentation +---------------------- + +- `#11011 `_: Added a warning about modifying the root logger during tests when using ``caplog``. + + +- `#11065 `_: Use ``pytestconfig`` instead of ``request.config`` in cache example to be consistent with the API documentation. + + +Trivial/Internal Changes +------------------------ + +- `#11208 `_: The (internal) ``FixtureDef.cached_result`` type has changed. + Now the third item ``cached_result[2]``, when set, is an exception instance instead of an exception triplet. + + +- `#11218 `_: (This entry is meant to assist plugins which access private pytest internals to instantiate ``FixtureRequest`` objects.) + + :class:`~pytest.FixtureRequest` is now an abstract class which can't be instantiated directly. + A new concrete ``TopRequest`` subclass of ``FixtureRequest`` has been added for the ``request`` fixture in test functions, + as counterpart to the existing ``SubRequest`` subclass for the ``request`` fixture in fixture functions. + + +- `#11315 `_: The :fixture:`pytester` fixture now uses the :fixture:`monkeypatch` fixture to manage the current working directory. + If you use ``pytester`` in combination with :func:`monkeypatch.undo() `, the CWD might get restored. + Use :func:`monkeypatch.context() ` instead. + + +- `#11333 `_: Corrected the spelling of ``Config.ArgsSource.INVOCATION_DIR``. + The previous spelling ``INCOVATION_DIR`` remains as an alias. + + +- `#11638 `_: Fixed the selftests to pass correctly if ``FORCE_COLOR``, ``NO_COLOR`` or ``PY_COLORS`` is set in the calling environment. + +pytest 7.4.4 (2023-12-31) +========================= + +Bug Fixes +--------- + +- `#11140 `_: Fix non-string constants at the top of file being detected as docstrings on Python>=3.8. + + +- `#11572 `_: Handle an edge case where :data:`sys.stderr` and :data:`sys.__stderr__` might already be closed when :ref:`faulthandler` is tearing down. + + +- `#11710 `_: Fixed tracebacks from collection errors not getting pruned. + + +- `#7966 `_: Removed unhelpful error message from assertion rewrite mechanism when exceptions are raised in ``__iter__`` methods. Now they are treated un-iterable instead. + + + +Improved Documentation +---------------------- + +- `#11091 `_: Updated documentation to refer to hyphenated options: replaced ``--junitxml`` with ``--junit-xml`` and ``--collectonly`` with ``--collect-only``. + + +pytest 7.4.3 (2023-10-24) +========================= + +Bug Fixes +--------- + +- `#10447 `_: Markers are now considered in the reverse mro order to ensure base class markers are considered first -- this resolves a regression. + + +- `#11239 `_: Fixed ``:=`` in asserts impacting unrelated test cases. + + +- `#11439 `_: Handled an edge case where :data:`sys.stderr` might already be closed when :ref:`faulthandler` is tearing down. + + +pytest 7.4.2 (2023-09-07) +========================= + +Bug Fixes +--------- + +- `#11237 `_: Fix doctest collection of `functools.cached_property` objects. + + +- `#11306 `_: Fixed bug using ``--importmode=importlib`` which would cause package ``__init__.py`` files to be imported more than once in some cases. + + +- `#11367 `_: Fixed bug where `user_properties` where not being saved in the JUnit XML file if a fixture failed during teardown. + + +- `#11394 `_: Fixed crash when parsing long command line arguments that might be interpreted as files. + + + +Improved Documentation +---------------------- + +- `#11391 `_: Improved disclaimer on pytest plugin reference page to better indicate this is an automated, non-curated listing. + + +pytest 7.4.1 (2023-09-02) +========================= + +Bug Fixes +--------- + +- `#10337 `_: Fixed bug where fake intermediate modules generated by ``--import-mode=importlib`` would not include the + child modules as attributes of the parent modules. + + +- `#10702 `_: Fixed error assertion handling in :func:`pytest.approx` when ``None`` is an expected or received value when comparing dictionaries. + + +- `#10811 `_: Fixed issue when using ``--import-mode=importlib`` together with ``--doctest-modules`` that caused modules + to be imported more than once, causing problems with modules that have import side effects. + + +pytest 7.4.0 (2023-06-23) +========================= + +Features +-------- + +- `#10901 `_: Added :func:`ExceptionInfo.from_exception() `, a simpler way to create an :class:`~pytest.ExceptionInfo` from an exception. + This can replace :func:`ExceptionInfo.from_exc_info() ` for most uses. + + + +Improvements +------------ + +- `#10872 `_: Update test log report annotation to named tuple and fixed inconsistency in docs for :hook:`pytest_report_teststatus` hook. + + +- `#10907 `_: When an exception traceback to be displayed is completely filtered out (by mechanisms such as ``__tracebackhide__``, internal frames, and similar), now only the exception string and the following message are shown: + + "All traceback entries are hidden. Pass `--full-trace` to see hidden and internal frames.". + + Previously, the last frame of the traceback was shown, even though it was hidden. + + +- `#10940 `_: Improved verbose output (``-vv``) of ``skip`` and ``xfail`` reasons by performing text wrapping while leaving a clear margin for progress output. + + Added ``TerminalReporter.wrap_write()`` as a helper for that. + + +- `#10991 `_: Added handling of ``%f`` directive to print microseconds in log format options, such as ``log-date-format``. + + +- `#11005 `_: Added the underlying exception to the cache provider's path creation and write warning messages. + + +- `#11013 `_: Added warning when :confval:`testpaths` is set, but paths are not found by glob. In this case, pytest will fall back to searching from the current directory. + + +- `#11043 `_: When `--confcutdir` is not specified, and there is no config file present, the conftest cutoff directory (`--confcutdir`) is now set to the :ref:`rootdir `. + Previously in such cases, `conftest.py` files would be probed all the way to the root directory of the filesystem. + If you are badly affected by this change, consider adding an empty config file to your desired cutoff directory, or explicitly set `--confcutdir`. + + +- `#11081 `_: The :confval:`norecursedirs` check is now performed in a :hook:`pytest_ignore_collect` implementation, so plugins can affect it. + + If after updating to this version you see that your `norecursedirs` setting is not being respected, + it means that a conftest or a plugin you use has a bad `pytest_ignore_collect` implementation. + Most likely, your hook returns `False` for paths it does not want to ignore, + which ends the processing and doesn't allow other plugins, including pytest itself, to ignore the path. + The fix is to return `None` instead of `False` for paths your hook doesn't want to ignore. + + +- `#8711 `_: :func:`caplog.set_level() ` and :func:`caplog.at_level() ` + will temporarily enable the requested ``level`` if ``level`` was disabled globally via + ``logging.disable(LEVEL)``. + + + +Bug Fixes +--------- + +- `#10831 `_: Terminal Reporting: Fixed bug when running in ``--tb=line`` mode where ``pytest.fail(pytrace=False)`` tests report ``None``. + + +- `#11068 `_: Fixed the ``--last-failed`` whole-file skipping functionality ("skipped N files") for :ref:`non-python test files `. + + +- `#11104 `_: Fixed a regression in pytest 7.3.2 which caused to :confval:`testpaths` to be considered for loading initial conftests, + even when it was not utilized (e.g. when explicit paths were given on the command line). + Now the ``testpaths`` are only considered when they are in use. + + +- `#1904 `_: Fixed traceback entries hidden with ``__tracebackhide__ = True`` still being shown for chained exceptions (parts after "... the above exception ..." message). + + +- `#7781 `_: Fix writing non-encodable text to log file when using ``--debug``. + + + +Improved Documentation +---------------------- + +- `#9146 `_: Improved documentation for :func:`caplog.set_level() `. + + + +Trivial/Internal Changes +------------------------ + +- `#11031 `_: Enhanced the CLI flag for ``-c`` to now include ``--config-file`` to make it clear that this flag applies to the usage of a custom config file. + + pytest 7.3.2 (2023-06-10) ========================= @@ -214,7 +2036,7 @@ pytest 7.2.0 (2022-10-23) Deprecations ------------ -- `#10012 `_: Update :class:`pytest.PytestUnhandledCoroutineWarning` to a deprecation; it will raise an error in pytest 8. +- `#10012 `_: Update ``pytest.PytestUnhandledCoroutineWarning`` to a deprecation; it will raise an error in pytest 8. - `#10396 `_: pytest no longer depends on the ``py`` library. ``pytest`` provides a vendored copy of ``py.error`` and ``py.path`` modules but will use the ``py`` library if it is installed. If you need other ``py.*`` modules, continue to install the deprecated ``py`` library separately, otherwise it can usually be removed as a dependency. @@ -264,7 +2086,7 @@ Improvements - `#8508 `_: Introduce multiline display for warning matching via :py:func:`pytest.warns` and - enhance match comparison for :py:func:`_pytest._code.ExceptionInfo.match` as returned by :py:func:`pytest.raises`. + enhance match comparison for :py:func:`pytest.ExceptionInfo.match` as returned by :py:func:`pytest.raises`. - `#8646 `_: Improve :py:func:`pytest.raises`. Previously passing an empty tuple would give a confusing @@ -273,7 +2095,7 @@ Improvements - `#9741 `_: On Python 3.11, use the standard library's :mod:`tomllib` to parse TOML. - :mod:`tomli` is no longer a dependency on Python 3.11. + `tomli` is no longer a dependency on Python 3.11. - `#9742 `_: Display assertion message without escaped newline characters with ``-vv``. @@ -308,7 +2130,7 @@ Bug Fixes When inheriting marks from super-classes, marks from the sub-classes are now ordered before marks from the super-classes, in MRO order. Previously it was the reverse. - When inheriting marks from super-classes, the `pytestmark` attribute of the sub-class now only contains the marks directly applied to it. Previously, it also contained marks from its super-classes. Please note that this attribute should not normally be accessed directly; use :func:`pytest.Node.iter_markers` instead. + When inheriting marks from super-classes, the `pytestmark` attribute of the sub-class now only contains the marks directly applied to it. Previously, it also contained marks from its super-classes. Please note that this attribute should not normally be accessed directly; use :func:`Node.iter_markers <_pytest.nodes.Node.iter_markers>` instead. - `#9159 `_: Showing inner exceptions by forcing native display in ``ExceptionGroups`` even when using display options other than ``--tb=native``. A temporary step before full implementation of pytest-native display for inner exceptions in ``ExceptionGroups``. @@ -496,7 +2318,7 @@ Bug Fixes tests/link -> tests/real running ``pytest tests`` now imports the conftest twice, once as ``tests/real/conftest.py`` and once as ``tests/link/conftest.py``. - This is a fix to match a similar change made to test collection itself in pytest 6.0 (see :pull:`6523` for details). + This is a fix to match a similar change made to test collection itself in pytest 6.0 (see :pr:`6523` for details). - `#9626 `_: Fixed count of selected tests on terminal collection summary when there were errors or skipped modules. @@ -549,7 +2371,7 @@ Deprecations ``__init__`` method, they should take ``**kwargs``. See :ref:`uncooperative-constructors-deprecated` for details. - Note that a deprection warning is only emitted when there is a conflict in the + Note that a deprecation warning is only emitted when there is a conflict in the arguments pytest expected to pass. This deprecation was already part of pytest 7.0.0rc1 but wasn't documented. @@ -561,7 +2383,7 @@ Bug Fixes - `#9355 `_: Fixed error message prints function decorators when using assert in Python 3.8 and above. -- `#9396 `_: Ensure :attr:`pytest.Config.inifile` is available during the :func:`pytest_cmdline_main <_pytest.hookspec.pytest_cmdline_main>` hook (regression during ``7.0.0rc1``). +- `#9396 `_: Ensure `pytest.Config.inifile` is available during the :hook:`pytest_cmdline_main` hook (regression during ``7.0.0rc1``). @@ -591,7 +2413,7 @@ Breaking Changes - `#7259 `_: The :ref:`Node.reportinfo() ` function first return value type has been expanded from `py.path.local | str` to `os.PathLike[str] | str`. Most plugins which refer to `reportinfo()` only define it as part of a custom :class:`pytest.Item` implementation. - Since `py.path.local` is an `os.PathLike[str]`, these plugins are unaffacted. + Since `py.path.local` is an `os.PathLike[str]`, these plugins are unaffected. Plugins and users which call `reportinfo()`, use the first return value and interact with it as a `py.path.local`, would need to adjust by calling `py.path.local(fspath)`. Although preferably, avoid the legacy `py.path.local` and use `pathlib.Path`, or use `item.location` or `item.path`, instead. @@ -706,13 +2528,13 @@ Deprecations - ``parser.addoption(..., type="int/string/float/complex")`` - use ``type=int`` etc. instead. -- `#8447 `_: Defining a custom pytest node type which is both an :class:`pytest.Item ` and a :class:`pytest.Collector ` (e.g. :class:`pytest.File `) now issues a warning. +- `#8447 `_: Defining a custom pytest node type which is both an :class:`~pytest.Item` and a :class:`~pytest.Collector` (e.g. :class:`~pytest.File`) now issues a warning. It was never sanely supported and triggers hard to debug errors. See :ref:`the deprecation note ` for full details. -- `#8592 `_: :hook:`pytest_cmdline_preparse` has been officially deprecated. It will be removed in a future release. Use :hook:`pytest_load_initial_conftests` instead. +- `#8592 `_: ``pytest_cmdline_preparse`` has been officially deprecated. It will be removed in a future release. Use :hook:`pytest_load_initial_conftests` instead. See :ref:`the deprecation note ` for full details. @@ -748,7 +2570,7 @@ Features - `#7132 `_: Added two environment variables :envvar:`PYTEST_THEME` and :envvar:`PYTEST_THEME_MODE` to let the users customize the pygments theme used. -- `#7259 `_: Added :meth:`cache.mkdir() `, which is similar to the existing :meth:`cache.makedir() `, +- `#7259 `_: Added :meth:`cache.mkdir() `, which is similar to the existing ``cache.makedir()``, but returns a :class:`pathlib.Path` instead of a legacy ``py.path.local``. Added a ``paths`` type to :meth:`parser.addini() `, @@ -774,7 +2596,7 @@ Features - ``pytest.HookRecorder`` for the :class:`HookRecorder ` type returned from :class:`~pytest.Pytester`. - ``pytest.RecordedHookCall`` for the :class:`RecordedHookCall ` type returned from :class:`~pytest.HookRecorder`. - ``pytest.RunResult`` for the :class:`RunResult ` type returned from :class:`~pytest.Pytester`. - - ``pytest.LineMatcher`` for the :class:`LineMatcher ` type used in :class:`~pytest.RunResult` and others. + - ``pytest.LineMatcher`` for the :class:`LineMatcher ` type used in :class:`~pytest.RunResult` and others. - ``pytest.TestReport`` for the :class:`TestReport ` type used in various hooks. - ``pytest.CollectReport`` for the :class:`CollectReport ` type used in various hooks. @@ -807,7 +2629,7 @@ Features - `#8251 `_: Implement ``Node.path`` as a ``pathlib.Path``. Both the old ``fspath`` and this new attribute gets set no matter whether ``path`` or ``fspath`` (deprecated) is passed to the constructor. It is a replacement for the ``fspath`` attribute (which represents the same path as ``py.path.local``). While ``fspath`` is not deprecated yet - due to the ongoing migration of methods like :meth:`~_pytest.Item.reportinfo`, we expect to deprecate it in a future release. + due to the ongoing migration of methods like :meth:`~pytest.Item.reportinfo`, we expect to deprecate it in a future release. .. note:: The name of the :class:`~_pytest.nodes.Node` arguments and attributes (the @@ -839,7 +2661,7 @@ Features See :ref:`plugin-stash` for details. -- `#8953 `_: :class:`RunResult <_pytest.pytester.RunResult>` method :meth:`assert_outcomes <_pytest.pytester.RunResult.assert_outcomes>` now accepts a +- `#8953 `_: :class:`~pytest.RunResult` method :meth:`~pytest.RunResult.assert_outcomes` now accepts a ``warnings`` argument to assert the total number of warnings captured. @@ -851,7 +2673,7 @@ Features used. -- `#9113 `_: :class:`RunResult <_pytest.pytester.RunResult>` method :meth:`assert_outcomes <_pytest.pytester.RunResult.assert_outcomes>` now accepts a +- `#9113 `_: :class:`~pytest.RunResult` method :meth:`~pytest.RunResult.assert_outcomes` now accepts a ``deselected`` argument to assert the total number of deselected tests. @@ -864,7 +2686,7 @@ Improvements - `#7480 `_: A deprecation scheduled to be removed in a major version X (e.g. pytest 7, 8, 9, ...) now uses warning category `PytestRemovedInXWarning`, a subclass of :class:`~pytest.PytestDeprecationWarning`, - instead of :class:`PytestDeprecationWarning` directly. + instead of :class:`~pytest.PytestDeprecationWarning` directly. See :ref:`backwards-compatibility` for more details. @@ -903,7 +2725,7 @@ Improvements - `#8803 `_: It is now possible to add colors to custom log levels on cli log. - By using :func:`add_color_level <_pytest.logging.add_color_level>` from a ``pytest_configure`` hook, colors can be added:: + By using ``add_color_level`` from a :hook:`pytest_configure` hook, colors can be added:: logging_plugin = config.pluginmanager.get_plugin('logging-plugin') logging_plugin.log_cli_handler.formatter.add_color_level(logging.INFO, 'cyan') @@ -968,7 +2790,7 @@ Bug Fixes - `#8503 `_: :meth:`pytest.MonkeyPatch.syspath_prepend` no longer fails when ``setuptools`` is not installed. - It now only calls :func:`pkg_resources.fixup_namespace_packages` if + It now only calls ``pkg_resources.fixup_namespace_packages`` if ``pkg_resources`` was previously imported, because it is not needed otherwise. @@ -1099,7 +2921,7 @@ Bug Fixes the ``tmp_path``/``tmpdir`` fixture). Now the directories are created with private permissions. - pytest used to silently use a pre-existing ``/tmp/pytest-of-`` directory, + pytest used to silently use a preexisting ``/tmp/pytest-of-`` directory, even if owned by another user. This means another user could pre-create such a directory and gain control of another user's temporary directory. Now such a condition results in an error. @@ -1195,7 +3017,7 @@ Features This is part of the movement to use :class:`pathlib.Path` objects internally, in order to remove the dependency to ``py`` in the future. - Internally, the old :class:`Testdir <_pytest.pytester.Testdir>` is now a thin wrapper around :class:`Pytester <_pytest.pytester.Pytester>`, preserving the old interface. + Internally, the old ``pytest.Testdir`` is now a thin wrapper around :class:`~pytest.Pytester`, preserving the old interface. - :issue:`7695`: A new hook was added, `pytest_markeval_namespace` which should return a dictionary. @@ -1233,7 +3055,7 @@ Features Improvements ------------ -- :issue:`1265`: Added an ``__str__`` implementation to the :class:`~pytest.pytester.LineMatcher` class which is returned from ``pytester.run_pytest().stdout`` and similar. It returns the entire output, like the existing ``str()`` method. +- :issue:`1265`: Added an ``__str__`` implementation to the :class:`~pytest.LineMatcher` class which is returned from ``pytester.run_pytest().stdout`` and similar. It returns the entire output, like the existing ``str()`` method. - :issue:`2044`: Verbose mode now shows the reason that a test was skipped in the test's terminal line after the "SKIPPED", "XFAIL" or "XPASS". @@ -1297,7 +3119,7 @@ Bug Fixes - :issue:`7911`: Directories created by by :fixture:`tmp_path` and :fixture:`tmpdir` are now considered stale after 3 days without modification (previous value was 3 hours) to avoid deleting directories still in use in long running test suites. -- :issue:`7913`: Fixed a crash or hang in :meth:`pytester.spawn <_pytest.pytester.Pytester.spawn>` when the :mod:`readline` module is involved. +- :issue:`7913`: Fixed a crash or hang in :meth:`pytester.spawn ` when the :mod:`readline` module is involved. - :issue:`7951`: Fixed handling of recursive symlinks when collecting tests. @@ -1414,7 +3236,7 @@ Deprecations if you use this and want a replacement. -- :issue:`7255`: The :hook:`pytest_warning_captured` hook is deprecated in favor +- :issue:`7255`: The ``pytest_warning_captured`` hook is deprecated in favor of :hook:`pytest_warning_recorded`, and will be removed in a future version. @@ -1442,8 +3264,8 @@ Improvements - :issue:`7572`: When a plugin listed in ``required_plugins`` is missing or an unknown config key is used with ``--strict-config``, a simple error message is now shown instead of a stacktrace. -- :issue:`7685`: Added two new attributes :attr:`rootpath <_pytest.config.Config.rootpath>` and :attr:`inipath <_pytest.config.Config.inipath>` to :class:`Config <_pytest.config.Config>`. - These attributes are :class:`pathlib.Path` versions of the existing :attr:`rootdir <_pytest.config.Config.rootdir>` and :attr:`inifile <_pytest.config.Config.inifile>` attributes, +- :issue:`7685`: Added two new attributes :attr:`rootpath ` and :attr:`inipath ` to :class:`~pytest.Config`. + These attributes are :class:`pathlib.Path` versions of the existing ``rootdir`` and ``inifile`` attributes, and should be preferred over them when possible. @@ -1514,7 +3336,7 @@ Trivial/Internal Changes - :issue:`7587`: The dependency on the ``more-itertools`` package has been removed. -- :issue:`7631`: The result type of :meth:`capfd.readouterr() <_pytest.capture.CaptureFixture.readouterr>` (and similar) is no longer a namedtuple, +- :issue:`7631`: The result type of :meth:`capfd.readouterr() ` (and similar) is no longer a namedtuple, but should behave like one in all respects. This was done for technical reasons. @@ -1665,7 +3487,7 @@ Breaking Changes Resolving symlinks for the current directory and during collection was introduced as a bugfix in 3.9.0, but it actually is a new feature which had unfortunate consequences in Windows and surprising results in other platforms. The team decided to step back on resolving symlinks at all, planning to review this in the future with a more solid solution (see discussion in - :pull:`6523` for details). + :pr:`6523` for details). This might break test suites which made use of this feature; the fix is to create a symlink for the entire test tree, and not only to partial files/tress as it was possible previously. @@ -1826,7 +3648,7 @@ Features also changes ``sys.modules`` as a side-effect), which works but has a number of drawbacks, like requiring test modules that don't live in packages to have unique names (as they need to reside under a unique name in ``sys.modules``). - ``--import-mode=importlib`` uses more fine grained import mechanisms from ``importlib`` which don't + ``--import-mode=importlib`` uses more fine-grained import mechanisms from ``importlib`` which don't require pytest to change ``sys.path`` or ``sys.modules`` at all, eliminating much of the drawbacks of the previous mode. @@ -1843,7 +3665,7 @@ Improvements ------------ - :issue:`4375`: The ``pytest`` command now suppresses the ``BrokenPipeError`` error message that - is printed to stderr when the output of ``pytest`` is piped and and the pipe is + is printed to stderr when the output of ``pytest`` is piped and the pipe is closed by the piped-to program (common examples are ``less`` and ``head``). @@ -1892,10 +3714,10 @@ Improvements - :issue:`7128`: `pytest --version` now displays just the pytest version, while `pytest --version --version` displays more verbose information including plugins. This is more consistent with how other tools show `--version`. -- :issue:`7133`: :meth:`caplog.set_level() <_pytest.logging.LogCaptureFixture.set_level>` will now override any :confval:`log_level` set via the CLI or configuration file. +- :issue:`7133`: :meth:`caplog.set_level() ` will now override any :confval:`log_level` set via the CLI or configuration file. -- :issue:`7159`: :meth:`caplog.set_level() <_pytest.logging.LogCaptureFixture.set_level>` and :meth:`caplog.at_level() <_pytest.logging.LogCaptureFixture.at_level>` no longer affect +- :issue:`7159`: :meth:`caplog.set_level() ` and :meth:`caplog.at_level() ` no longer affect the level of logs that are shown in the *Captured log report* report section. @@ -1948,7 +3770,7 @@ Bug Fixes - :issue:`6871`: Fix crash with captured output when using :fixture:`capsysbinary`. -- :issue:`6909`: Revert the change introduced by :pull:`6330`, which required all arguments to ``@pytest.mark.parametrize`` to be explicitly defined in the function signature. +- :issue:`6909`: Revert the change introduced by :pr:`6330`, which required all arguments to ``@pytest.mark.parametrize`` to be explicitly defined in the function signature. The intention of the original change was to remove what was expected to be an unintended/surprising behavior, but it turns out many people relied on it, so the restriction has been reverted. @@ -1990,7 +3812,7 @@ Bug Fixes parameter when Python is called with the ``-bb`` flag. -- :issue:`7143`: Fix :meth:`pytest.File.from_parent` so it forwards extra keyword arguments to the constructor. +- :issue:`7143`: Fix :meth:`pytest.File.from_parent <_pytest.nodes.Node.from_parent>` so it forwards extra keyword arguments to the constructor. - :issue:`7145`: Classes with broken ``__getattribute__`` methods are displayed correctly during failures. @@ -2118,7 +3940,7 @@ pytest 5.4.1 (2020-03-13) Bug Fixes --------- -- :issue:`6909`: Revert the change introduced by :pull:`6330`, which required all arguments to ``@pytest.mark.parametrize`` to be explicitly defined in the function signature. +- :issue:`6909`: Revert the change introduced by :pr:`6330`, which required all arguments to ``@pytest.mark.parametrize`` to be explicitly defined in the function signature. The intention of the original change was to remove what was expected to be an unintended/surprising behavior, but it turns out many people relied on it, so the restriction has been reverted. @@ -2145,7 +3967,7 @@ Breaking Changes This hook has been marked as deprecated and not been even called by pytest for over 10 years now. -- :issue:`6673`: Reversed / fix meaning of "+/-" in error diffs. "-" means that sth. expected is missing in the result and "+" means that there are unexpected extras in the result. +- :issue:`6673`: Reversed / fix meaning of "+/-" in error diffs. "-" means that something expected is missing in the result and "+" means that there are unexpected extras in the result. - :issue:`6737`: The ``cached_result`` attribute of ``FixtureDef`` is now set to ``None`` when @@ -2241,7 +4063,7 @@ Improvements - :issue:`6384`: Make `--showlocals` work also with `--tb=short`. -- :issue:`6653`: Add support for matching lines consecutively with :attr:`LineMatcher <_pytest.pytester.LineMatcher>`'s :func:`~_pytest.pytester.LineMatcher.fnmatch_lines` and :func:`~_pytest.pytester.LineMatcher.re_match_lines`. +- :issue:`6653`: Add support for matching lines consecutively with :class:`~pytest.LineMatcher`'s :func:`~pytest.LineMatcher.fnmatch_lines` and :func:`~pytest.LineMatcher.re_match_lines`. - :issue:`6658`: Code is now highlighted in tracebacks when ``pygments`` is installed. @@ -2309,7 +4131,7 @@ Bug Fixes - :issue:`6597`: Fix node ids which contain a parametrized empty-string variable. -- :issue:`6646`: Assertion rewriting hooks are (re)stored for the current item, which fixes them being still used after e.g. pytester's :func:`testdir.runpytest <_pytest.pytester.Testdir.runpytest>` etc. +- :issue:`6646`: Assertion rewriting hooks are (re)stored for the current item, which fixes them being still used after e.g. pytester's ``testdir.runpytest`` etc. - :issue:`6660`: :py:func:`pytest.exit` is handled when emitted from the :hook:`pytest_sessionfinish` hook. This includes quitting from a debugger. @@ -2375,7 +4197,7 @@ Bug Fixes ``multiprocessing`` module. -- :issue:`6436`: :class:`FixtureDef <_pytest.fixtures.FixtureDef>` objects now properly register their finalizers with autouse and +- :issue:`6436`: :class:`~pytest.FixtureDef` objects now properly register their finalizers with autouse and parameterized fixtures that execute before them in the fixture stack so they are torn down at the right times, and in the right order. @@ -2431,10 +4253,12 @@ Improvements Bug Fixes --------- -- :issue:`5914`: pytester: fix :py:func:`~_pytest.pytester.LineMatcher.no_fnmatch_line` when used after positive matching. +- :issue:`5914`: pytester: fix :py:func:`~pytest.LineMatcher.no_fnmatch_line` when used after positive matching. -- :issue:`6082`: Fix line detection for doctest samples inside :py:class:`python:property` docstrings, as a workaround to :bpo:`17446`. +- :issue:`6082`: Fix line detection for doctest samples inside + :py:class:`python:property` docstrings, as a workaround to + :issue:`python/cpython#61648`. - :issue:`6254`: Fix compatibility with pytest-parallel (regression in pytest 5.3.0). @@ -2495,8 +4319,8 @@ Features rather than implicitly. -- :issue:`5914`: :fixture:`testdir` learned two new functions, :py:func:`~_pytest.pytester.LineMatcher.no_fnmatch_line` and - :py:func:`~_pytest.pytester.LineMatcher.no_re_match_line`. +- :issue:`5914`: :fixture:`testdir` learned two new functions, :py:func:`~pytest.LineMatcher.no_fnmatch_line` and + :py:func:`~pytest.LineMatcher.no_re_match_line`. The functions are used to ensure the captured text *does not* match the given pattern. @@ -3141,7 +4965,7 @@ Bug Fixes (``--collect-only``) when ``--log-cli-level`` is used. -- :issue:`5389`: Fix regressions of :pull:`5063` for ``importlib_metadata.PathDistribution`` which have their ``files`` attribute being ``None``. +- :issue:`5389`: Fix regressions of :pr:`5063` for ``importlib_metadata.PathDistribution`` which have their ``files`` attribute being ``None``. - :issue:`5390`: Fix regression where the ``obj`` attribute of ``TestCase`` items was no longer bound to methods. @@ -3342,7 +5166,7 @@ Bug Fixes (``--collect-only``) when ``--log-cli-level`` is used. -- :issue:`5389`: Fix regressions of :pull:`5063` for ``importlib_metadata.PathDistribution`` which have their ``files`` attribute being ``None``. +- :issue:`5389`: Fix regressions of :pr:`5063` for ``importlib_metadata.PathDistribution`` which have their ``files`` attribute being ``None``. - :issue:`5390`: Fix regression where the ``obj`` attribute of ``TestCase`` items was no longer bound to methods. @@ -3750,7 +5574,7 @@ Bug Fixes Improved Documentation ---------------------- -- :issue:`4974`: Update docs for ``pytest_cmdline_parse`` hook to note availability liminations +- :issue:`4974`: Update docs for ``pytest_cmdline_parse`` hook to note availability limitations @@ -5608,7 +7432,7 @@ Features Bug Fixes --------- -- Fix hanging pexpect test on MacOS by using flush() instead of wait(). +- Fix hanging pexpect test on macOS by using flush() instead of wait(). (:issue:`2022`) - Fix restoring Python state after in-process pytest runs with the @@ -5656,7 +7480,7 @@ Trivial/Internal Changes ------------------------ - Show a simple and easy error when keyword expressions trigger a syntax error - (for example, ``"-k foo and import"`` will show an error that you can not use + (for example, ``"-k foo and import"`` will show an error that you cannot use the ``import`` keyword in expressions). (:issue:`2953`) - Change parametrized automatic test id generation to use the ``__name__`` @@ -6303,10 +8127,10 @@ New Features * Added ``junit_suite_name`` ini option to specify root ```` name for JUnit XML reports (:issue:`533`). * Added an ini option ``doctest_encoding`` to specify which encoding to use for doctest files. - Thanks :user:`wheerd` for the PR (:pull:`2101`). + Thanks :user:`wheerd` for the PR (:pr:`2101`). * ``pytest.warns`` now checks for subclass relationship rather than - class equality. Thanks :user:`lesteve` for the PR (:pull:`2166`) + class equality. Thanks :user:`lesteve` for the PR (:pr:`2166`) * ``pytest.raises`` now asserts that the error message matches a text or regex with the ``match`` keyword argument. Thanks :user:`Kriechi` for the PR. @@ -6334,7 +8158,7 @@ Changes the failure. (:issue:`2228`) Thanks to :user:`kkoukiou` for the PR. * Testcase reports with a ``url`` attribute will now properly write this to junitxml. - Thanks :user:`fushi` for the PR (:pull:`1874`). + Thanks :user:`fushi` for the PR (:pr:`1874`). * Remove common items from dict comparison output when verbosity=1. Also update the truncation message to make it clearer that pytest truncates all @@ -6343,12 +8167,12 @@ Changes * ``--pdbcls`` no longer implies ``--pdb``. This makes it possible to use ``addopts=--pdbcls=module.SomeClass`` on ``pytest.ini``. Thanks :user:`davidszotten` for - the PR (:pull:`1952`). + the PR (:pr:`1952`). * fix :issue:`2013`: turn RecordedWarning into ``namedtuple``, to give it a comprehensible repr while preventing unwarranted modification. -* fix :issue:`2208`: ensure an iteration limit for _pytest.compat.get_real_func. +* fix :issue:`2208`: ensure an iteration limit for ``_pytest.compat.get_real_func``. Thanks :user:`RonnyPfannschmidt` for the report and PR. * Hooks are now verified after collection is complete, rather than right after loading installed plugins. This @@ -6597,7 +8421,7 @@ Bug Fixes a sequence of strings) when modules are considered for assertion rewriting. Due to this bug, much more modules were being rewritten than necessary if a test suite uses ``pytest_plugins`` to load internal plugins (:issue:`1888`). - Thanks :user:`jaraco` for the report and :user:`nicoddemus` for the PR (:pull:`1891`). + Thanks :user:`jaraco` for the report and :user:`nicoddemus` for the PR (:pr:`1891`). * Do not call tearDown and cleanups when running tests from ``unittest.TestCase`` subclasses with ``--pdb`` @@ -6652,12 +8476,12 @@ time or change existing behaviors in order to make them less surprising/more use * ``--nomagic``: use ``--assert=plain`` instead; * ``--report``: use ``-r`` instead; - Thanks to :user:`RedBeardCode` for the PR (:pull:`1664`). + Thanks to :user:`RedBeardCode` for the PR (:pr:`1664`). * ImportErrors in plugins now are a fatal error instead of issuing a pytest warning (:issue:`1479`). Thanks to :user:`The-Compiler` for the PR. -* Removed support code for Python 3 versions < 3.3 (:pull:`1627`). +* Removed support code for Python 3 versions < 3.3 (:pr:`1627`). * Removed all ``py.test-X*`` entry points. The versioned, suffixed entry points were never documented and a leftover from a pre-virtualenv era. These entry @@ -6668,19 +8492,19 @@ time or change existing behaviors in order to make them less surprising/more use * ``pytest.skip()`` now raises an error when used to decorate a test function, as opposed to its original intent (to imperatively skip a test inside a test function). Previously this usage would cause the entire module to be skipped (:issue:`607`). - Thanks :user:`omarkohl` for the complete PR (:pull:`1519`). + Thanks :user:`omarkohl` for the complete PR (:pr:`1519`). * Exit tests if a collection error occurs. A poll indicated most users will hit CTRL-C anyway as soon as they see collection errors, so pytest might as well make that the default behavior (:issue:`1421`). A ``--continue-on-collection-errors`` option has been added to restore the previous behaviour. - Thanks :user:`olegpidsadnyi` and :user:`omarkohl` for the complete PR (:pull:`1628`). + Thanks :user:`olegpidsadnyi` and :user:`omarkohl` for the complete PR (:pr:`1628`). * Renamed the pytest ``pdb`` module (plugin) into ``debugging`` to avoid clashes with the builtin ``pdb`` module. * Raise a helpful failure message when requesting a parametrized fixture at runtime, e.g. with ``request.getfixturevalue``. Previously these parameters were simply never defined, so a fixture decorated like ``@pytest.fixture(params=[0, 1, 2])`` - only ran once (:pull:`460`). + only ran once (:pr:`460`). Thanks to :user:`nikratio` for the bug report, :user:`RedBeardCode` and :user:`tomviner` for the PR. * ``_pytest.monkeypatch.monkeypatch`` class has been renamed to ``_pytest.monkeypatch.MonkeyPatch`` @@ -6698,7 +8522,7 @@ time or change existing behaviors in order to make them less surprising/more use * New ``doctest_namespace`` fixture for injecting names into the namespace in which doctests run. - Thanks :user:`milliams` for the complete PR (:pull:`1428`). + Thanks :user:`milliams` for the complete PR (:pr:`1428`). * New ``--doctest-report`` option available to change the output format of diffs when running (failing) doctests (implements :issue:`1749`). @@ -6706,23 +8530,23 @@ time or change existing behaviors in order to make them less surprising/more use * New ``name`` argument to ``pytest.fixture`` decorator which allows a custom name for a fixture (to solve the funcarg-shadowing-fixture problem). - Thanks :user:`novas0x2a` for the complete PR (:pull:`1444`). + Thanks :user:`novas0x2a` for the complete PR (:pr:`1444`). * New ``approx()`` function for easily comparing floating-point numbers in tests. - Thanks :user:`kalekundert` for the complete PR (:pull:`1441`). + Thanks :user:`kalekundert` for the complete PR (:pr:`1441`). * Ability to add global properties in the final xunit output file by accessing the internal ``junitxml`` plugin (experimental). - Thanks :user:`tareqalayan` for the complete PR :pull:`1454`). + Thanks :user:`tareqalayan` for the complete PR :pr:`1454`). * New ``ExceptionInfo.match()`` method to match a regular expression on the string representation of an exception (:issue:`372`). - Thanks :user:`omarkohl` for the complete PR (:pull:`1502`). + Thanks :user:`omarkohl` for the complete PR (:pr:`1502`). * ``__tracebackhide__`` can now also be set to a callable which then can decide whether to filter the traceback based on the ``ExceptionInfo`` object passed - to it. Thanks :user:`The-Compiler` for the complete PR (:pull:`1526`). + to it. Thanks :user:`The-Compiler` for the complete PR (:pr:`1526`). * New ``pytest_make_parametrize_id(config, val)`` hook which can be used by plugins to provide friendly strings for custom types. @@ -6740,7 +8564,7 @@ time or change existing behaviors in order to make them less surprising/more use * Introduce ``pytest`` command as recommended entry point. Note that ``py.test`` still works and is not scheduled for removal. Closes proposal :issue:`1629`. Thanks :user:`obestwalter` and :user:`davehunt` for the complete PR - (:pull:`1633`). + (:pr:`1633`). * New cli flags: @@ -6784,19 +8608,19 @@ time or change existing behaviors in order to make them less surprising/more use * Change ``report.outcome`` for ``xpassed`` tests to ``"passed"`` in non-strict mode and ``"failed"`` in strict mode. Thanks to :user:`hackebrot` for the PR - (:pull:`1795`) and :user:`gprasad84` for report (:issue:`1546`). + (:pr:`1795`) and :user:`gprasad84` for report (:issue:`1546`). * Tests marked with ``xfail(strict=False)`` (the default) now appear in JUnitXML reports as passing tests instead of skipped. - Thanks to :user:`hackebrot` for the PR (:pull:`1795`). + Thanks to :user:`hackebrot` for the PR (:pr:`1795`). * Highlight path of the file location in the error report to make it easier to copy/paste. - Thanks :user:`suzaku` for the PR (:pull:`1778`). + Thanks :user:`suzaku` for the PR (:pr:`1778`). * Fixtures marked with ``@pytest.fixture`` can now use ``yield`` statements exactly like those marked with the ``@pytest.yield_fixture`` decorator. This change renders ``@pytest.yield_fixture`` deprecated and makes ``@pytest.fixture`` with ``yield`` statements - the preferred way to write teardown code (:pull:`1461`). + the preferred way to write teardown code (:pr:`1461`). Thanks :user:`csaftoiu` for bringing this to attention and :user:`nicoddemus` for the PR. * Explicitly passed parametrize ids do not get escaped to ascii (:issue:`1351`). @@ -6807,11 +8631,11 @@ time or change existing behaviors in order to make them less surprising/more use Thanks :user:`nicoddemus` for the PR. * ``pytest_terminal_summary`` hook now receives the ``exitstatus`` - of the test session as argument. Thanks :user:`blueyed` for the PR (:pull:`1809`). + of the test session as argument. Thanks :user:`blueyed` for the PR (:pr:`1809`). * Parametrize ids can accept ``None`` as specific test id, in which case the automatically generated id for that argument will be used. - Thanks :user:`palaviv` for the complete PR (:pull:`1468`). + Thanks :user:`palaviv` for the complete PR (:pr:`1468`). * The parameter to xunit-style setup/teardown methods (``setup_method``, ``setup_module``, etc.) is now optional and may be omitted. @@ -6819,32 +8643,32 @@ time or change existing behaviors in order to make them less surprising/more use * Improved automatic id generation selection in case of duplicate ids in parametrize. - Thanks :user:`palaviv` for the complete PR (:pull:`1474`). + Thanks :user:`palaviv` for the complete PR (:pr:`1474`). * Now pytest warnings summary is shown up by default. Added a new flag ``--disable-pytest-warnings`` to explicitly disable the warnings summary (:issue:`1668`). * Make ImportError during collection more explicit by reminding the user to check the name of the test module/package(s) (:issue:`1426`). - Thanks :user:`omarkohl` for the complete PR (:pull:`1520`). + Thanks :user:`omarkohl` for the complete PR (:pr:`1520`). * Add ``build/`` and ``dist/`` to the default ``--norecursedirs`` list. Thanks :user:`mikofski` for the report and :user:`tomviner` for the PR (:issue:`1544`). * ``pytest.raises`` in the context manager form accepts a custom ``message`` to raise when no exception occurred. - Thanks :user:`palaviv` for the complete PR (:pull:`1616`). + Thanks :user:`palaviv` for the complete PR (:pr:`1616`). * ``conftest.py`` files now benefit from assertion rewriting; previously it was only available for test modules. Thanks :user:`flub`, :user:`sober7` and :user:`nicoddemus` for the PR (:issue:`1619`). * Text documents without any doctests no longer appear as "skipped". - Thanks :user:`graingert` for reporting and providing a full PR (:pull:`1580`). + Thanks :user:`graingert` for reporting and providing a full PR (:pr:`1580`). * Ensure that a module within a namespace package can be found when it is specified on the command line together with the ``--pyargs`` - option. Thanks to :user:`taschini` for the PR (:pull:`1597`). + option. Thanks to :user:`taschini` for the PR (:pr:`1597`). * Always include full assertion explanation during assertion rewriting. The previous behaviour was hiding sub-expressions that happened to be ``False``, assuming this was redundant information. @@ -6860,20 +8684,20 @@ time or change existing behaviors in order to make them less surprising/more use Thanks :user:`nicoddemus` for the PR. * ``[pytest]`` sections in ``setup.cfg`` files should now be named ``[tool:pytest]`` - to avoid conflicts with other distutils commands (see :pull:`567`). ``[pytest]`` sections in + to avoid conflicts with other distutils commands (see :pr:`567`). ``[pytest]`` sections in ``pytest.ini`` or ``tox.ini`` files are supported and unchanged. Thanks :user:`nicoddemus` for the PR. * Using ``pytest_funcarg__`` prefix to declare fixtures is considered deprecated and will be - removed in pytest-4.0 (:pull:`1684`). + removed in pytest-4.0 (:pr:`1684`). Thanks :user:`nicoddemus` for the PR. * Passing a command-line string to ``pytest.main()`` is considered deprecated and scheduled - for removal in pytest-4.0. It is recommended to pass a list of arguments instead (:pull:`1723`). + for removal in pytest-4.0. It is recommended to pass a list of arguments instead (:pr:`1723`). * Rename ``getfuncargvalue`` to ``getfixturevalue``. ``getfuncargvalue`` is still present but is now considered deprecated. Thanks to :user:`RedBeardCode` and :user:`tomviner` - for the PR (:pull:`1626`). + for the PR (:pr:`1626`). * ``optparse`` type usage now triggers DeprecationWarnings (:issue:`1740`). @@ -6931,11 +8755,11 @@ time or change existing behaviors in order to make them less surprising/more use :user:`tomviner` for the PR. * ``ConftestImportFailure`` now shows the traceback making it easier to - identify bugs in ``conftest.py`` files (:pull:`1516`). Thanks :user:`txomon` for + identify bugs in ``conftest.py`` files (:pr:`1516`). Thanks :user:`txomon` for the PR. * Text documents without any doctests no longer appear as "skipped". - Thanks :user:`graingert` for reporting and providing a full PR (:pull:`1580`). + Thanks :user:`graingert` for reporting and providing a full PR (:pr:`1580`). * Fixed collection of classes with custom ``__new__`` method. Fixes :issue:`1579`. Thanks to :user:`Stranger6667` for the PR. @@ -6943,7 +8767,7 @@ time or change existing behaviors in order to make them less surprising/more use * Fixed scope overriding inside metafunc.parametrize (:issue:`634`). Thanks to :user:`Stranger6667` for the PR. -* Fixed the total tests tally in junit xml output (:pull:`1798`). +* Fixed the total tests tally in junit xml output (:pr:`1798`). Thanks to :user:`cboelsen` for the PR. * Fixed off-by-one error with lines from ``request.node.warn``. @@ -6960,14 +8784,14 @@ time or change existing behaviors in order to make them less surprising/more use * Fix Xfail does not work with condition keyword argument. Thanks :user:`astraw38` for reporting the issue (:issue:`1496`) and :user:`tomviner` - for PR the (:pull:`1524`). + for PR the (:pr:`1524`). * Fix win32 path issue when putting custom config file with absolute path in ``pytest.main("-c your_absolute_path")``. * Fix maximum recursion depth detection when raised error class is not aware of unicode/encoded bytes. - Thanks :user:`prusse-martin` for the PR (:pull:`1506`). + Thanks :user:`prusse-martin` for the PR (:pr:`1506`). * Fix ``pytest.mark.skip`` mark when used in strict mode. Thanks :user:`pquentin` for the PR and :user:`RonnyPfannschmidt` for @@ -6994,7 +8818,7 @@ time or change existing behaviors in order to make them less surprising/more use Thanks :user:`nicoddemus` for the PR. * Fix (:issue:`469`): junit parses report.nodeid incorrectly, when params IDs - contain ``::``. Thanks :user:`tomviner` for the PR (:pull:`1431`). + contain ``::``. Thanks :user:`tomviner` for the PR (:pr:`1431`). * Fix (:issue:`578`): SyntaxErrors containing non-ascii lines at the point of failure generated an internal @@ -7015,7 +8839,7 @@ time or change existing behaviors in order to make them less surprising/more use **New Features** * New ``pytest.mark.skip`` mark, which unconditionally skips marked tests. - Thanks :user:`MichaelAquilina` for the complete PR (:pull:`1040`). + Thanks :user:`MichaelAquilina` for the complete PR (:pr:`1040`). * ``--doctest-glob`` may now be passed multiple times in the command-line. Thanks :user:`jab` and :user:`nicoddemus` for the PR. @@ -7026,14 +8850,14 @@ time or change existing behaviors in order to make them less surprising/more use * ``pytest.mark.xfail`` now has a ``strict`` option, which makes ``XPASS`` tests to fail the test suite (defaulting to ``False``). There's also a ``xfail_strict`` ini option that can be used to configure it project-wise. - Thanks :user:`rabbbit` for the request and :user:`nicoddemus` for the PR (:pull:`1355`). + Thanks :user:`rabbbit` for the request and :user:`nicoddemus` for the PR (:pr:`1355`). * ``Parser.addini`` now supports options of type ``bool``. Thanks :user:`nicoddemus` for the PR. * New ``ALLOW_BYTES`` doctest option. This strips ``b`` prefixes from byte strings in doctest output (similar to ``ALLOW_UNICODE``). - Thanks :user:`jaraco` for the request and :user:`nicoddemus` for the PR (:pull:`1287`). + Thanks :user:`jaraco` for the request and :user:`nicoddemus` for the PR (:pr:`1287`). * Give a hint on ``KeyboardInterrupt`` to use the ``--fulltrace`` option to show the errors. Fixes :issue:`1366`. @@ -7065,7 +8889,7 @@ time or change existing behaviors in order to make them less surprising/more use * Removed code and documentation for Python 2.5 or lower versions, including removal of the obsolete ``_pytest.assertion.oldinterpret`` module. - Thanks :user:`nicoddemus` for the PR (:pull:`1226`). + Thanks :user:`nicoddemus` for the PR (:pr:`1226`). * Comparisons now always show up in full when ``CI`` or ``BUILD_NUMBER`` is found in the environment, even when ``-vv`` isn't used. @@ -7432,7 +9256,7 @@ time or change existing behaviors in order to make them less surprising/more use one will also have a "reprec" attribute with the recorded events/reports. - fix monkeypatch.setattr("x.y", raising=False) to actually not raise - if "y" is not a pre-existing attribute. Thanks Florian Bruhin. + if "y" is not a preexisting attribute. Thanks Florian Bruhin. - fix issue741: make running output from testdir.run copy/pasteable Thanks Bruno Oliveira. @@ -7488,7 +9312,7 @@ time or change existing behaviors in order to make them less surprising/more use - fix issue854: autouse yield_fixtures defined as class members of unittest.TestCase subclasses now work as expected. - Thannks xmo-odoo for the report and Bruno Oliveira for the PR. + Thanks xmo-odoo for the report and Bruno Oliveira for the PR. - fix issue833: --fixtures now shows all fixtures of collected test files, instead of just the fixtures declared on the first one. @@ -7592,7 +9416,7 @@ time or change existing behaviors in order to make them less surprising/more use github. See https://pytest.org/en/stable/contributing.html . Thanks to Anatoly for pushing and initial work on this. -- fix issue650: new option ``--docttest-ignore-import-errors`` which +- fix issue650: new option ``--doctest-ignore-import-errors`` which will turn import errors in doctests into skips. Thanks Charles Cloud for the complete PR. @@ -7780,7 +9604,7 @@ time or change existing behaviors in order to make them less surprising/more use - cleanup setup.py a bit and specify supported versions. Thanks Jurko Gospodnetic for the PR. -- change XPASS colour to yellow rather then red when tests are run +- change XPASS colour to yellow rather than red when tests are run with -v. - fix issue473: work around mock putting an unbound method into a class @@ -7953,7 +9777,7 @@ time or change existing behaviors in order to make them less surprising/more use Thanks Ralph Schmitt for the precise failure example. - fix issue244 by implementing special index for parameters to only use - indices for paramentrized test ids + indices for parametrized test ids - fix issue287 by running all finalizers but saving the exception from the first failing finalizer and re-raising it so teardown will @@ -7961,7 +9785,7 @@ time or change existing behaviors in order to make them less surprising/more use it might be the cause for other finalizers to fail. - fix ordering when mock.patch or other standard decorator-wrappings - are used with test methods. This fixues issue346 and should + are used with test methods. This fixes issue346 and should help with random "xdist" collection failures. Thanks to Ronny Pfannschmidt and Donald Stufft for helping to isolate it. @@ -8218,7 +10042,7 @@ Bug fixes: partially failed (finalizers would not always be called before) - fix issue320 - fix class scope for fixtures when mixed with - module-level functions. Thanks Anatloy Bubenkoff. + module-level functions. Thanks Anatoly Bubenkoff. - you can specify "-q" or "-qq" to get different levels of "quieter" reporting (thanks Katarzyna Jachim) @@ -8640,7 +10464,7 @@ Bug fixes: unexpected exceptions - fix issue47: timing output in junitxml for test cases is now correct - fix issue48: typo in MarkInfo repr leading to exception -- fix issue49: avoid confusing error when initizaliation partially fails +- fix issue49: avoid confusing error when initialization partially fails - fix issue44: env/username expansion for junitxml file path - show releaselevel information in test runs for pypy - reworked doc pages for better navigation and PDF generation @@ -8765,7 +10589,7 @@ Bug fixes: collection-before-running semantics were not setup as with pytest 1.3.4. Note, however, that the recommended and much cleaner way to do test - parametraization remains the "pytest_generate_tests" + parameterization remains the "pytest_generate_tests" mechanism, see the docs. 2.0.0 (2010-11-25) diff --git a/doc/en/conf.py b/doc/en/conf.py index 32f508219a6..c89e14d07fa 100644 --- a/doc/en/conf.py +++ b/doc/en/conf.py @@ -1,81 +1,42 @@ -# -# pytest documentation build configuration file, created by -# sphinx-quickstart on Fri Oct 8 17:54:28 2010. -# -# This file is execfile()d with the current directory set to its containing dir. -# -# Note that not all possible configuration values are present in this -# autogenerated file. -# -# All configuration values have a default; values that are commented out -# serve to show the default. -# The version info for the project you're documenting, acts as replacement for -# |version| and |release|, also used in various other places throughout the -# built documents. -# -# The full version, including alpha/beta/rc tags. -# The short X.Y version. -import ast +from __future__ import annotations + import os +from pathlib import Path import shutil -import sys from textwrap import dedent -from typing import List from typing import TYPE_CHECKING -from _pytest import __version__ as version +from pytest import __version__ as full_version + if TYPE_CHECKING: import sphinx.application +PROJECT_ROOT_DIR = Path(__file__).parents[2].resolve() -release = ".".join(version.split(".")[:2]) - -# If extensions (or modules to document with autodoc) are in another directory, -# add these directories to sys.path here. If the directory is relative to the -# documentation root, use os.path.abspath to make it absolute, like shown here. -# sys.path.insert(0, os.path.abspath('.')) - -autodoc_member_order = "bysource" -autodoc_typehints = "description" -autodoc_typehints_description_target = "documented" -todo_include_todos = 1 - -latex_engine = "lualatex" - -latex_elements = { - "preamble": dedent( - r""" - \directlua{ - luaotfload.add_fallback("fallbacks", { - "Noto Serif CJK SC:style=Regular;", - "Symbola:Style=Regular;" - }) - } - - \setmainfont{FreeSerif}[RawFeature={fallback=fallbacks}] - """ - ) -} +# -- Project information --------------------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information -# -- General configuration ----------------------------------------------------- +project = "pytest" +copyright = "2015, holger krekel and pytest-dev team" +version = full_version.split("+")[0] +release = ".".join(version.split(".")[:2]) -# If your documentation needs a minimal Sphinx version, state it here. -# needs_sphinx = '1.0' +# -- General configuration ------------------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration -# Add any Sphinx extension module names here, as strings. They can be extensions -# coming with Sphinx (named 'sphinx.ext.*') or your custom ones. +root_doc = "index" extensions = [ - "pallets_sphinx_themes", "pygments_pytest", "sphinx.ext.autodoc", "sphinx.ext.autosummary", - "sphinx.ext.extlinks", "sphinx.ext.intersphinx", "sphinx.ext.todo", "sphinx.ext.viewcode", "sphinx_removed_in", "sphinxcontrib_trio", + "sphinxcontrib.towncrier.ext", # provides `towncrier-draft-entries` directive + "sphinx_issues", # implements `:issue:`, `:pr:` and other GH-related roles ] # Building PDF docs on readthedocs requires inkscape for svg to pdf @@ -85,35 +46,6 @@ if shutil.which("inkscape"): extensions.append("sphinxcontrib.inkscapeconverter") -# Add any paths that contain templates here, relative to this directory. -templates_path = ["_templates"] - -# The suffix of source filenames. -source_suffix = ".rst" - -# The encoding of source files. -# source_encoding = 'utf-8-sig' - -# The master toctree document. -master_doc = "contents" - -# General information about the project. -project = "pytest" -copyright = "2015, holger krekel and pytest-dev team" - - -# The language for content autogenerated by Sphinx. Refer to documentation -# for a list of supported languages. -# language = None - -# There are two options for replacing |today|: either, you set today to some -# non-false value, then it is used: -# today = '' -# Else, today_fmt is used as the format for a strftime call. -# today_fmt = '%B %d, %Y' - -# List of patterns, relative to source directory, that match files and -# directories to ignore when looking for source files. exclude_patterns = [ "_build", "naming20.rst", @@ -125,165 +57,144 @@ "setup.rst", "example/remoteinterp.rst", ] - - -# The reST default role (used for this markup: `text`) to use for all documents. +templates_path = ["_templates"] default_role = "literal" -# If true, '()' will be appended to :func: etc. cross-reference text. -# add_function_parentheses = True +nitpicky = True +nitpick_ignore = [ + # TODO (fix in pluggy?) + ("py:class", "HookCaller"), + ("py:class", "HookspecMarker"), + ("py:exc", "PluginValidationError"), + # Might want to expose/TODO (https://github.com/pytest-dev/pytest/issues/7469) + ("py:class", "ExceptionRepr"), + ("py:class", "Exit"), + ("py:class", "SubRequest"), + ("py:class", "SubRequest"), + ("py:class", "TerminalReporter"), + ("py:class", "_pytest._code.code.TerminalRepr"), + ("py:class", "TerminalRepr"), + ("py:class", "_pytest.fixtures.FixtureFunctionMarker"), + ("py:class", "_pytest.fixtures.FixtureFunctionDefinition"), + ("py:class", "_pytest.logging.LogCaptureHandler"), + ("py:class", "_pytest.mark.structures.ParameterSet"), + # Intentionally undocumented/private + ("py:class", "_pytest._code.code.Traceback"), + ("py:class", "_pytest._py.path.LocalPath"), + ("py:class", "_pytest.capture.CaptureResult"), + ("py:class", "_pytest.compat.NotSetType"), + ("py:class", "_pytest.python.PyCollector"), + ("py:class", "_pytest.python.PyobjMixin"), + ("py:class", "_pytest.python_api.RaisesContext"), + ("py:class", "_pytest.recwarn.WarningsChecker"), + ("py:class", "_pytest.reports.BaseReport"), + # Undocumented third parties + ("py:class", "_tracing.TagTracerSub"), + ("py:class", "warnings.WarningMessage"), + # Undocumented type aliases + ("py:class", "LEGACY_PATH"), + ("py:class", "_PluggyPlugin"), + # TypeVars + ("py:class", "_pytest._code.code.E"), + ("py:class", "E"), # due to delayed annotation + ("py:class", "_pytest.fixtures.FixtureFunction"), + ("py:class", "_pytest.nodes._NodeType"), + ("py:class", "_NodeType"), # due to delayed annotation + ("py:class", "_pytest.python_api.E"), + ("py:class", "_pytest.recwarn.T"), + ("py:class", "_pytest.runner.TResult"), + ("py:obj", "_pytest.fixtures.FixtureValue"), + ("py:obj", "_pytest.stash.T"), + ("py:class", "_ScopeName"), + ("py:class", "BaseExcT_1"), + ("py:class", "ExcT_1"), +] -# If true, the current module name will be prepended to all description -# unit titles (such as .. function::). add_module_names = False -# If true, sectionauthor and moduleauthor directives will be shown in the -# output. They are ignored by default. -# show_authors = False +# -- Options for Autodoc -------------------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/extensions/autodoc.html#configuration -# The name of the Pygments (syntax highlighting) style to use. -pygments_style = "sphinx" +autodoc_member_order = "bysource" +autodoc_typehints = "description" +autodoc_typehints_description_target = "documented" +# -- Options for intersphinx ---------------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/extensions/intersphinx.html#configuration -# A list of ignored prefixes for module index sorting. -# modindex_common_prefix = [] +intersphinx_mapping = { + "pluggy": ("https://pluggy.readthedocs.io/en/stable", None), + "python": ("https://docs.python.org/3", None), + "numpy": ("https://numpy.org/doc/stable", None), + "pip": ("https://pip.pypa.io/en/stable", None), + "tox": ("https://tox.wiki/en/stable", None), + "virtualenv": ("https://virtualenv.pypa.io/en/stable", None), + "setuptools": ("https://setuptools.pypa.io/en/stable", None), + "packaging": ("https://packaging.python.org/en/latest", None), +} + +# -- Options for todo ----------------------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/extensions/todo.html#configuration + +todo_include_todos = True + +# -- Options for linkcheck builder ---------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-the-linkcheck-builder -# A list of regular expressions that match URIs that should not be checked when -# doing a linkcheck. linkcheck_ignore = [ "https://blogs.msdn.microsoft.com/bharry/2017/06/28/testing-in-a-cloud-delivery-cadence/", "http://pythontesting.net/framework/pytest-introduction/", r"https://github.com/pytest-dev/pytest/issues/\d+", r"https://github.com/pytest-dev/pytest/pull/\d+", ] - -# The number of worker threads to use when checking links (default=5). linkcheck_workers = 5 +# -- Options for HTML output ---------------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output -_repo = "https://github.com/pytest-dev/pytest" -extlinks = { - "bpo": ("https://bugs.python.org/issue%s", "bpo-%s"), - "pypi": ("https://pypi.org/project/%s/", "%s"), - "issue": (f"{_repo}/issues/%s", "issue #%s"), - "pull": (f"{_repo}/pull/%s", "pull request #%s"), - "user": ("https://github.com/%s", "@%s"), -} - - -# -- Options for HTML output --------------------------------------------------- +html_theme = "furo" +html_theme_options = {"sidebar_hide_name": True} -sys.path.append(os.path.abspath("_themes")) -html_theme_path = ["_themes"] - -# The theme to use for HTML and HTML Help pages. See the documentation for -# a list of builtin themes. -html_theme = "flask" - -# Theme options are theme-specific and customize the look and feel of a theme -# further. For a list of options available for each theme, see the -# documentation. -# html_theme_options = {"index_logo": None} - -# Add any paths that contain custom themes here, relative to this directory. -# html_theme_path = [] +html_static_path = ["_static"] +html_css_files = [ + "pytest-custom.css", +] -# The name for this set of Sphinx documents. If None, it defaults to -# " v documentation". html_title = "pytest documentation" +html_short_title = f"pytest-{release}" -# A shorter title for the navigation bar. Default is the same as html_title. -html_short_title = "pytest-%s" % release - -# The name of an image file (relative to this directory) to place at the top -# of the sidebar. -html_logo = "img/pytest_logo_curves.svg" - -# The name of an image file (within the static path) to use as favicon of the -# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 -# pixels large. +html_logo = "_static/pytest1.png" html_favicon = "img/favicon.png" -# Add any paths that contain custom static files (such as style sheets) here, -# relative to this directory. They are copied after the builtin static files, -# so a file named "default.css" will overwrite the builtin "default.css". -# html_static_path = ['_static'] - -# If not '', a 'Last updated on:' timestamp is inserted at every page bottom, -# using the given strftime format. -# html_last_updated_fmt = '%b %d, %Y' - -# If true, SmartyPants will be used to convert quotes and dashes to -# typographically correct entities. -# html_use_smartypants = True - -# Custom sidebar templates, maps document names to template names. -# html_sidebars = {} -# html_sidebars = {'index': 'indexsidebar.html'} - -html_sidebars = { - "index": [ - "slim_searchbox.html", - "sidebarintro.html", - "globaltoc.html", - "links.html", - "sourcelink.html", - ], - "**": [ - "slim_searchbox.html", - "globaltoc.html", - "relations.html", - "links.html", - "sourcelink.html", - ], -} - -# Additional templates that should be rendered to pages, maps page names to -# template names. -# html_additional_pages = {} -# html_additional_pages = {'index': 'index.html'} - - -# If false, no module index is generated. -html_domain_indices = True - -# If false, no index is generated. html_use_index = False - -# If true, the index is split into individual pages for each letter. -# html_split_index = False - -# If true, links to the reST sources are added to the pages. html_show_sourcelink = False -# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. -# html_show_sphinx = True +html_baseurl = "https://docs.pytest.org/en/stable/" -# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. -# html_show_copyright = True +# -- Options for HTML Help output ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-help-output -# If true, an OpenSearch description file will be output, and all pages will -# contain a tag referring to it. The value of this option must be the -# base URL from which the finished HTML is served. -# html_use_opensearch = '' +htmlhelp_basename = "pytestdoc" -# This is the file name suffix for HTML files (e.g. ".xhtml"). -# html_file_suffix = None -# Output file base name for HTML help builder. -htmlhelp_basename = "pytestdoc" +# -- Options for manual page output --------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-manual-page-output +man_pages = [ + ("how-to/usage", "pytest", "pytest usage", ["holger krekel at merlinux eu"], 1) +] -# -- Options for LaTeX output -------------------------------------------------- +# -- Options for epub output ---------------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-epub-output -# The paper size ('letter' or 'a4'). -# latex_paper_size = 'letter' +epub_title = "pytest" +epub_author = "holger krekel at merlinux eu" +epub_publisher = "holger krekel at merlinux eu" +epub_copyright = "2013, holger krekel et alii" -# The font size ('10pt', '11pt' or '12pt'). -# latex_font_size = '10pt' +# -- Options for LaTeX output -------------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-latex-output -# Grouping the document tree into LaTeX files. List of tuples -# (source start file, target name, title, author, documentclass [howto/manual]). latex_documents = [ ( "contents", @@ -293,84 +204,29 @@ "manual", ) ] - -# The name of an image file (relative to this directory) to place at the top of -# the title page. -latex_logo = "img/pytest1.png" - -# For "manual" documents, if this is true, then toplevel headings are parts, -# not chapters. -# latex_use_parts = False - -# If true, show page references after internal links. -# latex_show_pagerefs = False - -# If true, show URL addresses after external links. -# latex_show_urls = False - -# Additional stuff for the LaTeX preamble. -# latex_preamble = '' - -# Documents to append as an appendix to all manuals. -# latex_appendices = [] - -# If false, no module index is generated. latex_domain_indices = False +latex_engine = "lualatex" +latex_elements = { + "preamble": dedent( + r""" + \directlua{ + luaotfload.add_fallback("fallbacks", { + "Noto Serif CJK SC:style=Regular;", + "Symbola:Style=Regular;" + }) + } -# -- Options for manual page output -------------------------------------------- - -# One entry per manual page. List of tuples -# (source start file, name, description, authors, manual section). -man_pages = [ - ("how-to/usage", "pytest", "pytest usage", ["holger krekel at merlinux eu"], 1) -] - - -# -- Options for Epub output --------------------------------------------------- - -# Bibliographic Dublin Core info. -epub_title = "pytest" -epub_author = "holger krekel at merlinux eu" -epub_publisher = "holger krekel at merlinux eu" -epub_copyright = "2013, holger krekel et alii" - -# The language of the text. It defaults to the language option -# or en if the language is not set. -# epub_language = '' - -# The scheme of the identifier. Typical schemes are ISBN or URL. -# epub_scheme = '' - -# The unique identifier of the text. This can be an ISBN number -# or the project homepage. -# epub_identifier = '' - -# A unique identification for the text. -# epub_uid = '' - -# HTML files that should be inserted before the pages created by sphinx. -# The format is a list of tuples containing the path and title. -# epub_pre_files = [] - -# HTML files shat should be inserted after the pages created by sphinx. -# The format is a list of tuples containing the path and title. -# epub_post_files = [] - -# A list of files that should not be packed into the epub file. -# epub_exclude_files = [] - -# The depth of the table of contents in toc.ncx. -# epub_tocdepth = 3 - -# Allow duplicate toc entries. -# epub_tocdup = True - + \setmainfont{FreeSerif}[RawFeature={fallback=fallbacks}] + """ + ) +} -# -- Options for texinfo output ------------------------------------------------ +# -- Options for texinfo output ------------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-texinfo-output texinfo_documents = [ ( - master_doc, + root_doc, "pytest", "pytest Documentation", ( @@ -384,43 +240,37 @@ ) ] +# -- Options for towncrier_draft extension -------------------------------------------- +# https://sphinxcontrib-towncrier.readthedocs.io/en/latest/#how-to-use-this -intersphinx_mapping = { - "pluggy": ("https://pluggy.readthedocs.io/en/stable", None), - "python": ("https://docs.python.org/3", None), - "numpy": ("https://numpy.org/doc/stable", None), - "pip": ("https://pip.pypa.io/en/stable", None), - "tox": ("https://tox.wiki/en/stable", None), - "virtualenv": ("https://virtualenv.pypa.io/en/stable", None), - "setuptools": ("https://setuptools.pypa.io/en/stable", None), - "packaging": ("https://packaging.python.org/en/latest", None), -} +towncrier_draft_autoversion_mode = "draft" # or: 'sphinx-version', 'sphinx-release' +towncrier_draft_include_empty = True +towncrier_draft_working_directory = PROJECT_ROOT_DIR +towncrier_draft_config_path = "pyproject.toml" # relative to cwd +# -- Options for sphinx_issues extension ----------------------------------- +# https://github.com/sloria/sphinx-issues#installation-and-configuration -def configure_logging(app: "sphinx.application.Sphinx") -> None: - """Configure Sphinx's WarningHandler to handle (expected) missing include.""" - import sphinx.util.logging - import logging +issues_github_path = "pytest-dev/pytest" - class WarnLogFilter(logging.Filter): - def filter(self, record: logging.LogRecord) -> bool: - """Ignore warnings about missing include with "only" directive. +# -- Custom Read the Docs build configuration ----------------------------------------- +# https://docs.readthedocs.io/en/stable/reference/environment-variables.html#environment-variable-reference +# https://www.sphinx-doc.org/en/master/usage/restructuredtext/directives.html#including-content-based-on-tags - Ref: https://github.com/sphinx-doc/sphinx/issues/2150.""" - if ( - record.msg.startswith('Problems with "include" directive path:') - and "_changelog_towncrier_draft.rst" in record.msg - ): - return False - return True +IS_RELEASE_ON_RTD = ( + os.getenv("READTHEDOCS", "False") == "True" + and os.environ["READTHEDOCS_VERSION_TYPE"] == "tag" +) +if IS_RELEASE_ON_RTD: + tags: set[str] + # pylint: disable-next=used-before-assignment + tags.add("is_release") # noqa: F821 - logger = logging.getLogger(sphinx.util.logging.NAMESPACE) - warn_handler = [x for x in logger.handlers if x.level == logging.WARNING] - assert len(warn_handler) == 1, warn_handler - warn_handler[0].filters.insert(0, WarnLogFilter()) +# -- Custom documentation plugin ------------------------------------------------------ +# https://www.sphinx-doc.org/en/master/development/tutorials/extending_syntax.html#the-setup-function -def setup(app: "sphinx.application.Sphinx") -> None: +def setup(app: sphinx.application.Sphinx) -> None: app.add_crossref_type( "fixture", "fixture", @@ -449,27 +299,6 @@ def setup(app: "sphinx.application.Sphinx") -> None: indextemplate="pair: %s; hook", ) - configure_logging(app) - - # Make Sphinx mark classes with "final" when decorated with @final. - # We need this because we import final from pytest._compat, not from - # typing (for Python < 3.8 compat), so Sphinx doesn't detect it. - # To keep things simple we accept any `@final` decorator. - # Ref: https://github.com/pytest-dev/pytest/pull/7780 - import sphinx.pycode.ast - import sphinx.pycode.parser - - original_is_final = sphinx.pycode.parser.VariableCommentPicker.is_final - - def patched_is_final(self, decorators: List[ast.expr]) -> bool: - if original_is_final(self, decorators): - return True - return any( - sphinx.pycode.ast.unparse(decorator) == "final" for decorator in decorators - ) - - sphinx.pycode.parser.VariableCommentPicker.is_final = patched_is_final - # legacypath.py monkey-patches pytest.Testdir in. Import the file so # that autodoc can discover references to it. import _pytest.legacypath # noqa: F401 diff --git a/doc/en/conftest.py b/doc/en/conftest.py index 1a62e1b5df5..50e43a0b544 100644 --- a/doc/en/conftest.py +++ b/doc/en/conftest.py @@ -1 +1,4 @@ +from __future__ import annotations + + collect_ignore = ["conf.py"] diff --git a/doc/en/contact.rst b/doc/en/contact.rst index beed10d7f27..b2a1368eaba 100644 --- a/doc/en/contact.rst +++ b/doc/en/contact.rst @@ -3,52 +3,57 @@ .. _`contact`: Contact channels -=================================== +================ -- `pytest issue tracker`_ to report bugs or suggest features (for version - 2.0 and above). -- `pytest discussions`_ at github for general questions. -- `pytest discord server `_ - for pytest development visibility and general assistance. +Web +--- + +- `pytest issue tracker`_ to report bugs or suggest features. +- `pytest discussions`_ at GitHub for general questions. - `pytest on stackoverflow.com `_ - to post precise questions with the tag ``pytest``. New Questions will usually + to post precise questions with the tag ``pytest``. New questions will usually be seen by pytest users or developers and answered quickly. -- `Testing In Python`_: a mailing list for Python testing tools and discussion. - -- `pytest-dev at python.org (mailing list)`_ pytest specific announcements and discussions. - -- :doc:`contribution guide ` for help on submitting pull - requests to GitHub. +Chat +---- +- `pytest discord server `_ + for pytest development visibility and general assistance. - ``#pytest`` `on irc.libera.chat `_ IRC - channel for random questions (using an IRC client, `via webchat - `_, or `via Matrix - `_). + channel for random questions (using an IRC client, or `via webchat + `_) +- ``#pytest`` `on Matrix `_. -- private mail to Holger.Krekel at gmail com if you want to communicate sensitive issues +Microblogging +------------- +- Bluesky: `@pytest.org `_ +- Mastodon: `@pytest@fosstodon.org `_ +- Twitter/X: `@pytestdotorg `_ -- `merlinux.eu`_ offers pytest and tox-related professional teaching and - consulting. +Mail +---- -.. _`pytest issue tracker`: https://github.com/pytest-dev/pytest/issues -.. _`old issue tracker`: https://bitbucket.org/hpk42/py-trunk/issues/ - -.. _`pytest discussions`: https://github.com/pytest-dev/pytest/discussions - -.. _`merlinux.eu`: https://merlinux.eu/ - -.. _`get an account`: +- `Testing In Python`_: a mailing list for Python testing tools and discussion. +- Mail to `core@pytest.org `_ for topics that cannot be + discussed in public. Mails sent there will be distributed among the members + in the pytest core team, who can also be contacted individually: -.. _tetamap: https://tetamap.wordpress.com/ + * Bruno Oliveira (:user:`nicoddemus`, `bruno@pytest.org `_) + * Florian Bruhin (:user:`The-Compiler`, `florian@pytest.org `_) + * Pierre Sassoulas (:user:`Pierre-Sassoulas`, `pierre@pytest.org `_) + * Ran Benita (:user:`bluetech`, `ran@pytest.org `_) + * Ronny Pfannschmidt (:user:`RonnyPfannschmidt`, `ronny@pytest.org `_) + * Zac Hatfield-Dodds (:user:`Zac-HD`, `zac@pytest.org `_) -.. _`@pylibcommit`: https://twitter.com/pylibcommit +Other +----- +- The :doc:`contribution guide ` for help on submitting pull + requests to GitHub. +- Florian Bruhin (:user:`The-Compiler`) offers pytest professional teaching and + consulting via `Bruhin Software `_. +.. _`pytest issue tracker`: https://github.com/pytest-dev/pytest/issues +.. _`pytest discussions`: https://github.com/pytest-dev/pytest/discussions .. _`Testing in Python`: http://lists.idyll.org/listinfo/testing-in-python -.. _FOAF: https://en.wikipedia.org/wiki/FOAF -.. _`py-dev`: -.. _`development mailing list`: -.. _`pytest-dev at python.org (mailing list)`: http://mail.python.org/mailman/listinfo/pytest-dev -.. _`pytest-commit at python.org (mailing list)`: http://mail.python.org/mailman/listinfo/pytest-commit diff --git a/doc/en/contents.rst b/doc/en/contents.rst index ae42884f658..07c0b3ff6b9 100644 --- a/doc/en/contents.rst +++ b/doc/en/contents.rst @@ -1,3 +1,5 @@ +:orphan: + .. _toc: Full pytest documentation @@ -44,7 +46,6 @@ How-to guides how-to/existingtestsuite how-to/unittest - how-to/nose how-to/xunit_setup how-to/bash-completion diff --git a/doc/en/deprecations.rst b/doc/en/deprecations.rst index 4f7830a2791..6897fb28fc1 100644 --- a/doc/en/deprecations.rst +++ b/doc/en/deprecations.rst @@ -7,10 +7,6 @@ This page lists all pytest features that are currently deprecated or have been r The objective is to give users a clear rationale why a certain feature has been removed, and what alternatives should be used instead. -.. contents:: - :depth: 3 - :local: - Deprecated Features ------------------- @@ -19,129 +15,109 @@ Below is a complete list of all pytest features which are considered deprecated. :class:`~pytest.PytestWarning` or subclasses, which can be filtered using :ref:`standard warning filters `. -.. _nose-deprecation: - -Support for tests written for nose -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -.. deprecated:: 7.2 - -Support for running tests written for `nose `__ is now deprecated. - -``nose`` has been in maintenance mode-only for years, and maintaining the plugin is not trivial as it spills -over the code base (see :issue:`9886` for more details). - -setup/teardown -^^^^^^^^^^^^^^ - -One thing that might catch users by surprise is that plain ``setup`` and ``teardown`` methods are not pytest native, -they are in fact part of the ``nose`` support. - - -.. code-block:: python - - class Test: - def setup(self): - self.resource = make_resource() +.. _sync-test-async-fixture: - def teardown(self): - self.resource.close() - - def test_foo(self): - ... - - def test_bar(self): - ... +sync test depending on async fixture +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +.. deprecated:: 8.4 +Pytest has for a long time given an error when encountering an asynchronous test function, prompting the user to install +a plugin that can handle it. It has not given any errors if you have an asynchronous fixture that's depended on by a +synchronous test. If the fixture was an async function you did get an "unawaited coroutine" warning, but for async yield fixtures you didn't even get that. +This is a problem even if you do have a plugin installed for handling async tests, as they may require +special decorators for async fixtures to be handled, and some may not robustly handle if a user accidentally requests an +async fixture from their sync tests. Fixture values being cached can make this even more unintuitive, where everything will +"work" if the fixture is first requested by an async test, and then requested by a synchronous test. -Native pytest support uses ``setup_method`` and ``teardown_method`` (see :ref:`xunit-method-setup`), so the above should be changed to: +Unfortunately there is no 100% reliable method of identifying when a user has made a mistake, versus when they expect an +unawaited object from their fixture that they will handle on their own. To suppress this warning +when you in fact did intend to handle this you can wrap your async fixture in a synchronous fixture: .. code-block:: python - class Test: - def setup_method(self): - self.resource = make_resource() - - def teardown_method(self): - self.resource.close() + import asyncio + import pytest - def test_foo(self): - ... - def test_bar(self): - ... + @pytest.fixture + async def unawaited_fixture(): + return 1 -This is easy to do in an entire code base by doing a simple find/replace. + def test_foo(unawaited_fixture): + assert 1 == asyncio.run(unawaited_fixture) -@with_setup -^^^^^^^^^^^ +should be changed to -Code using `@with_setup `_ such as this: .. code-block:: python - from nose.tools import with_setup - - - def setup_some_resource(): - ... - - - def teardown_some_resource(): - ... + import asyncio + import pytest - @with_setup(setup_some_resource, teardown_some_resource) - def test_foo(): - ... + @pytest.fixture + def unawaited_fixture(): + async def inner_fixture(): + return 1 -Will also need to be ported to a supported pytest style. One way to do it is using a fixture: + return inner_fixture() -.. code-block:: python - import pytest + def test_foo(unawaited_fixture): + assert 1 == asyncio.run(unawaited_fixture) - def setup_some_resource(): - ... +You can also make use of `pytest_fixture_setup` to handle the coroutine/asyncgen before pytest sees it - this is the way current async pytest plugins handle it. +If a user has an async fixture with ``autouse=True`` in their ``conftest.py``, or in a file +containing both synchronous tests and the fixture, they will receive this warning. +Unless you're using a plugin that specifically handles async fixtures +with synchronous tests, we strongly recommend against this practice. +It can lead to unpredictable behavior (with larger scopes, it may appear to "work" if an async +test is the first to request the fixture, due to value caching) and will generate +unawaited-coroutine runtime warnings (but only for non-yield fixtures). +Additionally, it creates ambiguity for other developers about whether the fixture is intended to perform +setup for synchronous tests. - def teardown_some_resource(): - ... +The `anyio pytest plugin `_ supports +synchronous tests with async fixtures, though certain limitations apply. - @pytest.fixture - def some_resource(): - setup_some_resource() - yield - teardown_some_resource() +.. _import-or-skip-import-error: +``pytest.importorskip`` default behavior regarding :class:`ImportError` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - def test_foo(some_resource): - ... +.. deprecated:: 8.2 +Traditionally :func:`pytest.importorskip` will capture :class:`ImportError`, with the original intent being to skip +tests where a dependent module is not installed, for example testing with different dependencies. -.. _`with-setup-nose`: https://nose.readthedocs.io/en/latest/testing_tools.html?highlight=with_setup#nose.tools.with_setup +However some packages might be installed in the system, but are not importable due to +some other issue, for example, a compilation error or a broken installation. In those cases :func:`pytest.importorskip` +would still silently skip the test, but more often than not users would like to see the unexpected +error so the underlying issue can be fixed. -.. _instance-collector-deprecation: +In ``8.2`` the ``exc_type`` parameter has been added, giving users the ability of passing :class:`ModuleNotFoundError` +to skip tests only if the module cannot really be found, and not because of some other error. -The ``pytest.Instance`` collector -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Catching only :class:`ModuleNotFoundError` by default (and letting other errors propagate) would be the best solution, +however for backward compatibility, pytest will keep the existing behavior but raise an warning if: -.. versionremoved:: 7.0 +1. The captured exception is of type :class:`ImportError`, and: +2. The user does not pass ``exc_type`` explicitly. -The ``pytest.Instance`` collector type has been removed. +If the import attempt raises :class:`ModuleNotFoundError` (the usual case), then the module is skipped and no +warning is emitted. -Previously, Python test methods were collected as :class:`~pytest.Class` -> ``Instance`` -> :class:`~pytest.Function`. -Now :class:`~pytest.Class` collects the test methods directly. +This way, the usual cases will keep working the same way, while unexpected errors will now issue a warning, with +users being able to suppress the warning by passing ``exc_type=ImportError`` explicitly. -Most plugins which reference ``Instance`` do so in order to ignore or skip it, -using a check such as ``if isinstance(node, Instance): return``. -Such plugins should simply remove consideration of ``Instance`` on pytest>=7. -However, to keep such uses working, a dummy type has been instanted in ``pytest.Instance`` and ``_pytest.python.Instance``, -and importing it emits a deprecation warning. This will be removed in pytest 8. +In ``9.0``, the warning will turn into an error, and in ``9.1`` :func:`pytest.importorskip` will only capture +:class:`ModuleNotFoundError` by default and no warnings will be issued anymore -- but users can still capture +:class:`ImportError` by passing it to ``exc_type``. .. _node-ctor-fspath-deprecation: @@ -177,13 +153,12 @@ arguments they only pass on to the superclass. resolved in future versions as we slowly get rid of the :pypi:`py` dependency (see :issue:`9283` for a longer discussion). -Due to the ongoing migration of methods like :meth:`~_pytest.Item.reportinfo` +Due to the ongoing migration of methods like :meth:`~pytest.Item.reportinfo` which still is expected to return a ``py.path.local`` object, nodes still have both ``fspath`` (``py.path.local``) and ``path`` (``pathlib.Path``) attributes, no matter what argument was used in the constructor. We expect to deprecate the ``fspath`` attribute in a future release. -.. _legacy-path-hooks-deprecated: Configuring hook specs/impls using markers ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -197,13 +172,11 @@ have been available since years and should be used instead. .. code-block:: python @pytest.mark.tryfirst - def pytest_runtest_call(): - ... + def pytest_runtest_call(): ... # or - def pytest_runtest_call(): - ... + def pytest_runtest_call(): ... pytest_runtest_call.tryfirst = True @@ -213,8 +186,7 @@ should be changed to: .. code-block:: python @pytest.hookimpl(tryfirst=True) - def pytest_runtest_call(): - ... + def pytest_runtest_call(): ... Changed ``hookimpl`` attributes: @@ -229,6 +201,8 @@ Changed ``hookwrapper`` attributes: * ``historic`` +.. _legacy-path-hooks-deprecated: + ``py.path.local`` arguments for hooks replaced with ``pathlib.Path`` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -273,62 +247,6 @@ Directly constructing the following classes is now deprecated: These constructors have always been considered private, but now issue a deprecation warning, which may become a hard error in pytest 8. -.. _cmdline-preparse-deprecated: - -Passing ``msg=`` to ``pytest.skip``, ``pytest.fail`` or ``pytest.exit`` -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -.. deprecated:: 7.0 - -Passing the keyword argument ``msg`` to :func:`pytest.skip`, :func:`pytest.fail` or :func:`pytest.exit` -is now deprecated and ``reason`` should be used instead. This change is to bring consistency between these -functions and the ``@pytest.mark.skip`` and ``@pytest.mark.xfail`` markers which already accept a ``reason`` argument. - -.. code-block:: python - - def test_fail_example(): - # old - pytest.fail(msg="foo") - # new - pytest.fail(reason="bar") - - - def test_skip_example(): - # old - pytest.skip(msg="foo") - # new - pytest.skip(reason="bar") - - - def test_exit_example(): - # old - pytest.exit(msg="foo") - # new - pytest.exit(reason="bar") - - -Implementing the ``pytest_cmdline_preparse`` hook -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -.. deprecated:: 7.0 - -Implementing the :hook:`pytest_cmdline_preparse` hook has been officially deprecated. -Implement the :hook:`pytest_load_initial_conftests` hook instead. - -.. code-block:: python - - def pytest_cmdline_preparse(config: Config, args: List[str]) -> None: - ... - - - # becomes: - - - def pytest_load_initial_conftests( - early_config: Config, parser: Parser, args: List[str] - ) -> None: - ... - .. _diamond-inheritance-deprecated: Diamond inheritance between :class:`pytest.Collector` and :class:`pytest.Item` @@ -336,7 +254,7 @@ Diamond inheritance between :class:`pytest.Collector` and :class:`pytest.Item` .. deprecated:: 7.0 -Defining a custom pytest node type which is both an :class:`pytest.Item ` and a :class:`pytest.Collector ` (e.g. :class:`pytest.File `) now issues a warning. +Defining a custom pytest node type which is both an :class:`~pytest.Item` and a :class:`~pytest.Collector` (e.g. :class:`~pytest.File`) now issues a warning. It was never sanely supported and triggers hard to debug errors. Some plugins providing linting/code analysis have been using this as a hack. @@ -348,8 +266,8 @@ Instead, a separate collector node should be used, which collects the item. See .. _uncooperative-constructors-deprecated: -Constructors of custom :class:`pytest.Node` subclasses should take ``**kwargs`` -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Constructors of custom :class:`~_pytest.nodes.Node` subclasses should take ``**kwargs`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. deprecated:: 7.0 @@ -380,74 +298,267 @@ conflicts (such as :class:`pytest.File` now taking ``path`` instead of ``fspath``, as :ref:`outlined above `), a deprecation warning is now raised. -Backward compatibilities in ``Parser.addoption`` -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Applying a mark to a fixture function +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -.. deprecated:: 2.4 +.. deprecated:: 7.4 -Several behaviors of :meth:`Parser.addoption ` are now -scheduled for removal in pytest 8 (deprecated since pytest 2.4.0): +Applying a mark to a fixture function never had any effect, but it is a common user error. -- ``parser.addoption(..., help=".. %default ..")`` - use ``%(default)s`` instead. -- ``parser.addoption(..., type="int/string/float/complex")`` - use ``type=int`` etc. instead. +.. code-block:: python + @pytest.mark.usefixtures("clean_database") + @pytest.fixture + def user() -> User: ... -Using ``pytest.warns(None)`` -~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Users expected in this case that the ``usefixtures`` mark would have its intended effect of using the ``clean_database`` fixture when ``user`` was invoked, when in fact it has no effect at all. -.. deprecated:: 7.0 +Now pytest will issue a warning when it encounters this problem, and will raise an error in the future versions. -:func:`pytest.warns(None) ` is now deprecated because it was frequently misused. -Its correct usage was checking that the code emits at least one warning of any type - like ``pytest.warns()`` -or ``pytest.warns(Warning)``. -See :ref:`warns use cases` for examples. +The ``yield_fixture`` function/decorator +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +.. deprecated:: 6.2 + +``pytest.yield_fixture`` is a deprecated alias for :func:`pytest.fixture`. + +It has been so for a very long time, so can be search/replaced safely. -Returning non-None value in test functions -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Removed Features and Breaking Changes +------------------------------------- + +As stated in our :ref:`backwards-compatibility` policy, deprecated features are removed only in major releases after +an appropriate period of deprecation has passed. + +Some breaking changes which could not be deprecated are also listed. + +.. _yield tests deprecated: + +``yield`` tests +~~~~~~~~~~~~~~~ + +.. versionremoved:: 4.0 + + ``yield`` tests ``xfail``. + +.. versionremoved:: 8.4 + + ``yield`` tests raise a collection error. + +pytest no longer supports ``yield``-style tests, where a test function actually ``yield`` functions and values +that are then turned into proper test methods. Example: + +.. code-block:: python + + def check(x, y): + assert x**x == y + + + def test_squared(): + yield check, 2, 4 + yield check, 3, 9 + +This would result in two actual test functions being generated. + +This form of test function doesn't support fixtures properly, and users should switch to ``pytest.mark.parametrize``: + +.. code-block:: python + + @pytest.mark.parametrize("x, y", [(2, 4), (3, 9)]) + def test_squared(x, y): + assert x**x == y + +.. _nose-deprecation: + +Support for tests written for nose +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. deprecated:: 7.2 +.. versionremoved:: 8.0 + +Support for running tests written for `nose `__ is now deprecated. + +``nose`` has been in maintenance mode-only for years, and maintaining the plugin is not trivial as it spills +over the code base (see :issue:`9886` for more details). + +setup/teardown +^^^^^^^^^^^^^^ -A :class:`pytest.PytestReturnNotNoneWarning` is now emitted if a test function returns something other than `None`. +One thing that might catch users by surprise is that plain ``setup`` and ``teardown`` methods are not pytest native, +they are in fact part of the ``nose`` support. -This prevents a common mistake among beginners that expect that returning a `bool` would cause a test to pass or fail, for example: .. code-block:: python - @pytest.mark.parametrize( - ["a", "b", "result"], - [ - [1, 2, 5], - [2, 3, 8], - [5, 3, 18], - ], - ) - def test_foo(a, b, result): - return foo(a, b) == result + class Test: + def setup(self): + self.resource = make_resource() + + def teardown(self): + self.resource.close() + + def test_foo(self): ... -Given that pytest ignores the return value, this might be surprising that it will never fail. + def test_bar(self): ... -The proper fix is to change the `return` to an `assert`: + + +Native pytest support uses ``setup_method`` and ``teardown_method`` (see :ref:`xunit-method-setup`), so the above should be changed to: .. code-block:: python - @pytest.mark.parametrize( - ["a", "b", "result"], - [ - [1, 2, 5], - [2, 3, 8], - [5, 3, 18], - ], - ) - def test_foo(a, b, result): - assert foo(a, b) == result + class Test: + def setup_method(self): + self.resource = make_resource() + + def teardown_method(self): + self.resource.close() + + def test_foo(self): ... + + def test_bar(self): ... + + +This is easy to do in an entire code base by doing a simple find/replace. + +@with_setup +^^^^^^^^^^^ + +Code using `@with_setup `_ such as this: + +.. code-block:: python + + from nose.tools import with_setup + + + def setup_some_resource(): ... + + + def teardown_some_resource(): ... + + + @with_setup(setup_some_resource, teardown_some_resource) + def test_foo(): ... + +Will also need to be ported to a supported pytest style. One way to do it is using a fixture: + +.. code-block:: python + + import pytest + + + def setup_some_resource(): ... + + + def teardown_some_resource(): ... + + + @pytest.fixture + def some_resource(): + setup_some_resource() + yield + teardown_some_resource() + + + def test_foo(some_resource): ... + + +.. _`with-setup-nose`: https://nose.readthedocs.io/en/latest/testing_tools.html?highlight=with_setup#nose.tools.with_setup + + +The ``compat_co_firstlineno`` attribute +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Nose inspects this attribute on function objects to allow overriding the function's inferred line number. +Pytest no longer respects this attribute. + + + +Passing ``msg=`` to ``pytest.skip``, ``pytest.fail`` or ``pytest.exit`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. deprecated:: 7.0 +.. versionremoved:: 8.0 + +Passing the keyword argument ``msg`` to :func:`pytest.skip`, :func:`pytest.fail` or :func:`pytest.exit` +is now deprecated and ``reason`` should be used instead. This change is to bring consistency between these +functions and the ``@pytest.mark.skip`` and ``@pytest.mark.xfail`` markers which already accept a ``reason`` argument. + +.. code-block:: python + + def test_fail_example(): + # old + pytest.fail(msg="foo") + # new + pytest.fail(reason="bar") + + + def test_skip_example(): + # old + pytest.skip(msg="foo") + # new + pytest.skip(reason="bar") + + + def test_exit_example(): + # old + pytest.exit(msg="foo") + # new + pytest.exit(reason="bar") + + +.. _instance-collector-deprecation: + +The ``pytest.Instance`` collector +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. versionremoved:: 7.0 + +The ``pytest.Instance`` collector type has been removed. + +Previously, Python test methods were collected as :class:`~pytest.Class` -> ``Instance`` -> :class:`~pytest.Function`. +Now :class:`~pytest.Class` collects the test methods directly. + +Most plugins which reference ``Instance`` do so in order to ignore or skip it, +using a check such as ``if isinstance(node, Instance): return``. +Such plugins should simply remove consideration of ``Instance`` on pytest>=7. +However, to keep such uses working, a dummy type has been instanced in ``pytest.Instance`` and ``_pytest.python.Instance``, +and importing it emits a deprecation warning. This was removed in pytest 8. + + +Using ``pytest.warns(None)`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. deprecated:: 7.0 +.. versionremoved:: 8.0 + +:func:`pytest.warns(None) ` is now deprecated because it was frequently misused. +Its correct usage was checking that the code emits at least one warning of any type - like ``pytest.warns()`` +or ``pytest.warns(Warning)``. + +See :ref:`warns use cases` for examples. + + +Backward compatibilities in ``Parser.addoption`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. deprecated:: 2.4 +.. versionremoved:: 8.0 + +Several behaviors of :meth:`Parser.addoption ` are now +removed in pytest 8 (deprecated since pytest 2.4.0): + +- ``parser.addoption(..., help=".. %default ..")`` - use ``%(default)s`` instead. +- ``parser.addoption(..., type="int/string/float/complex")`` - use ``type=int`` etc. instead. The ``--strict`` command-line option ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. deprecated:: 6.2 +.. versionremoved:: 8.0 The ``--strict`` command-line option has been deprecated in favor of ``--strict-markers``, which better conveys what the option does. @@ -457,21 +568,141 @@ flag for all strictness related options (``--strict-markers`` and ``--strict-con at the moment, more might be introduced in the future). -The ``yield_fixture`` function/decorator -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +.. _cmdline-preparse-deprecated: -.. deprecated:: 6.2 +Implementing the ``pytest_cmdline_preparse`` hook +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -``pytest.yield_fixture`` is a deprecated alias for :func:`pytest.fixture`. +.. deprecated:: 7.0 +.. versionremoved:: 8.0 -It has been so for a very long time, so can be search/replaced safely. +Implementing the ``pytest_cmdline_preparse`` hook has been officially deprecated. +Implement the :hook:`pytest_load_initial_conftests` hook instead. +.. code-block:: python -Removed Features ----------------- + def pytest_cmdline_preparse(config: Config, args: List[str]) -> None: ... -As stated in our :ref:`backwards-compatibility` policy, deprecated features are removed only in major releases after -an appropriate period of deprecation has passed. + + # becomes: + + + def pytest_load_initial_conftests( + early_config: Config, parser: Parser, args: List[str] + ) -> None: ... + + +Collection changes in pytest 8 +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Added a new :class:`pytest.Directory` base collection node, which all collector nodes for filesystem directories are expected to subclass. +This is analogous to the existing :class:`pytest.File` for file nodes. + +Changed :class:`pytest.Package` to be a subclass of :class:`pytest.Directory`. +A ``Package`` represents a filesystem directory which is a Python package, +i.e. contains an ``__init__.py`` file. + +:class:`pytest.Package` now only collects files in its own directory; previously it collected recursively. +Sub-directories are collected as sub-collector nodes, thus creating a collection tree which mirrors the filesystem hierarchy. + +:attr:`session.name ` is now ``""``; previously it was the rootdir directory name. +This matches :attr:`session.nodeid <_pytest.nodes.Node.nodeid>` which has always been `""`. + +Added a new :class:`pytest.Dir` concrete collection node, a subclass of :class:`pytest.Directory`. +This node represents a filesystem directory, which is not a :class:`pytest.Package`, +i.e. does not contain an ``__init__.py`` file. +Similarly to ``Package``, it only collects the files in its own directory, +while collecting sub-directories as sub-collector nodes. + +Files and directories are now collected in alphabetical order jointly, unless changed by a plugin. +Previously, files were collected before directories. + +The collection tree now contains directories/packages up to the :ref:`rootdir `, +for initial arguments that are found within the rootdir. +For files outside the rootdir, only the immediate directory/package is collected -- +note however that collecting from outside the rootdir is discouraged. + +As an example, given the following filesystem tree:: + + myroot/ + pytest.ini + top/ + ├── aaa + │ └── test_aaa.py + ├── test_a.py + ├── test_b + │ ├── __init__.py + │ └── test_b.py + ├── test_c.py + └── zzz + ├── __init__.py + └── test_zzz.py + +the collection tree, as shown by `pytest --collect-only top/` but with the otherwise-hidden :class:`~pytest.Session` node added for clarity, +is now the following:: + + + + + + + + + + + + + + + + + + +Previously, it was:: + + + + + + + + + + + + + + + +Code/plugins which rely on a specific shape of the collection tree might need to update. + + +:class:`pytest.Package` is no longer a :class:`pytest.Module` or :class:`pytest.File` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. versionchanged:: 8.0 + +The ``Package`` collector node designates a Python package, that is, a directory with an `__init__.py` file. +Previously ``Package`` was a subtype of ``pytest.Module`` (which represents a single Python module), +the module being the `__init__.py` file. +This has been deemed a design mistake (see :issue:`11137` and :issue:`7777` for details). + +The ``path`` property of ``Package`` nodes now points to the package directory instead of the ``__init__.py`` file. + +Note that a ``Module`` node for ``__init__.py`` (which is not a ``Package``) may still exist, +if it is picked up during collection (e.g. if you configured :confval:`python_files` to include ``__init__.py`` files). + + +Collecting ``__init__.py`` files no longer collects package +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. versionremoved:: 8.0 + +Running `pytest pkg/__init__.py` now collects the `pkg/__init__.py` file (module) only. +Previously, it collected the entire `pkg` package, including other test files in the directory, but excluding tests in the `__init__.py` file itself +(unless :confval:`python_files` was changed to allow `__init__.py` file). + +To collect the entire package, specify just the directory: `pytest pkg`. The ``pytest.collect`` module @@ -596,7 +827,7 @@ By using ``legacy`` you will keep using the legacy/xunit1 format when upgrading pytest 6.0, where the default format will be ``xunit2``. In order to let users know about the transition, pytest will issue a warning in case -the ``--junitxml`` option is given in the command line but ``junit_family`` is not explicitly +the ``--junit-xml`` option is given in the command line but ``junit_family`` is not explicitly configured in ``pytest.ini``. Services known to support the ``xunit2`` format: @@ -773,8 +1004,7 @@ Applying marks to values of a ``pytest.mark.parametrize`` call is now deprecated (50, 500), ], ) - def test_foo(a, b): - ... + def test_foo(a, b): ... This code applies the ``pytest.mark.xfail(reason="flaky")`` mark to the ``(6, 36)`` value of the above parametrization call. @@ -797,8 +1027,7 @@ To update the code, use ``pytest.param``: (50, 500), ], ) - def test_foo(a, b): - ... + def test_foo(a, b): ... .. _pytest_funcarg__ prefix deprecated: @@ -949,15 +1178,13 @@ This is just a matter of renaming the fixture as the API is the same: .. code-block:: python - def test_foo(record_xml_property): - ... + def test_foo(record_xml_property): ... Change to: .. code-block:: python - def test_foo(record_property): - ... + def test_foo(record_property): ... .. _passing command-line string to pytest.main deprecated: @@ -1039,36 +1266,6 @@ with the ``name`` parameter: return cell() -.. _yield tests deprecated: - -``yield`` tests -~~~~~~~~~~~~~~~ - -.. versionremoved:: 4.0 - -pytest supported ``yield``-style tests, where a test function actually ``yield`` functions and values -that are then turned into proper test methods. Example: - -.. code-block:: python - - def check(x, y): - assert x**x == y - - - def test_squared(): - yield check, 2, 4 - yield check, 3, 9 - -This would result into two actual test functions being generated. - -This form of test function doesn't support fixtures properly, and users should switch to ``pytest.mark.parametrize``: - -.. code-block:: python - - @pytest.mark.parametrize("x, y", [(2, 4), (3, 9)]) - def test_squared(x, y): - assert x**x == y - .. _internal classes accessed through node deprecated: Internal classes accessed through ``Node`` @@ -1119,8 +1316,7 @@ Example of usage: .. code-block:: python - class MySymbol: - ... + class MySymbol: ... def pytest_namespace(): diff --git a/doc/en/example/assertion/failure_demo.py b/doc/en/example/assertion/failure_demo.py index abb9bce5097..16a578fda12 100644 --- a/doc/en/example/assertion/failure_demo.py +++ b/doc/en/example/assertion/failure_demo.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import pytest from pytest import raises @@ -172,7 +174,7 @@ def test_raise(self): raise ValueError("demo error") def test_tupleerror(self): - a, b = [1] # NOQA + a, b = [1] # noqa: F841 def test_reinterpret_fails_with_print_for_the_fun_of_it(self): items = [1, 2, 3] @@ -180,7 +182,7 @@ def test_reinterpret_fails_with_print_for_the_fun_of_it(self): a, b = items.pop() def test_some_error(self): - if namenotexi: # NOQA + if namenotexi: # noqa: F821 pass def func1(self): @@ -265,9 +267,9 @@ class A: a = 1 b = 2 - assert ( - A.a == b - ), "A.a appears not to be b\nor does not appear to be b\none of those" + assert A.a == b, ( + "A.a appears not to be b\nor does not appear to be b\none of those" + ) def test_custom_repr(self): class JSON: diff --git a/doc/en/example/assertion/global_testmodule_config/conftest.py b/doc/en/example/assertion/global_testmodule_config/conftest.py index 7cdf18cdbc1..835726473ba 100644 --- a/doc/en/example/assertion/global_testmodule_config/conftest.py +++ b/doc/en/example/assertion/global_testmodule_config/conftest.py @@ -1,7 +1,10 @@ +from __future__ import annotations + import os.path import pytest + mydir = os.path.dirname(__file__) diff --git a/doc/en/example/assertion/global_testmodule_config/test_hello_world.py b/doc/en/example/assertion/global_testmodule_config/test_hello_world.py index a31a601a1ce..e3c927316f9 100644 --- a/doc/en/example/assertion/global_testmodule_config/test_hello_world.py +++ b/doc/en/example/assertion/global_testmodule_config/test_hello_world.py @@ -1,3 +1,6 @@ +from __future__ import annotations + + hello = "world" diff --git a/doc/en/example/assertion/test_failures.py b/doc/en/example/assertion/test_failures.py index 350518b43c7..17373f62213 100644 --- a/doc/en/example/assertion/test_failures.py +++ b/doc/en/example/assertion/test_failures.py @@ -1,6 +1,9 @@ +from __future__ import annotations + import os.path import shutil + failure_demo = os.path.join(os.path.dirname(__file__), "failure_demo.py") pytest_plugins = ("pytester",) diff --git a/doc/en/example/assertion/test_setup_flow_example.py b/doc/en/example/assertion/test_setup_flow_example.py index 0e7eded06b6..fe11c2bf3f2 100644 --- a/doc/en/example/assertion/test_setup_flow_example.py +++ b/doc/en/example/assertion/test_setup_flow_example.py @@ -1,3 +1,6 @@ +from __future__ import annotations + + def setup_module(module): module.TestStateFullThing.classcount = 0 diff --git a/doc/en/example/conftest.py b/doc/en/example/conftest.py index f905738c4f6..21c9a489961 100644 --- a/doc/en/example/conftest.py +++ b/doc/en/example/conftest.py @@ -1 +1,4 @@ -collect_ignore = ["nonpython"] +from __future__ import annotations + + +collect_ignore = ["nonpython", "customdirectory"] diff --git a/doc/en/example/customdirectory.rst b/doc/en/example/customdirectory.rst new file mode 100644 index 00000000000..1e4d7e370de --- /dev/null +++ b/doc/en/example/customdirectory.rst @@ -0,0 +1,77 @@ +.. _`custom directory collectors`: + +Using a custom directory collector +==================================================== + +By default, pytest collects directories using :class:`pytest.Package`, for directories with ``__init__.py`` files, +and :class:`pytest.Dir` for other directories. +If you want to customize how a directory is collected, you can write your own :class:`pytest.Directory` collector, +and use :hook:`pytest_collect_directory` to hook it up. + +.. _`directory manifest plugin`: + +A basic example for a directory manifest file +-------------------------------------------------------------- + +Suppose you want to customize how collection is done on a per-directory basis. +Here is an example ``conftest.py`` plugin that allows directories to contain a ``manifest.json`` file, +which defines how the collection should be done for the directory. +In this example, only a simple list of files is supported, +however you can imagine adding other keys, such as exclusions and globs. + +.. include:: customdirectory/conftest.py + :literal: + +You can create a ``manifest.json`` file and some test files: + +.. include:: customdirectory/tests/manifest.json + :literal: + +.. include:: customdirectory/tests/test_first.py + :literal: + +.. include:: customdirectory/tests/test_second.py + :literal: + +.. include:: customdirectory/tests/test_third.py + :literal: + +An you can now execute the test specification: + +.. code-block:: pytest + + customdirectory $ pytest + =========================== test session starts ============================ + platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y + rootdir: /home/sweet/project/customdirectory + configfile: pytest.ini + collected 2 items + + tests/test_first.py . [ 50%] + tests/test_second.py . [100%] + + ============================ 2 passed in 0.12s ============================= + +.. regendoc:wipe + +Notice how ``test_three.py`` was not executed, because it is not listed in the manifest. + +You can verify that your custom collector appears in the collection tree: + +.. code-block:: pytest + + customdirectory $ pytest --collect-only + =========================== test session starts ============================ + platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y + rootdir: /home/sweet/project/customdirectory + configfile: pytest.ini + collected 2 items + + + + + + + + + ======================== 2 tests collected in 0.12s ======================== diff --git a/doc/en/example/customdirectory/conftest.py b/doc/en/example/customdirectory/conftest.py new file mode 100644 index 00000000000..ea922e04723 --- /dev/null +++ b/doc/en/example/customdirectory/conftest.py @@ -0,0 +1,30 @@ +# content of conftest.py +from __future__ import annotations + +import json + +import pytest + + +class ManifestDirectory(pytest.Directory): + def collect(self): + # The standard pytest behavior is to loop over all `test_*.py` files and + # call `pytest_collect_file` on each file. This collector instead reads + # the `manifest.json` file and only calls `pytest_collect_file` for the + # files defined there. + manifest_path = self.path / "manifest.json" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + ihook = self.ihook + for file in manifest["files"]: + yield from ihook.pytest_collect_file( + file_path=self.path / file, parent=self + ) + + +@pytest.hookimpl +def pytest_collect_directory(path, parent): + # Use our custom collector for directories containing a `manifest.json` file. + if path.joinpath("manifest.json").is_file(): + return ManifestDirectory.from_parent(parent=parent, path=path) + # Otherwise fallback to the standard behavior. + return None diff --git a/doc/en/example/customdirectory/pytest.ini b/doc/en/example/customdirectory/pytest.ini new file mode 100644 index 00000000000..e69de29bb2d diff --git a/doc/en/example/customdirectory/tests/manifest.json b/doc/en/example/customdirectory/tests/manifest.json new file mode 100644 index 00000000000..6ab6d0a5222 --- /dev/null +++ b/doc/en/example/customdirectory/tests/manifest.json @@ -0,0 +1,6 @@ +{ + "files": [ + "test_first.py", + "test_second.py" + ] +} diff --git a/doc/en/example/customdirectory/tests/test_first.py b/doc/en/example/customdirectory/tests/test_first.py new file mode 100644 index 00000000000..9953dd37785 --- /dev/null +++ b/doc/en/example/customdirectory/tests/test_first.py @@ -0,0 +1,6 @@ +# content of test_first.py +from __future__ import annotations + + +def test_1(): + pass diff --git a/doc/en/example/customdirectory/tests/test_second.py b/doc/en/example/customdirectory/tests/test_second.py new file mode 100644 index 00000000000..df264f48b3b --- /dev/null +++ b/doc/en/example/customdirectory/tests/test_second.py @@ -0,0 +1,6 @@ +# content of test_second.py +from __future__ import annotations + + +def test_2(): + pass diff --git a/doc/en/example/customdirectory/tests/test_third.py b/doc/en/example/customdirectory/tests/test_third.py new file mode 100644 index 00000000000..b8b072dd770 --- /dev/null +++ b/doc/en/example/customdirectory/tests/test_third.py @@ -0,0 +1,6 @@ +# content of test_third.py +from __future__ import annotations + + +def test_3(): + pass diff --git a/doc/en/example/fixtures/test_fixtures_order_autouse.py b/doc/en/example/fixtures/test_fixtures_order_autouse.py index ec282ab4b2b..04cbc268b7f 100644 --- a/doc/en/example/fixtures/test_fixtures_order_autouse.py +++ b/doc/en/example/fixtures/test_fixtures_order_autouse.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import pytest diff --git a/doc/en/example/fixtures/test_fixtures_order_autouse_multiple_scopes.py b/doc/en/example/fixtures/test_fixtures_order_autouse_multiple_scopes.py index de0c2642793..828fa4cf6d6 100644 --- a/doc/en/example/fixtures/test_fixtures_order_autouse_multiple_scopes.py +++ b/doc/en/example/fixtures/test_fixtures_order_autouse_multiple_scopes.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import pytest diff --git a/doc/en/example/fixtures/test_fixtures_order_autouse_temp_effects.py b/doc/en/example/fixtures/test_fixtures_order_autouse_temp_effects.py index ba01ad32f57..ebd5d10f5bb 100644 --- a/doc/en/example/fixtures/test_fixtures_order_autouse_temp_effects.py +++ b/doc/en/example/fixtures/test_fixtures_order_autouse_temp_effects.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import pytest diff --git a/doc/en/example/fixtures/test_fixtures_order_dependencies.py b/doc/en/example/fixtures/test_fixtures_order_dependencies.py index e76e3f93c62..1c59f010341 100644 --- a/doc/en/example/fixtures/test_fixtures_order_dependencies.py +++ b/doc/en/example/fixtures/test_fixtures_order_dependencies.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import pytest diff --git a/doc/en/example/fixtures/test_fixtures_order_scope.py b/doc/en/example/fixtures/test_fixtures_order_scope.py index 5d9487cab34..4b4260fbdcd 100644 --- a/doc/en/example/fixtures/test_fixtures_order_scope.py +++ b/doc/en/example/fixtures/test_fixtures_order_scope.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import pytest diff --git a/doc/en/example/fixtures/test_fixtures_request_different_scope.py b/doc/en/example/fixtures/test_fixtures_request_different_scope.py index 00e2e46d845..dee61f8c4d7 100644 --- a/doc/en/example/fixtures/test_fixtures_request_different_scope.py +++ b/doc/en/example/fixtures/test_fixtures_request_different_scope.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import pytest diff --git a/doc/en/example/index.rst b/doc/en/example/index.rst index 71e855534ff..840819002d4 100644 --- a/doc/en/example/index.rst +++ b/doc/en/example/index.rst @@ -18,7 +18,6 @@ For basic examples, see - :ref:`Fixtures ` for basic fixture/setup examples - :ref:`parametrize` for basic test function parametrization - :ref:`unittest` for basic unittest integration -- :ref:`noseintegration` for basic nosetests integration The following examples aim at various use cases you might encounter. @@ -32,3 +31,4 @@ The following examples aim at various use cases you might encounter. special pythoncollection nonpython + customdirectory diff --git a/doc/en/example/markers.rst b/doc/en/example/markers.rst index 55fd1f576cf..babcd9e2f3a 100644 --- a/doc/en/example/markers.rst +++ b/doc/en/example/markers.rst @@ -25,10 +25,12 @@ You can "mark" a test function with custom metadata like this: pass # perform some webtest test for your app + @pytest.mark.device(serial="123") def test_something_quick(): pass + @pytest.mark.device(serial="abc") def test_another(): pass @@ -45,7 +47,7 @@ You can then restrict a test run to only run tests marked with ``webtest``: $ pytest -v -m webtest =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-7.x.y, pluggy-1.x.y -- $PYTHON_PREFIX/bin/python + platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y -- $PYTHON_PREFIX/bin/python cachedir: .pytest_cache rootdir: /home/sweet/project collecting ... collected 4 items / 3 deselected / 1 selected @@ -60,7 +62,7 @@ Or the inverse, running all tests except the webtest ones: $ pytest -v -m "not webtest" =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-7.x.y, pluggy-1.x.y -- $PYTHON_PREFIX/bin/python + platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y -- $PYTHON_PREFIX/bin/python cachedir: .pytest_cache rootdir: /home/sweet/project collecting ... collected 4 items / 1 deselected / 3 selected @@ -71,6 +73,28 @@ Or the inverse, running all tests except the webtest ones: ===================== 3 passed, 1 deselected in 0.12s ====================== +.. _`marker_keyword_expression_example`: + +Additionally, you can restrict a test run to only run tests matching one or multiple marker +keyword arguments, e.g. to run only tests marked with ``device`` and the specific ``serial="123"``: + +.. code-block:: pytest + + $ pytest -v -m "device(serial='123')" + =========================== test session starts ============================ + platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y -- $PYTHON_PREFIX/bin/python + cachedir: .pytest_cache + rootdir: /home/sweet/project + collecting ... collected 4 items / 3 deselected / 1 selected + + test_server.py::test_something_quick PASSED [100%] + + ===================== 1 passed, 3 deselected in 0.12s ====================== + +.. note:: Only keyword argument matching is supported in marker expressions. + +.. note:: Only :class:`int`, (unescaped) :class:`str`, :class:`bool` & :data:`None` values are supported in marker expressions. + Selecting tests based on their node ID -------------------------------------- @@ -82,7 +106,7 @@ tests based on their module, class, method, or function name: $ pytest -v test_server.py::TestClass::test_method =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-7.x.y, pluggy-1.x.y -- $PYTHON_PREFIX/bin/python + platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y -- $PYTHON_PREFIX/bin/python cachedir: .pytest_cache rootdir: /home/sweet/project collecting ... collected 1 item @@ -97,7 +121,7 @@ You can also select on the class: $ pytest -v test_server.py::TestClass =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-7.x.y, pluggy-1.x.y -- $PYTHON_PREFIX/bin/python + platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y -- $PYTHON_PREFIX/bin/python cachedir: .pytest_cache rootdir: /home/sweet/project collecting ... collected 1 item @@ -112,7 +136,7 @@ Or select multiple nodes: $ pytest -v test_server.py::TestClass test_server.py::test_send_http =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-7.x.y, pluggy-1.x.y -- $PYTHON_PREFIX/bin/python + platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y -- $PYTHON_PREFIX/bin/python cachedir: .pytest_cache rootdir: /home/sweet/project collecting ... collected 2 items @@ -136,7 +160,7 @@ Or select multiple nodes: Node IDs for failing tests are displayed in the test summary info when running pytest with the ``-rf`` option. You can also - construct Node IDs from the output of ``pytest --collectonly``. + construct Node IDs from the output of ``pytest --collect-only``. Using ``-k expr`` to select tests based on their name ------------------------------------------------------- @@ -156,7 +180,7 @@ The expression matching is now case-insensitive. $ pytest -v -k http # running with the above defined example module =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-7.x.y, pluggy-1.x.y -- $PYTHON_PREFIX/bin/python + platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y -- $PYTHON_PREFIX/bin/python cachedir: .pytest_cache rootdir: /home/sweet/project collecting ... collected 4 items / 3 deselected / 1 selected @@ -171,7 +195,7 @@ And you can also run all tests except the ones that match the keyword: $ pytest -k "not send_http" -v =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-7.x.y, pluggy-1.x.y -- $PYTHON_PREFIX/bin/python + platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y -- $PYTHON_PREFIX/bin/python cachedir: .pytest_cache rootdir: /home/sweet/project collecting ... collected 4 items / 1 deselected / 3 selected @@ -188,7 +212,7 @@ Or to select "http" and "quick" tests: $ pytest -k "http or quick" -v =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-7.x.y, pluggy-1.x.y -- $PYTHON_PREFIX/bin/python + platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y -- $PYTHON_PREFIX/bin/python cachedir: .pytest_cache rootdir: /home/sweet/project collecting ... collected 4 items / 2 deselected / 2 selected @@ -397,7 +421,7 @@ the test needs: $ pytest -E stage2 =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-7.x.y, pluggy-1.x.y + platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y rootdir: /home/sweet/project collected 1 item @@ -411,7 +435,7 @@ and here is one that specifies exactly the environment needed: $ pytest -E stage1 =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-7.x.y, pluggy-1.x.y + platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y rootdir: /home/sweet/project collected 1 item @@ -604,7 +628,7 @@ then you will see two tests skipped and two executed tests as expected: $ pytest -rs # this option reports skip reasons =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-7.x.y, pluggy-1.x.y + platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y rootdir: /home/sweet/project collected 4 items @@ -620,7 +644,7 @@ Note that if you specify a platform via the marker-command line option like this $ pytest -m linux =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-7.x.y, pluggy-1.x.y + platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y rootdir: /home/sweet/project collected 4 items / 3 deselected / 1 selected @@ -683,7 +707,7 @@ We can now use the ``-m option`` to select one set: $ pytest -m interface --tb=short =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-7.x.y, pluggy-1.x.y + platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y rootdir: /home/sweet/project collected 4 items / 2 deselected / 2 selected @@ -709,7 +733,7 @@ or to select both "event" and "interface" tests: $ pytest -m "interface or event" --tb=short =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-7.x.y, pluggy-1.x.y + platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y rootdir: /home/sweet/project collected 4 items / 1 deselected / 3 selected diff --git a/doc/en/example/multipython.py b/doc/en/example/multipython.py index 9005d31addd..f54524213bc 100644 --- a/doc/en/example/multipython.py +++ b/doc/en/example/multipython.py @@ -1,14 +1,16 @@ -""" -module containing a parametrized tests testing cross-python -serialization via the pickle module. -""" +"""Module containing a parametrized tests testing cross-python serialization +via the pickle module.""" + +from __future__ import annotations + import shutil import subprocess import textwrap import pytest -pythonlist = ["python3.5", "python3.6", "python3.7"] + +pythonlist = ["python3.9", "python3.10", "python3.11"] @pytest.fixture(params=pythonlist) @@ -33,37 +35,33 @@ def dumps(self, obj): dumpfile = self.picklefile.with_name("dump.py") dumpfile.write_text( textwrap.dedent( - r""" + rf""" import pickle - f = open({!r}, 'wb') - s = pickle.dump({!r}, f, protocol=2) + f = open({str(self.picklefile)!r}, 'wb') + s = pickle.dump({obj!r}, f, protocol=2) f.close() - """.format( - str(self.picklefile), obj - ) + """ ) ) - subprocess.check_call((self.pythonpath, str(dumpfile))) + subprocess.run((self.pythonpath, str(dumpfile)), check=True) def load_and_is_true(self, expression): loadfile = self.picklefile.with_name("load.py") loadfile.write_text( textwrap.dedent( - r""" + rf""" import pickle - f = open({!r}, 'rb') + f = open({str(self.picklefile)!r}, 'rb') obj = pickle.load(f) f.close() - res = eval({!r}) + res = eval({expression!r}) if not res: raise SystemExit(1) - """.format( - str(self.picklefile), expression - ) + """ ) ) print(loadfile) - subprocess.check_call((self.pythonpath, str(loadfile))) + subprocess.run((self.pythonpath, str(loadfile)), check=True) @pytest.mark.parametrize("obj", [42, {}, {1: 3}]) diff --git a/doc/en/example/nonpython.rst b/doc/en/example/nonpython.rst index efb701b1f16..aa463e2416b 100644 --- a/doc/en/example/nonpython.rst +++ b/doc/en/example/nonpython.rst @@ -28,7 +28,7 @@ now execute the test specification: nonpython $ pytest test_simple.yaml =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-7.x.y, pluggy-1.x.y + platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y rootdir: /home/sweet/project/nonpython collected 2 items @@ -64,7 +64,7 @@ consulted when reporting in ``verbose`` mode: nonpython $ pytest -v =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-7.x.y, pluggy-1.x.y -- $PYTHON_PREFIX/bin/python + platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y -- $PYTHON_PREFIX/bin/python cachedir: .pytest_cache rootdir: /home/sweet/project/nonpython collecting ... collected 2 items @@ -90,7 +90,7 @@ interesting to just look at the collection tree: nonpython $ pytest --collect-only =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-7.x.y, pluggy-1.x.y + platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y rootdir: /home/sweet/project/nonpython collected 2 items diff --git a/doc/en/example/nonpython/conftest.py b/doc/en/example/nonpython/conftest.py index dd1ebe88d7e..b7bdc77a004 100644 --- a/doc/en/example/nonpython/conftest.py +++ b/doc/en/example/nonpython/conftest.py @@ -1,4 +1,6 @@ # content of conftest.py +from __future__ import annotations + import pytest @@ -12,7 +14,7 @@ def collect(self): # We need a yaml parser, e.g. PyYAML. import yaml - raw = yaml.safe_load(self.path.open()) + raw = yaml.safe_load(self.path.open(encoding="utf-8")) for name, spec in sorted(raw.items()): yield YamlItem.from_parent(self, name=name, spec=spec) diff --git a/doc/en/example/parametrize.rst b/doc/en/example/parametrize.rst index df2859b14e0..6374e0edb3d 100644 --- a/doc/en/example/parametrize.rst +++ b/doc/en/example/parametrize.rst @@ -4,8 +4,6 @@ Parametrizing tests ================================================= -.. currentmodule:: _pytest.python - ``pytest`` allows to easily parametrize test functions. For basic docs, see :ref:`parametrize-basics`. @@ -160,19 +158,20 @@ objects, they are still using the default pytest representation: $ pytest test_time.py --collect-only =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-7.x.y, pluggy-1.x.y + platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y rootdir: /home/sweet/project collected 8 items - - - - - - - - - + + + + + + + + + + ======================== 8 tests collected in 0.12s ======================== @@ -185,7 +184,7 @@ A quick port of "testscenarios" Here is a quick port to run tests configured with :pypi:`testscenarios`, an add-on from Robert Collins for the standard unittest framework. We only have to work a bit to construct the correct arguments for pytest's -:py:func:`Metafunc.parametrize`: +:py:func:`Metafunc.parametrize `: .. code-block:: python @@ -222,7 +221,7 @@ this is a fully self-contained example which you can run with: $ pytest test_scenarios.py =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-7.x.y, pluggy-1.x.y + platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y rootdir: /home/sweet/project collected 4 items @@ -236,16 +235,17 @@ If you just collect tests you'll also nicely see 'advanced' and 'basic' as varia $ pytest --collect-only test_scenarios.py =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-7.x.y, pluggy-1.x.y + platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y rootdir: /home/sweet/project collected 4 items - - - - - - + + + + + + + ======================== 4 tests collected in 0.12s ======================== @@ -314,13 +314,14 @@ Let's first see how it looks like at collection time: $ pytest test_backends.py --collect-only =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-7.x.y, pluggy-1.x.y + platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y rootdir: /home/sweet/project collected 2 items - - - + + + + ======================== 2 tests collected in 0.12s ======================== @@ -351,7 +352,7 @@ The first invocation with ``db == "DB1"`` passed while the second with ``db == " Indirect parametrization --------------------------------------------------- -Using the ``indirect=True`` parameter when parametrizing a test allows to +Using the ``indirect=True`` parameter when parametrizing a test allows one to parametrize a test with a fixture receiving the values before passing them to a test: @@ -412,7 +413,7 @@ The result of this test will be successful: $ pytest -v test_indirect_list.py =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-7.x.y, pluggy-1.x.y -- $PYTHON_PREFIX/bin/python + platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y -- $PYTHON_PREFIX/bin/python cachedir: .pytest_cache rootdir: /home/sweet/project collecting ... collected 1 item @@ -483,8 +484,8 @@ argument sets to use for each test function. Let's run it: FAILED test_parametrize.py::TestClass::test_equals[1-2] - assert 1 == 2 1 failed, 2 passed in 0.12s -Indirect parametrization with multiple fixtures --------------------------------------------------------------- +Parametrization with multiple fixtures +-------------------------------------- Here is a stripped down real-life example of using parametrized testing for testing serialization of objects between different python @@ -504,13 +505,13 @@ Running it results in some skips if we don't have all the python interpreters in . $ pytest -rs -q multipython.py sssssssssssssssssssssssssss [100%] ========================= short test summary info ========================== - SKIPPED [9] multipython.py:69: 'python3.5' not found - SKIPPED [9] multipython.py:69: 'python3.6' not found - SKIPPED [9] multipython.py:69: 'python3.7' not found + SKIPPED [9] multipython.py:67: 'python3.9' not found + SKIPPED [9] multipython.py:67: 'python3.10' not found + SKIPPED [9] multipython.py:67: 'python3.11' not found 27 skipped in 0.12s -Indirect parametrization of optional implementations/imports --------------------------------------------------------------------- +Parametrization of optional implementations/imports +--------------------------------------------------- If you want to compare the outcomes of several implementations of a given API, you can write test functions that receive the already imported implementations @@ -567,7 +568,7 @@ If you run this with reporting for skips enabled: $ pytest -rs test_module.py =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-7.x.y, pluggy-1.x.y + platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y rootdir: /home/sweet/project collected 2 items @@ -628,7 +629,7 @@ Then run ``pytest`` with verbose mode and with only the ``basic`` marker: $ pytest -v -m basic =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-7.x.y, pluggy-1.x.y -- $PYTHON_PREFIX/bin/python + platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y -- $PYTHON_PREFIX/bin/python cachedir: .pytest_cache rootdir: /home/sweet/project collecting ... collected 24 items / 21 deselected / 3 selected @@ -657,13 +658,16 @@ Use :func:`pytest.raises` with the :ref:`pytest.mark.parametrize ref` decorator to write parametrized tests in which some tests raise exceptions and others do not. -It may be helpful to use ``nullcontext`` as a complement to ``raises``. +``contextlib.nullcontext`` can be used to test cases that are not expected to +raise exceptions but that should result in some value. The value is given as the +``enter_result`` parameter, which will be available as the ``with`` statement’s +target (``e`` in the example below). For example: .. code-block:: python - from contextlib import nullcontext as does_not_raise + from contextlib import nullcontext import pytest @@ -671,16 +675,17 @@ For example: @pytest.mark.parametrize( "example_input,expectation", [ - (3, does_not_raise()), - (2, does_not_raise()), - (1, does_not_raise()), + (3, nullcontext(2)), + (2, nullcontext(3)), + (1, nullcontext(6)), (0, pytest.raises(ZeroDivisionError)), ], ) def test_division(example_input, expectation): """Test how much I know division.""" - with expectation: - assert (6 / example_input) is not None + with expectation as e: + assert (6 / example_input) == e -In the example above, the first three test cases should run unexceptionally, -while the fourth should raise ``ZeroDivisionError``. +In the example above, the first three test cases should run without any +exceptions, while the fourth should raise a ``ZeroDivisionError`` exception, +which is expected by pytest. diff --git a/doc/en/example/pythoncollection.py b/doc/en/example/pythoncollection.py index 8742526a191..7595ee02ca4 100644 --- a/doc/en/example/pythoncollection.py +++ b/doc/en/example/pythoncollection.py @@ -1,5 +1,6 @@ # run this with $ pytest --collect-only test_collectonly.py # +from __future__ import annotations def test_function(): diff --git a/doc/en/example/pythoncollection.rst b/doc/en/example/pythoncollection.rst index 2451e3cab49..2487e7b9d19 100644 --- a/doc/en/example/pythoncollection.rst +++ b/doc/en/example/pythoncollection.rst @@ -147,15 +147,16 @@ The test collection would look like this: $ pytest --collect-only =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-7.x.y, pluggy-1.x.y + platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y rootdir: /home/sweet/project configfile: pytest.ini collected 2 items - - - - + + + + + ======================== 2 tests collected in 0.12s ======================== @@ -209,16 +210,18 @@ You can always peek at the collection tree without running tests like this: . $ pytest --collect-only pythoncollection.py =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-7.x.y, pluggy-1.x.y + platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y rootdir: /home/sweet/project configfile: pytest.ini collected 3 items - - - - - + + + + + + + ======================== 3 tests collected in 0.12s ======================== @@ -291,7 +294,7 @@ file will be left out: $ pytest --collect-only =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-7.x.y, pluggy-1.x.y + platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y rootdir: /home/sweet/project configfile: pytest.ini collected 0 items @@ -322,3 +325,30 @@ with ``Test`` by setting a boolean ``__test__`` attribute to ``False``. # Will not be discovered as a test class TestClass: __test__ = False + +.. note:: + + If you are working with abstract test classes and want to avoid manually setting + the ``__test__`` attribute for subclasses, you can use a mixin class to handle + this automatically. For example: + + .. code-block:: python + + # Mixin to handle abstract test classes + class NotATest: + def __init_subclass__(cls): + cls.__test__ = NotATest not in cls.__bases__ + + + # Abstract test class + class AbstractTest(NotATest): + pass + + + # Subclass that will be collected as a test + class RealTest(AbstractTest): + def test_example(self): + assert 1 + 1 == 2 + + This approach ensures that subclasses of abstract test classes are automatically + collected without needing to explicitly set the ``__test__`` attribute. diff --git a/doc/en/example/reportingdemo.rst b/doc/en/example/reportingdemo.rst index cb59c4b42e1..8040ee9b957 100644 --- a/doc/en/example/reportingdemo.rst +++ b/doc/en/example/reportingdemo.rst @@ -9,7 +9,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: assertion $ pytest failure_demo.py =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-7.x.y, pluggy-1.x.y + platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y rootdir: /home/sweet/project/assertion collected 44 items @@ -25,7 +25,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: > assert param1 * 2 < param2 E assert (3 * 2) < 6 - failure_demo.py:19: AssertionError + failure_demo.py:21: AssertionError _________________________ TestFailing.test_simple __________________________ self = @@ -42,7 +42,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: E + where 42 = .f at 0xdeadbeef0002>() E + and 43 = .g at 0xdeadbeef0003>() - failure_demo.py:30: AssertionError + failure_demo.py:32: AssertionError ____________________ TestFailing.test_simple_multiline _____________________ self = @@ -50,7 +50,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: def test_simple_multiline(self): > otherfunc_multi(42, 6 * 9) - failure_demo.py:33: + failure_demo.py:35: _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ a = 42, b = 54 @@ -59,7 +59,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: > assert a == b E assert 42 == 54 - failure_demo.py:14: AssertionError + failure_demo.py:16: AssertionError ___________________________ TestFailing.test_not ___________________________ self = @@ -72,7 +72,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: E assert not 42 E + where 42 = .f at 0xdeadbeef0006>() - failure_demo.py:39: AssertionError + failure_demo.py:41: AssertionError _________________ TestSpecialisedExplanations.test_eq_text _________________ self = @@ -80,10 +80,11 @@ Here is a nice run of several failures and how ``pytest`` presents things: def test_eq_text(self): > assert "spam" == "eggs" E AssertionError: assert 'spam' == 'eggs' + E E - eggs E + spam - failure_demo.py:44: AssertionError + failure_demo.py:46: AssertionError _____________ TestSpecialisedExplanations.test_eq_similar_text _____________ self = @@ -91,12 +92,13 @@ Here is a nice run of several failures and how ``pytest`` presents things: def test_eq_similar_text(self): > assert "foo 1 bar" == "foo 2 bar" E AssertionError: assert 'foo 1 bar' == 'foo 2 bar' + E E - foo 2 bar E ? ^ E + foo 1 bar E ? ^ - failure_demo.py:47: AssertionError + failure_demo.py:49: AssertionError ____________ TestSpecialisedExplanations.test_eq_multiline_text ____________ self = @@ -104,12 +106,13 @@ Here is a nice run of several failures and how ``pytest`` presents things: def test_eq_multiline_text(self): > assert "foo\nspam\nbar" == "foo\neggs\nbar" E AssertionError: assert 'foo\nspam\nbar' == 'foo\neggs\nbar' + E E foo E - eggs E + spam E bar - failure_demo.py:50: AssertionError + failure_demo.py:52: AssertionError ______________ TestSpecialisedExplanations.test_eq_long_text _______________ self = @@ -119,6 +122,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: b = "1" * 100 + "b" + "2" * 100 > assert a == b E AssertionError: assert '111111111111...2222222222222' == '111111111111...2222222222222' + E E Skipping 90 identical leading characters in diff, use -v to show E Skipping 91 identical trailing characters in diff, use -v to show E - 1111111111b222222222 @@ -126,7 +130,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: E + 1111111111a222222222 E ? ^ - failure_demo.py:55: AssertionError + failure_demo.py:57: AssertionError _________ TestSpecialisedExplanations.test_eq_long_text_multiline __________ self = @@ -136,17 +140,17 @@ Here is a nice run of several failures and how ``pytest`` presents things: b = "1\n" * 100 + "b" + "2\n" * 100 > assert a == b E AssertionError: assert '1\n1\n1\n1\n...n2\n2\n2\n2\n' == '1\n1\n1\n1\n...n2\n2\n2\n2\n' + E E Skipping 190 identical leading characters in diff, use -v to show E Skipping 191 identical trailing characters in diff, use -v to show E 1 E 1 E 1 - E 1 E 1... E - E ...Full output truncated (6 lines hidden), use '-vv' to show + E ...Full output truncated (7 lines hidden), use '-vv' to show - failure_demo.py:60: AssertionError + failure_demo.py:62: AssertionError _________________ TestSpecialisedExplanations.test_eq_list _________________ self = @@ -154,10 +158,11 @@ Here is a nice run of several failures and how ``pytest`` presents things: def test_eq_list(self): > assert [0, 1, 2] == [0, 1, 3] E assert [0, 1, 2] == [0, 1, 3] + E E At index 2 diff: 2 != 3 E Use -v to get more diff - failure_demo.py:63: AssertionError + failure_demo.py:65: AssertionError ______________ TestSpecialisedExplanations.test_eq_list_long _______________ self = @@ -167,10 +172,11 @@ Here is a nice run of several failures and how ``pytest`` presents things: b = [0] * 100 + [2] + [3] * 100 > assert a == b E assert [0, 0, 0, 0, 0, 0, ...] == [0, 0, 0, 0, 0, 0, ...] + E E At index 100 diff: 1 != 2 E Use -v to get more diff - failure_demo.py:68: AssertionError + failure_demo.py:70: AssertionError _________________ TestSpecialisedExplanations.test_eq_dict _________________ self = @@ -178,6 +184,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: def test_eq_dict(self): > assert {"a": 0, "b": 1, "c": 0} == {"a": 0, "b": 2, "d": 0} E AssertionError: assert {'a': 0, 'b': 1, 'c': 0} == {'a': 0, 'b': 2, 'd': 0} + E E Omitting 1 identical items, use -vv to show E Differing items: E {'b': 1} != {'b': 2} @@ -187,7 +194,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: E {'d': 0} E Use -v to get more diff - failure_demo.py:71: AssertionError + failure_demo.py:73: AssertionError _________________ TestSpecialisedExplanations.test_eq_set __________________ self = @@ -195,6 +202,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: def test_eq_set(self): > assert {0, 10, 11, 12} == {0, 20, 21} E assert {0, 10, 11, 12} == {0, 20, 21} + E E Extra items in the left set: E 10 E 11 @@ -204,7 +212,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: E 21 E Use -v to get more diff - failure_demo.py:74: AssertionError + failure_demo.py:76: AssertionError _____________ TestSpecialisedExplanations.test_eq_longer_list ______________ self = @@ -212,10 +220,11 @@ Here is a nice run of several failures and how ``pytest`` presents things: def test_eq_longer_list(self): > assert [1, 2] == [1, 2, 3] E assert [1, 2] == [1, 2, 3] + E E Right contains one more item: 3 E Use -v to get more diff - failure_demo.py:77: AssertionError + failure_demo.py:79: AssertionError _________________ TestSpecialisedExplanations.test_in_list _________________ self = @@ -224,7 +233,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: > assert 1 in [0, 2, 3, 4, 5] E assert 1 in [0, 2, 3, 4, 5] - failure_demo.py:80: AssertionError + failure_demo.py:82: AssertionError __________ TestSpecialisedExplanations.test_not_in_text_multiline __________ self = @@ -233,6 +242,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: text = "some multiline\ntext\nwhich\nincludes foo\nand a\ntail" > assert "foo" not in text E AssertionError: assert 'foo' not in 'some multil...nand a\ntail' + E E 'foo' is contained here: E some multiline E text @@ -242,7 +252,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: E and a E tail - failure_demo.py:84: AssertionError + failure_demo.py:86: AssertionError ___________ TestSpecialisedExplanations.test_not_in_text_single ____________ self = @@ -251,11 +261,12 @@ Here is a nice run of several failures and how ``pytest`` presents things: text = "single foo line" > assert "foo" not in text E AssertionError: assert 'foo' not in 'single foo line' + E E 'foo' is contained here: E single foo line E ? +++ - failure_demo.py:88: AssertionError + failure_demo.py:90: AssertionError _________ TestSpecialisedExplanations.test_not_in_text_single_long _________ self = @@ -264,11 +275,12 @@ Here is a nice run of several failures and how ``pytest`` presents things: text = "head " * 50 + "foo " + "tail " * 20 > assert "foo" not in text E AssertionError: assert 'foo' not in 'head head h...l tail tail ' + E E 'foo' is contained here: E head head foo tail tail tail tail tail tail tail tail tail tail tail tail tail tail tail tail tail tail tail tail E ? +++ - failure_demo.py:92: AssertionError + failure_demo.py:94: AssertionError ______ TestSpecialisedExplanations.test_not_in_text_single_long_term _______ self = @@ -277,11 +289,12 @@ Here is a nice run of several failures and how ``pytest`` presents things: text = "head " * 50 + "f" * 70 + "tail " * 20 > assert "f" * 70 not in text E AssertionError: assert 'fffffffffff...ffffffffffff' not in 'head head h...l tail tail ' + E E 'ffffffffffffffffff...fffffffffffffffffff' is contained here: E head head fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffftail tail tail tail tail tail tail tail tail tail tail tail tail tail tail tail tail tail tail tail E ? ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ - failure_demo.py:96: AssertionError + failure_demo.py:98: AssertionError ______________ TestSpecialisedExplanations.test_eq_dataclass _______________ self = @@ -308,7 +321,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: E - c E + b - failure_demo.py:108: AssertionError + failure_demo.py:110: AssertionError ________________ TestSpecialisedExplanations.test_eq_attrs _________________ self = @@ -335,7 +348,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: E - c E + b - failure_demo.py:120: AssertionError + failure_demo.py:122: AssertionError ______________________________ test_attribute ______________________________ def test_attribute(): @@ -347,7 +360,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: E assert 1 == 2 E + where 1 = .Foo object at 0xdeadbeef0018>.b - failure_demo.py:128: AssertionError + failure_demo.py:130: AssertionError _________________________ test_attribute_instance __________________________ def test_attribute_instance(): @@ -359,7 +372,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: E + where 1 = .Foo object at 0xdeadbeef0019>.b E + where .Foo object at 0xdeadbeef0019> = .Foo'>() - failure_demo.py:135: AssertionError + failure_demo.py:137: AssertionError __________________________ test_attribute_failure __________________________ def test_attribute_failure(): @@ -371,8 +384,9 @@ Here is a nice run of several failures and how ``pytest`` presents things: i = Foo() > assert i.b == 2 + ^^^ - failure_demo.py:146: + failure_demo.py:148: _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ self = .Foo object at 0xdeadbeef001a> @@ -381,7 +395,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: > raise Exception("Failed to get attrib") E Exception: Failed to get attrib - failure_demo.py:141: Exception + failure_demo.py:143: Exception _________________________ test_attribute_multiple __________________________ def test_attribute_multiple(): @@ -398,7 +412,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: E + and 2 = .Bar object at 0xdeadbeef001c>.b E + where .Bar object at 0xdeadbeef001c> = .Bar'>() - failure_demo.py:156: AssertionError + failure_demo.py:158: AssertionError __________________________ TestRaises.test_raises __________________________ self = @@ -408,7 +422,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: > raises(TypeError, int, s) E ValueError: invalid literal for int() with base 10: 'qwe' - failure_demo.py:166: ValueError + failure_demo.py:168: ValueError ______________________ TestRaises.test_raises_doesnt _______________________ self = @@ -417,7 +431,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: > raises(OSError, int, "3") E Failed: DID NOT RAISE - failure_demo.py:169: Failed + failure_demo.py:171: Failed __________________________ TestRaises.test_raise ___________________________ self = @@ -426,16 +440,17 @@ Here is a nice run of several failures and how ``pytest`` presents things: > raise ValueError("demo error") E ValueError: demo error - failure_demo.py:172: ValueError + failure_demo.py:174: ValueError ________________________ TestRaises.test_tupleerror ________________________ self = def test_tupleerror(self): - > a, b = [1] # NOQA + > a, b = [1] # noqa: F841 + ^^^^ E ValueError: not enough values to unpack (expected 2, got 1) - failure_demo.py:175: ValueError + failure_demo.py:177: ValueError ______ TestRaises.test_reinterpret_fails_with_print_for_the_fun_of_it ______ self = @@ -444,9 +459,10 @@ Here is a nice run of several failures and how ``pytest`` presents things: items = [1, 2, 3] print(f"items is {items!r}") > a, b = items.pop() + ^^^^ E TypeError: cannot unpack non-iterable int object - failure_demo.py:180: TypeError + failure_demo.py:182: TypeError --------------------------- Captured stdout call --------------------------- items is [1, 2, 3] ________________________ TestRaises.test_some_error ________________________ @@ -454,10 +470,11 @@ Here is a nice run of several failures and how ``pytest`` presents things: self = def test_some_error(self): - > if namenotexi: # NOQA + > if namenotexi: # noqa: F821 + ^^^^^^^^^^ E NameError: name 'namenotexi' is not defined - failure_demo.py:183: NameError + failure_demo.py:185: NameError ____________________ test_dynamic_compile_shows_nicely _____________________ def test_dynamic_compile_shows_nicely(): @@ -473,7 +490,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: sys.modules[name] = module > module.foo() - failure_demo.py:202: + failure_demo.py:204: _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ > ??? @@ -493,9 +510,9 @@ Here is a nice run of several failures and how ``pytest`` presents things: > somefunc(f(), g()) - failure_demo.py:213: + failure_demo.py:215: _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ - failure_demo.py:10: in somefunc + failure_demo.py:12: in somefunc otherfunc(x, y) _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ @@ -505,7 +522,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: > assert a == b E assert 44 == 43 - failure_demo.py:6: AssertionError + failure_demo.py:8: AssertionError ___________________ TestMoreErrors.test_z1_unpack_error ____________________ self = @@ -513,9 +530,10 @@ Here is a nice run of several failures and how ``pytest`` presents things: def test_z1_unpack_error(self): items = [] > a, b = items + ^^^^ E ValueError: not enough values to unpack (expected 2, got 0) - failure_demo.py:217: ValueError + failure_demo.py:219: ValueError ____________________ TestMoreErrors.test_z2_type_error _____________________ self = @@ -523,9 +541,10 @@ Here is a nice run of several failures and how ``pytest`` presents things: def test_z2_type_error(self): items = 3 > a, b = items + ^^^^ E TypeError: cannot unpack non-iterable int object - failure_demo.py:221: TypeError + failure_demo.py:223: TypeError ______________________ TestMoreErrors.test_startswith ______________________ self = @@ -538,7 +557,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: E + where False = ('456') E + where = '123'.startswith - failure_demo.py:226: AssertionError + failure_demo.py:228: AssertionError __________________ TestMoreErrors.test_startswith_nested ___________________ self = @@ -557,7 +576,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: E + where '123' = .f at 0xdeadbeef0029>() E + and '456' = .g at 0xdeadbeef002a>() - failure_demo.py:235: AssertionError + failure_demo.py:237: AssertionError _____________________ TestMoreErrors.test_global_func ______________________ self = @@ -568,7 +587,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: E + where False = isinstance(43, float) E + where 43 = globf(42) - failure_demo.py:238: AssertionError + failure_demo.py:240: AssertionError _______________________ TestMoreErrors.test_instance _______________________ self = @@ -579,7 +598,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: E assert 42 != 42 E + where 42 = .x - failure_demo.py:242: AssertionError + failure_demo.py:244: AssertionError _______________________ TestMoreErrors.test_compare ________________________ self = @@ -589,7 +608,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: E assert 11 < 5 E + where 11 = globf(10) - failure_demo.py:245: AssertionError + failure_demo.py:247: AssertionError _____________________ TestMoreErrors.test_try_finally ______________________ self = @@ -600,7 +619,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: > assert x == 0 E assert 1 == 0 - failure_demo.py:250: AssertionError + failure_demo.py:252: AssertionError ___________________ TestCustomAssertMsg.test_single_line ___________________ self = @@ -615,7 +634,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: E assert 1 == 2 E + where 1 = .A'>.a - failure_demo.py:261: AssertionError + failure_demo.py:263: AssertionError ____________________ TestCustomAssertMsg.test_multiline ____________________ self = @@ -625,16 +644,16 @@ Here is a nice run of several failures and how ``pytest`` presents things: a = 1 b = 2 - > assert ( - A.a == b - ), "A.a appears not to be b\nor does not appear to be b\none of those" + > assert A.a == b, ( + "A.a appears not to be b\nor does not appear to be b\none of those" + ) E AssertionError: A.a appears not to be b E or does not appear to be b E one of those E assert 1 == 2 E + where 1 = .A'>.a - failure_demo.py:268: AssertionError + failure_demo.py:270: AssertionError ___________________ TestCustomAssertMsg.test_custom_repr ___________________ self = @@ -656,7 +675,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: E assert 1 == 2 E + where 1 = This is JSON\n{\n 'foo': 'bar'\n}.a - failure_demo.py:281: AssertionError + failure_demo.py:283: AssertionError ========================= short test summary info ========================== FAILED failure_demo.py::test_generative[3-6] - assert (3 * 2) < 6 FAILED failure_demo.py::TestFailing::test_simple - assert 42 == 43 diff --git a/doc/en/example/simple.rst b/doc/en/example/simple.rst index 97a6dd9f436..c1a444bea18 100644 --- a/doc/en/example/simple.rst +++ b/doc/en/example/simple.rst @@ -104,6 +104,7 @@ Let's run this without supplying our new option: elif cmdopt == "type2": print("second") > assert 0 # to see what was printed + ^^^^^^^^ E assert 0 test_sample.py:6: AssertionError @@ -130,6 +131,7 @@ And now with supplying a command line option: elif cmdopt == "type2": print("second") > assert 0 # to see what was printed + ^^^^^^^^ E assert 0 test_sample.py:6: AssertionError @@ -164,11 +166,11 @@ Now we'll get feedback on a bad argument: $ pytest -q --cmdopt=type3 ERROR: usage: pytest [options] [file_or_dir] [file_or_dir] [...] - pytest: error: argument --cmdopt: invalid choice: 'type3' (choose from 'type1', 'type2') + pytest: error: argument --cmdopt: invalid choice: 'type3' (choose from type1, type2) If you need to provide more detailed error messages, you can use the -``type`` parameter and raise ``pytest.UsageError``: +``type`` parameter and raise :exc:`pytest.UsageError`: .. code-block:: python @@ -212,7 +214,7 @@ the command line arguments before they get processed: .. code-block:: python - # setuptools plugin + # installable external plugin import sys @@ -232,7 +234,7 @@ directory with the above conftest.py: $ pytest =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-7.x.y, pluggy-1.x.y + platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y rootdir: /home/sweet/project collected 0 items @@ -296,7 +298,7 @@ and when running it will see a skipped "slow" test: $ pytest -rs # "-rs" means report details on the little 's' =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-7.x.y, pluggy-1.x.y + platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y rootdir: /home/sweet/project collected 2 items @@ -312,7 +314,7 @@ Or run it including the ``slow`` marked test: $ pytest --runslow =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-7.x.y, pluggy-1.x.y + platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y rootdir: /home/sweet/project collected 2 items @@ -405,35 +407,20 @@ Detect if running from within a pytest run Usually it is a bad idea to make application code behave differently if called from a test. But if you absolutely must find out if your application code is -running from a test you can do something like this: +running from a test you can do this: .. code-block:: python - # content of your_module.py + import os - _called_from_test = False - -.. code-block:: python - - # content of conftest.py - - - def pytest_configure(config): - your_module._called_from_test = True - -and then check for the ``your_module._called_from_test`` flag: - -.. code-block:: python - - if your_module._called_from_test: - # called from within a test run + if os.environ.get("PYTEST_VERSION") is not None: + # Things you want to to do if your code is called by pytest. ... else: - # called "normally" + # Things you want to to do if your code is not called by pytest. ... -accordingly in your application. Adding info to test report header -------------------------------------------------------------- @@ -456,7 +443,7 @@ which will add the string to the test header accordingly: $ pytest =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-7.x.y, pluggy-1.x.y + platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y project deps: mylib-1.1 rootdir: /home/sweet/project collected 0 items @@ -475,7 +462,7 @@ display more information if applicable: def pytest_report_header(config): - if config.getoption("verbose") > 0: + if config.get_verbosity() > 0: return ["info1: did you know that ...", "did you?"] which will add info only when run with "--v": @@ -484,7 +471,7 @@ which will add info only when run with "--v": $ pytest -v =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-7.x.y, pluggy-1.x.y -- $PYTHON_PREFIX/bin/python + platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y -- $PYTHON_PREFIX/bin/python cachedir: .pytest_cache info1: did you know that ... did you? @@ -499,7 +486,7 @@ and nothing when run plainly: $ pytest =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-7.x.y, pluggy-1.x.y + platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y rootdir: /home/sweet/project collected 0 items @@ -538,7 +525,7 @@ Now we can profile which test functions execute the slowest: $ pytest --durations=3 =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-7.x.y, pluggy-1.x.y + platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y rootdir: /home/sweet/project collected 3 items @@ -644,7 +631,7 @@ If we run this: $ pytest -rx =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-7.x.y, pluggy-1.x.y + platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y rootdir: /home/sweet/project collected 4 items @@ -661,7 +648,7 @@ If we run this: test_step.py:11: AssertionError ========================= short test summary info ========================== - XFAIL test_step.py::TestUserHandling::test_deletion - reason: previous test failed (test_modification) + XFAIL test_step.py::TestUserHandling::test_deletion - previous test failed (test_modification) ================== 1 failed, 2 passed, 1 xfailed in 0.12s ================== We'll see that ``test_deletion`` was not executed because ``test_modification`` @@ -726,58 +713,60 @@ We can run this: $ pytest =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-7.x.y, pluggy-1.x.y + platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y rootdir: /home/sweet/project collected 7 items - test_step.py .Fx. [ 57%] - a/test_db.py F [ 71%] - a/test_db2.py F [ 85%] - b/test_error.py E [100%] + a/test_db.py F [ 14%] + a/test_db2.py F [ 28%] + b/test_error.py E [ 42%] + test_step.py .Fx. [100%] ================================== ERRORS ================================== _______________________ ERROR at setup of test_root ________________________ file /home/sweet/project/b/test_error.py, line 1 def test_root(db): # no db here, will error out E fixture 'db' not found - > available fixtures: cache, capfd, capfdbinary, caplog, capsys, capsysbinary, doctest_namespace, monkeypatch, pytestconfig, record_property, record_testsuite_property, record_xml_attribute, recwarn, tmp_path, tmp_path_factory, tmpdir, tmpdir_factory + > available fixtures: cache, capfd, capfdbinary, caplog, capsys, capsysbinary, capteesys, doctest_namespace, monkeypatch, pytestconfig, record_property, record_testsuite_property, record_xml_attribute, recwarn, tmp_path, tmp_path_factory, tmpdir, tmpdir_factory > use 'pytest --fixtures [testpath]' for help on them. /home/sweet/project/b/test_error.py:1 ================================= FAILURES ================================= - ____________________ TestUserHandling.test_modification ____________________ - - self = - - def test_modification(self): - > assert 0 - E assert 0 - - test_step.py:11: AssertionError _________________________________ test_a1 __________________________________ - db = + db = def test_a1(db): > assert 0, db # to show value - E AssertionError: + ^^^^^^^^^^^^ + E AssertionError: E assert 0 a/test_db.py:2: AssertionError _________________________________ test_a2 __________________________________ - db = + db = def test_a2(db): > assert 0, db # to show value - E AssertionError: + ^^^^^^^^^^^^ + E AssertionError: E assert 0 a/test_db2.py:2: AssertionError + ____________________ TestUserHandling.test_modification ____________________ + + self = + + def test_modification(self): + > assert 0 + E assert 0 + + test_step.py:11: AssertionError ========================= short test summary info ========================== - FAILED test_step.py::TestUserHandling::test_modification - assert 0 FAILED a/test_db.py::test_a1 - AssertionError: `) doesn't work with frozen executables so pytest can't find any third party plugins automatically. To include third party plugins like ``pytest-timeout`` they must be imported explicitly and passed on to pytest.main. @@ -1088,4 +1081,4 @@ application with standard ``pytest`` command-line options: .. code-block:: bash - ./app_main --pytest --verbose --tb=long --junitxml=results.xml test-suite/ + ./app_main --pytest --verbose --tb=long --junit=xml=results.xml test-suite/ diff --git a/doc/en/example/xfail_demo.py b/doc/en/example/xfail_demo.py index 01e6da1ad2e..4999e15f238 100644 --- a/doc/en/example/xfail_demo.py +++ b/doc/en/example/xfail_demo.py @@ -1,5 +1,8 @@ +from __future__ import annotations + import pytest + xfail = pytest.mark.xfail diff --git a/doc/en/explanation/anatomy.rst b/doc/en/explanation/anatomy.rst index e86dd74251e..93d3400dae2 100644 --- a/doc/en/explanation/anatomy.rst +++ b/doc/en/explanation/anatomy.rst @@ -34,7 +34,7 @@ a function/method call. **Assert** is where we look at that resulting state and check if it looks how we'd expect after the dust has settled. It's where we gather evidence to say the -behavior does or does not aligns with what we expect. The ``assert`` in our test +behavior does or does not align with what we expect. The ``assert`` in our test is where we take that measurement/observation and apply our judgement to it. If something should be green, we'd say ``assert thing == "green"``. diff --git a/doc/en/explanation/ci.rst b/doc/en/explanation/ci.rst new file mode 100644 index 00000000000..45fe658d14f --- /dev/null +++ b/doc/en/explanation/ci.rst @@ -0,0 +1,70 @@ +.. _`ci-pipelines`: + +CI Pipelines +============ + +Rationale +--------- + +The goal of testing in a CI pipeline is different from testing locally. Indeed, +you can quickly edit some code and run your tests again on your computer, but +it is not possible with CI pipeline. They run on a separate server and are +triggered by specific actions. + +From that observation, pytest can detect when it is in a CI environment and +adapt some of its behaviours. + +How CI is detected +------------------ + +Pytest knows it is in a CI environment when either one of these environment variables are set, +regardless of their value: + +* `CI`: used by many CI systems. +* `BUILD_NUMBER`: used by Jenkins. + +Effects on CI +------------- + +For now, the effects on pytest of being in a CI environment are limited. + +When a CI environment is detected, the output of the short test summary info is no longer truncated to the terminal size i.e. the entire message will be shown. + + .. code-block:: python + + # content of test_ci.py + import pytest + + + def test_db_initialized(): + pytest.fail( + "deliberately failing for demo purpose, Lorem ipsum dolor sit amet, " + "consectetur adipiscing elit. Cras facilisis, massa in suscipit " + "dignissim, mauris lacus molestie nisi, quis varius metus nulla ut ipsum." + ) + + +Running this locally, without any extra options, will output: + + .. code-block:: pytest + + $ pytest test_ci.py + ... + ========================= short test summary info ========================== + FAILED test_backends.py::test_db_initialized[d2] - Failed: deliberately f... + +*(Note the truncated text)* + + +While running this on CI will output: + + .. code-block:: pytest + + $ export CI=true + $ pytest test_ci.py + ... + ========================= short test summary info ========================== + FAILED test_backends.py::test_db_initialized[d2] - Failed: deliberately failing + for demo purpose, Lorem ipsum dolor sit amet, consectetur adipiscing elit. Cras + facilisis, massa in suscipit dignissim, mauris lacus molestie nisi, quis varius + metus nulla ut ipsum. diff --git a/doc/en/explanation/fixtures.rst b/doc/en/explanation/fixtures.rst index 194e576493e..53d4796c825 100644 --- a/doc/en/explanation/fixtures.rst +++ b/doc/en/explanation/fixtures.rst @@ -75,7 +75,7 @@ style of setup/teardown functions: * fixture management scales from simple unit to complex functional testing, allowing to parametrize fixtures and tests according - to configuration and component options, or to re-use fixtures + to configuration and component options, or to reuse fixtures across function, class, module or whole test session scopes. * teardown logic can be easily, and safely managed, no matter how many fixtures @@ -85,7 +85,7 @@ style of setup/teardown functions: In addition, pytest continues to support :ref:`xunitsetup`. You can mix both styles, moving incrementally from classic to new style, as you prefer. You can also start out from existing :ref:`unittest.TestCase -style ` or :ref:`nose based ` projects. +style `. @@ -162,7 +162,7 @@ A note about fixture cleanup ---------------------------- pytest does not do any special processing for :data:`SIGTERM ` and -:data:`SIGQUIT ` signals (:data:`SIGINT ` is handled naturally +``SIGQUIT`` signals (:data:`SIGINT ` is handled naturally by the Python runtime via :class:`KeyboardInterrupt`), so fixtures that manage external resources which are important to be cleared when the Python process is terminated (by those signals) might leak resources. diff --git a/doc/en/explanation/flaky.rst b/doc/en/explanation/flaky.rst index ccf3fbb2b0c..8369e1d9311 100644 --- a/doc/en/explanation/flaky.rst +++ b/doc/en/explanation/flaky.rst @@ -18,7 +18,7 @@ System state Broadly speaking, a flaky test indicates that the test relies on some system state that is not being appropriately controlled - the test environment is not sufficiently isolated. Higher level tests are more likely to be flaky as they rely on more state. -Flaky tests sometimes appear when a test suite is run in parallel (such as use of pytest-xdist). This can indicate a test is reliant on test ordering. +Flaky tests sometimes appear when a test suite is run in parallel (such as use of `pytest-xdist`_). This can indicate a test is reliant on test ordering. - Perhaps a different test is failing to clean up after itself and leaving behind data which causes the flaky test to fail. - The flaky test is reliant on data from a previous test that doesn't clean up after itself, and in parallel runs that previous test is not always present @@ -30,9 +30,22 @@ Overly strict assertion Overly strict assertions can cause problems with floating point comparison as well as timing issues. :func:`pytest.approx` is useful here. +Thread safety +~~~~~~~~~~~~~ -Pytest features -^^^^^^^^^^^^^^^ +pytest is single-threaded, executing its tests always in the same thread, sequentially, never spawning any threads itself. + +Even in case of plugins which run tests in parallel, for example `pytest-xdist`_, usually work by spawning multiple *processes* and running tests in batches, without using multiple threads. + +It is of course possible (and common) for tests and fixtures to spawn threads themselves as part of their testing workflow (for example, a fixture that starts a server thread in the background, or a test which executes production code that spawns threads), but some care must be taken: + +* Make sure to eventually wait on any spawned threads -- for example at the end of a test, or during the teardown of a fixture. +* Avoid using primitives provided by pytest (:func:`pytest.warns`, :func:`pytest.raises`, etc) from multiple threads, as they are not thread-safe. + +If your test suite uses threads and your are seeing flaky test results, do not discount the possibility that the test is implicitly using global state in pytest itself. + +Related features +^^^^^^^^^^^^^^^^ Xfail strict ~~~~~~~~~~~~ @@ -52,10 +65,9 @@ Plugins Rerunning any failed tests can mitigate the negative effects of flaky tests by giving them additional chances to pass, so that the overall build does not fail. Several pytest plugins support this: -* `flaky `_ -* `pytest-flakefinder `_ - `blog post `_ * `pytest-rerunfailures `_ * `pytest-replay `_: This plugin helps to reproduce locally crashes or flaky tests observed during CI runs. +* `pytest-flakefinder `_ - `blog post `_ Plugins to deliberately randomize tests can help expose tests with state problems: @@ -105,8 +117,11 @@ This is a limited list, please submit an issue or pull request to expand it! * Gao, Zebao, Yalan Liang, Myra B. Cohen, Atif M. Memon, and Zhen Wang. "Making system user interactive tests repeatable: When and what should we control?." In *Software Engineering (ICSE), 2015 IEEE/ACM 37th IEEE International Conference on*, vol. 1, pp. 55-65. IEEE, 2015. `PDF `__ * Palomba, Fabio, and Andy Zaidman. "Does refactoring of test smells induce fixing flaky tests?." In *Software Maintenance and Evolution (ICSME), 2017 IEEE International Conference on*, pp. 1-12. IEEE, 2017. `PDF in Google Drive `__ -* Bell, Jonathan, Owolabi Legunsen, Michael Hilton, Lamyaa Eloussi, Tifany Yung, and Darko Marinov. "DeFlaker: Automatically detecting flaky tests." In *Proceedings of the 2018 International Conference on Software Engineering*. 2018. `PDF `__ - +* Bell, Jonathan, Owolabi Legunsen, Michael Hilton, Lamyaa Eloussi, Tifany Yung, and Darko Marinov. "DeFlaker: Automatically detecting flaky tests." In *Proceedings of the 2018 International Conference on Software Engineering*. 2018. `PDF `__ +* Dutta, Saikat and Shi, August and Choudhary, Rutvik and Zhang, Zhekun and Jain, Aryaman and Misailovic, Sasa. "Detecting flaky tests in probabilistic and machine learning applications." In *Proceedings of the 29th ACM SIGSOFT International Symposium on Software Testing and Analysis (ISSTA)*, pp. 211-224. ACM, 2020. `PDF `__ +* Habchi, Sarra and Haben, Guillaume and Sohn, Jeongju and Franci, Adriano and Papadakis, Mike and Cordy, Maxime and Le Traon, Yves. "What Made This Test Flake? Pinpointing Classes Responsible for Test Flakiness." In Proceedings of the 38th IEEE International Conference on Software Maintenance and Evolution (ICSME), IEEE, 2022. `PDF `__ +* Lamprou, Sokrates. "Non-deterministic tests and where to find them: Empirically investigating the relationship between flaky tests and test smells by examining test order dependency." Bachelor thesis, Department of Computer and Information Science, Linköping University, 2022. LIU-IDA/LITH-EX-G–19/056–SE. `PDF `__ +* Leinen, Fabian and Elsner, Daniel and Pretschner, Alexander and Stahlbauer, Andreas and Sailer, Michael and Jürgens, Elmar. "Cost of Flaky Tests in Continuous Integration: An Industrial Case Study." Technical University of Munich and CQSE GmbH, Munich, Germany, 2023. `PDF `__ Resources ^^^^^^^^^ @@ -124,3 +139,13 @@ Resources * `Flaky Tests at Google and How We Mitigate Them `_ by John Micco, 2016 * `Where do Google's flaky tests come from? `_ by Jeff Listfield, 2017 + +* Dropbox: + * `Athena: Our automated build health management system `_ by Utsav Shah, 2019 + * `How To Manage Flaky Tests in your CI Workflows `_ by Li Haoyi, 2025 + +* Uber: + * `Handling Flaky Unit Tests in Java `_ by Uber Engineering, 2021 + * `Flaky Tests Overhaul at Uber `_ by Uber Engineering, 2024 + +.. _pytest-xdist: https://github.com/pytest-dev/pytest-xdist diff --git a/doc/en/explanation/goodpractices.rst b/doc/en/explanation/goodpractices.rst index efde420cd8f..51c0b960aed 100644 --- a/doc/en/explanation/goodpractices.rst +++ b/doc/en/explanation/goodpractices.rst @@ -60,8 +60,10 @@ Within Python modules, ``pytest`` also discovers tests using the standard :ref:`unittest.TestCase ` subclassing technique. -Choosing a test layout / import rules -------------------------------------- +.. _`test layout`: + +Choosing a test layout +---------------------- ``pytest`` supports two common test layouts: @@ -208,8 +210,8 @@ Note that this layout also works in conjunction with the ``src`` layout mentione to avoid surprises such as a test module getting imported twice. With ``--import-mode=importlib`` things are less convoluted because - pytest doesn't need to change ``sys.path`` or ``sys.modules``, making things - much less surprising. + pytest doesn't need to change ``sys.path``, making things much less + surprising. .. _which-import-mode: diff --git a/doc/en/explanation/index.rst b/doc/en/explanation/index.rst index 53910f1eb7b..2606d7d4b34 100644 --- a/doc/en/explanation/index.rst +++ b/doc/en/explanation/index.rst @@ -11,5 +11,7 @@ Explanation anatomy fixtures goodpractices - flaky pythonpath + types + ci + flaky diff --git a/doc/en/explanation/pythonpath.rst b/doc/en/explanation/pythonpath.rst index 5b533f47fdc..e68f455cedf 100644 --- a/doc/en/explanation/pythonpath.rst +++ b/doc/en/explanation/pythonpath.rst @@ -10,23 +10,31 @@ Import modes pytest as a testing framework needs to import test modules and ``conftest.py`` files for execution. -Importing files in Python (at least until recently) is a non-trivial processes, often requiring -changing :data:`sys.path`. Some aspects of the +Importing files in Python is a non-trivial process, so aspects of the import process can be controlled through the ``--import-mode`` command-line flag, which can assume these values: -* ``prepend`` (default): the directory path containing each module will be inserted into the *beginning* - of :py:data:`sys.path` if not already there, and then imported with the :func:`importlib.import_module ` function. +.. _`import-mode-prepend`: - This requires test module names to be unique when the test directory tree is not arranged in - packages, because the modules will put in :py:data:`sys.modules` after importing. +* ``prepend`` (default): The directory path containing each module will be inserted into the *beginning* + of :py:data:`sys.path` if not already there, and then imported with + the :func:`importlib.import_module ` function. + + It is highly recommended to arrange your test modules as packages by adding ``__init__.py`` files to your directories + containing tests. This will make the tests part of a proper Python package, allowing pytest to resolve their full + name (for example ``tests.core.test_core`` for ``test_core.py`` inside the ``tests.core`` package). + + If the test directory tree is not arranged as packages, then each test file needs to have a unique name + compared to the other test files, otherwise pytest will raise an error if it finds two tests with the same name. This is the classic mechanism, dating back from the time Python 2 was still supported. +.. _`import-mode-append`: + * ``append``: the directory containing each module is appended to the end of :py:data:`sys.path` if not already there, and imported with :func:`importlib.import_module `. - This better allows to run test modules against installed versions of a package even if the + This better allows users to run test modules against installed versions of a package even if the package under test has the same import root. For example: :: @@ -37,33 +45,79 @@ these values: the tests will run against the installed version of ``pkg_under_test`` when ``--import-mode=append`` is used whereas - with ``prepend`` they would pick up the local version. This kind of confusion is why - we advocate for using :ref:`src ` layouts. + with ``prepend``, they would pick up the local version. This kind of confusion is why + we advocate for using :ref:`src-layouts `. Same as ``prepend``, requires test module names to be unique when the test directory tree is not arranged in packages, because the modules will put in :py:data:`sys.modules` after importing. -* ``importlib``: new in pytest-6.0, this mode uses more fine control mechanisms provided by :mod:`importlib` to import test modules. This gives full control over the import process, and doesn't require changing :py:data:`sys.path`. +.. _`import-mode-importlib`: + +* ``importlib``: this mode uses more fine control mechanisms provided by :mod:`importlib` to import test modules, without changing :py:data:`sys.path`. + + Advantages of this mode: + + * pytest will not change :py:data:`sys.path` at all. + * Test module names do not need to be unique -- pytest will generate a unique name automatically based on the ``rootdir``. + + Disadvantages: + + * Test modules can't import each other. + * Testing utility modules in the tests directories (for example a ``tests.helpers`` module containing test-related functions/classes) + are not importable. The recommendation in this case it to place testing utility modules together with the application/library + code, for example ``app.testing.helpers``. + + Important: by "test utility modules", we mean functions/classes which are imported by + other tests directly; this does not include fixtures, which should be placed in ``conftest.py`` files, along + with the test modules, and are discovered automatically by pytest. + + It works like this: + + 1. Given a certain module path, for example ``tests/core/test_models.py``, derives a canonical name + like ``tests.core.test_models`` and tries to import it. - For this reason this doesn't require test module names to be unique. + For non-test modules, this will work if they are accessible via :py:data:`sys.path`. So + for example, ``.env/lib/site-packages/app/core.py`` will be importable as ``app.core``. + This happens when plugins import non-test modules (for example doctesting). - One drawback however is that test modules are non-importable by each other. Also, utility - modules in the tests directories are not automatically importable because the tests directory is no longer - added to :py:data:`sys.path`. + If this step succeeds, the module is returned. - Initially we intended to make ``importlib`` the default in future releases, however it is clear now that - it has its own set of drawbacks so the default will remain ``prepend`` for the foreseeable future. + For test modules, unless they are reachable from :py:data:`sys.path`, this step will fail. + + 2. If the previous step fails, we import the module directly using ``importlib`` facilities, which lets us import it without + changing :py:data:`sys.path`. + + Because Python requires the module to also be available in :py:data:`sys.modules`, pytest derives a unique name for it based + on its relative location from the ``rootdir``, and adds the module to :py:data:`sys.modules`. + + For example, ``tests/core/test_models.py`` will end up being imported as the module ``tests.core.test_models``. + + .. versionadded:: 6.0 + +.. note:: + + Initially we intended to make ``importlib`` the default in future releases, however it is clear now that + it has its own set of drawbacks so the default will remain ``prepend`` for the foreseeable future. + +.. note:: + + By default, pytest will not attempt to resolve namespace packages automatically, but that can + be changed via the :confval:`consider_namespace_packages` configuration variable. .. seealso:: The :confval:`pythonpath` configuration variable. + The :confval:`consider_namespace_packages` configuration variable. + + :ref:`test layout`. + ``prepend`` and ``append`` import modes scenarios ------------------------------------------------- Here's a list of scenarios when using ``prepend`` or ``append`` import modes where pytest needs to -change ``sys.path`` in order to import test modules or ``conftest.py`` files, and the issues users +change :py:data:`sys.path` in order to import test modules or ``conftest.py`` files, and the issues users might encounter because of that. Test modules / ``conftest.py`` files inside packages @@ -92,7 +146,7 @@ pytest will find ``foo/bar/tests/test_foo.py`` and realize it is part of a packa there's an ``__init__.py`` file in the same folder. It will then search upwards until it can find the last folder which still contains an ``__init__.py`` file in order to find the package *root* (in this case ``foo/``). To load the module, it will insert ``root/`` to the front of -``sys.path`` (if not there already) in order to load +:py:data:`sys.path` (if not there already) in order to load ``test_foo.py`` as the *module* ``foo.bar.tests.test_foo``. The same logic applies to the ``conftest.py`` file: it will be imported as ``foo.conftest`` module. @@ -122,8 +176,8 @@ When executing: pytest will find ``foo/bar/tests/test_foo.py`` and realize it is NOT part of a package given that there's no ``__init__.py`` file in the same folder. It will then add ``root/foo/bar/tests`` to -``sys.path`` in order to import ``test_foo.py`` as the *module* ``test_foo``. The same is done -with the ``conftest.py`` file by adding ``root/foo`` to ``sys.path`` to import it as ``conftest``. +:py:data:`sys.path` in order to import ``test_foo.py`` as the *module* ``test_foo``. The same is done +with the ``conftest.py`` file by adding ``root/foo`` to :py:data:`sys.path` to import it as ``conftest``. For this reason this layout cannot have test modules with the same name, as they all will be imported in the global import namespace. @@ -136,7 +190,7 @@ Invoking ``pytest`` versus ``python -m pytest`` ----------------------------------------------- Running pytest with ``pytest [...]`` instead of ``python -m pytest [...]`` yields nearly -equivalent behaviour, except that the latter will add the current directory to ``sys.path``, which +equivalent behaviour, except that the latter will add the current directory to :py:data:`sys.path`, which is standard ``python`` behavior. See also :ref:`invoke-python`. diff --git a/doc/en/explanation/types.rst b/doc/en/explanation/types.rst new file mode 100644 index 00000000000..827a2bf02b6 --- /dev/null +++ b/doc/en/explanation/types.rst @@ -0,0 +1,89 @@ +.. _types: + +Typing in pytest +================ + +.. note:: + This page assumes the reader is familiar with Python's typing system and its advantages. + + For more information, refer to `Python's Typing Documentation `_. + +Why type tests? +--------------- + +Typing tests provides significant advantages: + +- **Readability:** Clearly defines expected inputs and outputs, improving readability, especially in complex or parameterized tests. + +- **Refactoring:** This is the main benefit in typing tests, as it will greatly help with refactoring, letting the type checker point out the necessary changes in both production and tests, without needing to run the full test suite. + +For production code, typing also helps catching some bugs that might not be caught by tests at all (regardless of coverage), for example: + +.. code-block:: python + + def get_caption(target: int, items: list[tuple[int, str]]) -> str: + for value, caption in items: + if value == target: + return caption + + +The type checker will correctly error out that the function might return `None`, however even a full coverage test suite might miss that case: + +.. code-block:: python + + def test_get_caption() -> None: + assert get_caption(10, [(1, "foo"), (10, "bar")]) == "bar" + + +Note the code above has 100% coverage, but the bug is not caught (of course the example is "obvious", but serves to illustrate the point). + + + +Using typing in test suites +--------------------------- + +To type fixtures in pytest, just add normal types to the fixture functions -- there is nothing special that needs to be done just because of the `fixture` decorator. + +.. code-block:: python + + import pytest + + + @pytest.fixture + def sample_fixture() -> int: + return 38 + +In the same manner, the fixtures passed to test functions need be annotated with the fixture's return type: + +.. code-block:: python + + def test_sample_fixture(sample_fixture: int) -> None: + assert sample_fixture == 38 + +From the POV of the type checker, it does not matter that `sample_fixture` is actually a fixture managed by pytest, all it matters to it is that `sample_fixture` is a parameter of type `int`. + + +The same logic applies to :ref:`@pytest.mark.parametrize <@pytest.mark.parametrize>`: + +.. code-block:: python + + + @pytest.mark.parametrize("input_value, expected_output", [(1, 2), (5, 6), (10, 11)]) + def test_increment(input_value: int, expected_output: int) -> None: + assert input_value + 1 == expected_output + + +The same logic applies when typing fixture functions which receive other fixtures: + +.. code-block:: python + + @pytest.fixture + def mock_env_user(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("USER", "TestingUser") + + +Conclusion +---------- + +Incorporating typing into pytest tests enhances **clarity**, improves **debugging** and **maintenance**, and ensures **type safety**. +These practices lead to a **robust**, **readable**, and **easily maintainable** test suite that is better equipped to handle future changes with minimal risk of errors. diff --git a/doc/en/funcarg_compare.rst b/doc/en/funcarg_compare.rst index 3bf4527cfb5..bc5e7d3c515 100644 --- a/doc/en/funcarg_compare.rst +++ b/doc/en/funcarg_compare.rst @@ -11,14 +11,12 @@ funcarg mechanism, see :ref:`historical funcargs and pytest.funcargs`. If you are new to pytest, then you can simply ignore this section and read the other sections. -.. currentmodule:: _pytest - Shortcomings of the previous ``pytest_funcarg__`` mechanism -------------------------------------------------------------- The pre pytest-2.3 funcarg mechanism calls a factory each time a funcarg for a test function is required. If a factory wants to -re-use a resource across different scopes, it often used +reuse a resource across different scopes, it often used the ``request.cached_setup()`` helper to manage caching of resources. Here is a basic example how we could implement a per-session Database object: @@ -46,7 +44,7 @@ There are several limitations and difficulties with this approach: 2. parametrizing the "db" resource is not straight forward: you need to apply a "parametrize" decorator or implement a - :py:func:`~hookspec.pytest_generate_tests` hook + :hook:`pytest_generate_tests` hook calling :py:func:`~pytest.Metafunc.parametrize` which performs parametrization at the places where the resource is used. Moreover, you need to modify the factory to use an @@ -94,15 +92,14 @@ Direct parametrization of funcarg resource factories Previously, funcarg factories could not directly cause parametrization. You needed to specify a ``@parametrize`` decorator on your test function -or implement a ``pytest_generate_tests`` hook to perform +or implement a :hook:`pytest_generate_tests` hook to perform parametrization, i.e. calling a test multiple times with different value sets. pytest-2.3 introduces a decorator for use on the factory itself: .. code-block:: python @pytest.fixture(params=["mysql", "pg"]) - def db(request): - ... # use request.param + def db(request): ... # use request.param Here the factory will be invoked twice (with the respective "mysql" and "pg" values set as ``request.param`` attributes) and all of @@ -110,7 +107,7 @@ the tests requiring "db" will run twice as well. The "mysql" and "pg" values will also be used for reporting the test-invocation variants. This new way of parametrizing funcarg factories should in many cases -allow to re-use already written factories because effectively +allow to reuse already written factories because effectively ``request.param`` was already used when test functions/classes were parametrized via :py:func:`metafunc.parametrize(indirect=True) ` calls. @@ -143,8 +140,7 @@ argument: .. code-block:: python @pytest.fixture() - def db(request): - ... + def db(request): ... The name under which the funcarg resource can be requested is ``db``. @@ -153,8 +149,7 @@ aka: .. code-block:: python - def pytest_funcarg__db(request): - ... + def pytest_funcarg__db(request): ... But it is then not possible to define scoping and parametrization. @@ -169,7 +164,7 @@ hook which are often used to setup global resources. This suffers from several problems: 1. in distributed testing the managing process would setup test resources - that are never needed because it only co-ordinates the test run + that are never needed because it only coordinates the test run activities of the worker processes. 2. if you only perform a collection (with "--collect-only") diff --git a/doc/en/getting-started.rst b/doc/en/getting-started.rst index 2dbf7d3870c..349711faaf4 100644 --- a/doc/en/getting-started.rst +++ b/doc/en/getting-started.rst @@ -9,7 +9,7 @@ Get Started Install ``pytest`` ---------------------------------------- -``pytest`` requires: Python 3.7+ or PyPy3. +``pytest`` requires: Python 3.8+ or PyPy3. 1. Run the following command in your command line: @@ -22,7 +22,7 @@ Install ``pytest`` .. code-block:: bash $ pytest --version - pytest 7.3.2 + pytest 8.4.2 .. _`simpletest`: @@ -47,7 +47,7 @@ The test $ pytest =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-7.x.y, pluggy-1.x.y + platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y rootdir: /home/sweet/project collected 1 item @@ -109,6 +109,8 @@ Execute the test function with “quiet” reporting mode: The ``-q/--quiet`` flag keeps the output brief in this and following examples. +See :ref:`assertraises` for specifying more details about the expected exception. + Group multiple tests in a class -------------------------------------------------------------- @@ -250,7 +252,7 @@ Continue reading Check out additional pytest resources to help you customize tests for your unique workflow: * ":ref:`usage`" for command line invocation examples -* ":ref:`existingtestsuite`" for working with pre-existing tests +* ":ref:`existingtestsuite`" for working with preexisting tests * ":ref:`mark`" for information on the ``pytest.mark`` mechanism * ":ref:`fixtures`" for providing a functional baseline to your tests * ":ref:`plugins`" for managing and writing plugins diff --git a/doc/en/historical-notes.rst b/doc/en/historical-notes.rst index 29ebbd5d199..be67036d6ca 100644 --- a/doc/en/historical-notes.rst +++ b/doc/en/historical-notes.rst @@ -107,12 +107,12 @@ Here is a non-exhaustive list of issues fixed by the new implementation: * Marker transfer incompatible with inheritance (:issue:`535`). -More details can be found in the :pull:`original PR <3317>`. +More details can be found in the :pr:`original PR <3317>`. .. note:: in a future major release of pytest we will introduce class based markers, - at which point markers will no longer be limited to instances of :py:class:`~_pytest.mark.Mark`. + at which point markers will no longer be limited to instances of :py:class:`~pytest.Mark`. cache plugin integrated into the core @@ -227,8 +227,7 @@ to use strings: @pytest.mark.skipif("sys.version_info >= (3,3)") - def test_function(): - ... + def test_function(): ... During test function setup the skipif condition is evaluated by calling ``eval('sys.version_info >= (3,0)', namespace)``. The namespace contains @@ -262,8 +261,7 @@ configuration value which you might have added: .. code-block:: python @pytest.mark.skipif("not config.getvalue('db')") - def test_function(): - ... + def test_function(): ... The equivalent with "boolean conditions" is: diff --git a/doc/en/how-to/assert.rst b/doc/en/how-to/assert.rst index 1b10c131389..c83fb93c4b1 100644 --- a/doc/en/how-to/assert.rst +++ b/doc/en/how-to/assert.rst @@ -29,7 +29,7 @@ you will see the return value of the function call: $ pytest test_assert1.py =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-7.x.y, pluggy-1.x.y + platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y rootdir: /home/sweet/project collected 1 item @@ -54,14 +54,13 @@ operators. (See :ref:`tbreportdemo`). This allows you to use the idiomatic python constructs without boilerplate code while not losing introspection information. -However, if you specify a message with the assertion like this: +If a message is specified with the assertion like this: .. code-block:: python assert a % 2 == 0, "value was odd, should be even" -then no assertion introspection takes places at all and the message -will be simply shown in the traceback. +it is printed alongside the assertion introspection in the traceback. See :ref:`assert-details` for more information on assertion introspection. @@ -99,6 +98,27 @@ and if you need to have access to the actual exception info you may use: the actual exception raised. The main attributes of interest are ``.type``, ``.value`` and ``.traceback``. +Note that ``pytest.raises`` will match the exception type or any subclasses (like the standard ``except`` statement). +If you want to check if a block of code is raising an exact exception type, you need to check that explicitly: + + +.. code-block:: python + + def test_foo_not_implemented(): + def foo(): + raise NotImplementedError + + with pytest.raises(RuntimeError) as excinfo: + foo() + assert excinfo.type is RuntimeError + +The :func:`pytest.raises` call will succeed, even though the function raises :class:`NotImplementedError`, because +:class:`NotImplementedError` is a subclass of :class:`RuntimeError`; however the following `assert` statement will +catch the problem. + +Matching exception messages +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + You can pass a ``match`` keyword parameter to the context-manager to test that a regular expression matches on the string representation of an exception (similar to the ``TestCase.assertRaisesRegex`` method from ``unittest``): @@ -116,36 +136,210 @@ that a regular expression matches on the string representation of an exception with pytest.raises(ValueError, match=r".* 123 .*"): myfunc() -The regexp parameter of the ``match`` method is matched with the ``re.search`` -function, so in the above example ``match='123'`` would have worked as -well. +Notes: + +* The ``match`` parameter is matched with the :func:`re.search` + function, so in the above example ``match='123'`` would have worked as well. +* The ``match`` parameter also matches against `PEP-678 `__ ``__notes__``. + + +.. _`assert-matching-exception-groups`: + +Assertions about expected exception groups +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +When expecting a :exc:`BaseExceptionGroup` or :exc:`ExceptionGroup` you can use :class:`pytest.RaisesGroup`: + +.. code-block:: python + + def test_exception_in_group(): + with pytest.RaisesGroup(ValueError): + raise ExceptionGroup("group msg", [ValueError("value msg")]) + with pytest.RaisesGroup(ValueError, TypeError): + raise ExceptionGroup("msg", [ValueError("foo"), TypeError("bar")]) + + +It accepts a ``match`` parameter, that checks against the group message, and a ``check`` parameter that takes an arbitrary callable which it passes the group to, and only succeeds if the callable returns ``True``. + +.. code-block:: python + + def test_raisesgroup_match_and_check(): + with pytest.RaisesGroup(BaseException, match="my group msg"): + raise BaseExceptionGroup("my group msg", [KeyboardInterrupt()]) + with pytest.RaisesGroup( + Exception, check=lambda eg: isinstance(eg.__cause__, ValueError) + ): + raise ExceptionGroup("", [TypeError()]) from ValueError() + +It is strict about structure and unwrapped exceptions, unlike :ref:`except* `, so you might want to set the ``flatten_subgroups`` and/or ``allow_unwrapped`` parameters. + +.. code-block:: python + + def test_structure(): + with pytest.RaisesGroup(pytest.RaisesGroup(ValueError)): + raise ExceptionGroup("", (ExceptionGroup("", (ValueError(),)),)) + with pytest.RaisesGroup(ValueError, flatten_subgroups=True): + raise ExceptionGroup("1st group", [ExceptionGroup("2nd group", [ValueError()])]) + with pytest.RaisesGroup(ValueError, allow_unwrapped=True): + raise ValueError + +To specify more details about the contained exception you can use :class:`pytest.RaisesExc` + +.. code-block:: python + + def test_raises_exc(): + with pytest.RaisesGroup(pytest.RaisesExc(ValueError, match="foo")): + raise ExceptionGroup("", (ValueError("foo"))) + +They both supply a method :meth:`pytest.RaisesGroup.matches` :meth:`pytest.RaisesExc.matches` if you want to do matching outside of using it as a contextmanager. This can be helpful when checking ``.__context__`` or ``.__cause__``. + +.. code-block:: python + + def test_matches(): + exc = ValueError() + exc_group = ExceptionGroup("", [exc]) + if RaisesGroup(ValueError).matches(exc_group): + ... + # helpful error is available in `.fail_reason` if it fails to match + r = RaisesExc(ValueError) + assert r.matches(e), r.fail_reason + +Check the documentation on :class:`pytest.RaisesGroup` and :class:`pytest.RaisesExc` for more details and examples. + +``ExceptionInfo.group_contains()`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. warning:: + + This helper makes it easy to check for the presence of specific exceptions, but it is very bad for checking that the group does *not* contain *any other exceptions*. So this will pass: + + .. code-block:: python + + class EXTREMELYBADERROR(BaseException): + """This is a very bad error to miss""" + + + def test_for_value_error(): + with pytest.raises(ExceptionGroup) as excinfo: + excs = [ValueError()] + if very_unlucky(): + excs.append(EXTREMELYBADERROR()) + raise ExceptionGroup("", excs) + # This passes regardless of if there's other exceptions. + assert excinfo.group_contains(ValueError) + # You can't simply list all exceptions you *don't* want to get here. + -There's an alternate form of the :func:`pytest.raises` function where you pass -a function that will be executed with the given ``*args`` and ``**kwargs`` and -assert that the given exception is raised: + There is no good way of using :func:`excinfo.group_contains() ` to ensure you're not getting *any* other exceptions than the one you expected. + You should instead use :class:`pytest.RaisesGroup`, see :ref:`assert-matching-exception-groups`. + +You can also use the :func:`excinfo.group_contains() ` +method to test for exceptions returned as part of an :class:`ExceptionGroup`: + +.. code-block:: python + + def test_exception_in_group(): + with pytest.raises(ExceptionGroup) as excinfo: + raise ExceptionGroup( + "Group message", + [ + RuntimeError("Exception 123 raised"), + ], + ) + assert excinfo.group_contains(RuntimeError, match=r".* 123 .*") + assert not excinfo.group_contains(TypeError) + +The optional ``match`` keyword parameter works the same way as for +:func:`pytest.raises`. + +By default ``group_contains()`` will recursively search for a matching +exception at any level of nested ``ExceptionGroup`` instances. You can +specify a ``depth`` keyword parameter if you only want to match an +exception at a specific level; exceptions contained directly in the top +``ExceptionGroup`` would match ``depth=1``. .. code-block:: python - pytest.raises(ExpectedException, func, *args, **kwargs) + def test_exception_in_group_at_given_depth(): + with pytest.raises(ExceptionGroup) as excinfo: + raise ExceptionGroup( + "Group message", + [ + RuntimeError(), + ExceptionGroup( + "Nested group", + [ + TypeError(), + ], + ), + ], + ) + assert excinfo.group_contains(RuntimeError, depth=1) + assert excinfo.group_contains(TypeError, depth=2) + assert not excinfo.group_contains(RuntimeError, depth=2) + assert not excinfo.group_contains(TypeError, depth=1) + +Alternate `pytest.raises` form (legacy) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +There is an alternate form of :func:`pytest.raises` where you pass +a function that will be executed, along with ``*args`` and ``**kwargs``. :func:`pytest.raises` +will then execute the function with those arguments and assert that the given exception is raised: + +.. code-block:: python + + def func(x): + if x <= 0: + raise ValueError("x needs to be larger than zero") + + + pytest.raises(ValueError, func, x=-1) The reporter will provide you with helpful output in case of failures such as *no exception* or *wrong exception*. -Note that it is also possible to specify a "raises" argument to -``pytest.mark.xfail``, which checks that the test is failing in a more +This form was the original :func:`pytest.raises` API, developed before the ``with`` statement was +added to the Python language. Nowadays, this form is rarely used, with the context-manager form (using ``with``) +being considered more readable. +Nonetheless, this form is fully supported and not deprecated in any way. + +xfail mark and pytest.raises +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +It is also possible to specify a ``raises`` argument to +:ref:`pytest.mark.xfail `, which checks that the test is failing in a more specific way than just having any exception raised: .. code-block:: python + def f(): + raise IndexError() + + @pytest.mark.xfail(raises=IndexError) def test_f(): f() -Using :func:`pytest.raises` is likely to be better for cases where you are -testing exceptions your own code is deliberately raising, whereas using -``@pytest.mark.xfail`` with a check function is probably better for something -like documenting unfixed bugs (where the test describes what "should" happen) -or bugs in dependencies. + +This will only "xfail" if the test fails by raising ``IndexError`` or subclasses. + +* Using :ref:`pytest.mark.xfail ` with the ``raises`` parameter is probably better for something + like documenting unfixed bugs (where the test describes what "should" happen) or bugs in dependencies. + +* Using :func:`pytest.raises` is likely to be better for cases where you are + testing exceptions your own code is deliberately raising, which is the majority of cases. + +You can also use :class:`pytest.RaisesGroup`: + +.. code-block:: python + + def f(): + raise ExceptionGroup("", [IndexError()]) + + + @pytest.mark.xfail(raises=RaisesGroup(IndexError)) + def test_f(): + f() .. _`assertwarns`: @@ -183,7 +377,7 @@ if you run this module: $ pytest test_assert2.py =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-7.x.y, pluggy-1.x.y + platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y rootdir: /home/sweet/project collected 1 item @@ -197,6 +391,7 @@ if you run this module: set2 = set("8035") > assert set1 == set2 E AssertionError: assert {'0', '1', '3', '8'} == {'0', '3', '5', '8'} + E E Extra items in the left set: E '1' E Extra items in the right set: @@ -281,6 +476,50 @@ the conftest file: FAILED test_foocompare.py::test_compare - assert Comparing Foo instances: 1 failed in 0.12s +.. _`return-not-none`: + +Returning non-None value in test functions +------------------------------------------ + +A :class:`pytest.PytestReturnNotNoneWarning` is emitted when a test function returns a value other than ``None``. + +This helps prevent a common mistake made by beginners who assume that returning a ``bool`` (e.g., ``True`` or ``False``) will determine whether a test passes or fails. + +Example: + +.. code-block:: python + + @pytest.mark.parametrize( + ["a", "b", "result"], + [ + [1, 2, 5], + [2, 3, 8], + [5, 3, 18], + ], + ) + def test_foo(a, b, result): + return foo(a, b) == result # Incorrect usage, do not do this. + +Since pytest ignores return values, it might be surprising that the test will never fail based on the returned value. + +The correct fix is to replace the ``return`` statement with an ``assert``: + +.. code-block:: python + + @pytest.mark.parametrize( + ["a", "b", "result"], + [ + [1, 2, 5], + [2, 3, 8], + [5, 3, 18], + ], + ) + def test_foo(a, b, result): + assert foo(a, b) == result + + + + .. _assert-details: .. _`assert introspection`: diff --git a/doc/en/how-to/cache.rst b/doc/en/how-to/cache.rst index 8554a984c72..e3209b79359 100644 --- a/doc/en/how-to/cache.rst +++ b/doc/en/how-to/cache.rst @@ -86,7 +86,7 @@ If you then run it with ``--lf``: $ pytest --lf =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-7.x.y, pluggy-1.x.y + platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y rootdir: /home/sweet/project collected 2 items run-last-failure: rerun previous 2 failures @@ -132,7 +132,7 @@ of ``FF`` and dots): $ pytest --ff =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-7.x.y, pluggy-1.x.y + platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y rootdir: /home/sweet/project collected 50 items run-last-failure: rerun previous 2 failures first @@ -176,14 +176,21 @@ with more recent files coming first. Behavior when no tests failed in the last run --------------------------------------------- -When no tests failed in the last run, or when no cached ``lastfailed`` data was -found, ``pytest`` can be configured either to run all of the tests or no tests, -using the ``--last-failed-no-failures`` option, which takes one of the following values: +The ``--lfnf/--last-failed-no-failures`` option governs the behavior of ``--last-failed``. +Determines whether to execute tests when there are no previously (known) +failures or when no cached ``lastfailed`` data was found. + +There are two options: + +* ``all``: when there are no known test failures, runs all tests (the full test suite). This is the default. +* ``none``: when there are no known test failures, just emits a message stating this and exit successfully. + +Example: .. code-block:: bash - pytest --last-failed --last-failed-no-failures all # run all tests (default behavior) - pytest --last-failed --last-failed-no-failures none # run no tests and exit + pytest --last-failed --last-failed-no-failures all # runs the full test suite (default behavior) + pytest --last-failed --last-failed-no-failures none # runs no tests and exits successfully The new config.cache object -------------------------------- @@ -192,7 +199,7 @@ The new config.cache object Plugins or conftest.py support code can get a cached value using the pytest ``config`` object. Here is a basic example plugin which -implements a :ref:`fixture ` which re-uses previously created state +implements a :ref:`fixture ` which reuses previously created state across pytest invocations: .. code-block:: python @@ -206,12 +213,12 @@ across pytest invocations: @pytest.fixture - def mydata(request): - val = request.config.cache.get("example/value", None) + def mydata(pytestconfig): + val = pytestconfig.cache.get("example/value", None) if val is None: expensive_computation() val = 42 - request.config.cache.set("example/value", val) + pytestconfig.cache.set("example/value", val) return val @@ -274,7 +281,7 @@ You can always peek at the content of the cache using the $ pytest --cache-show =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-7.x.y, pluggy-1.x.y + platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y rootdir: /home/sweet/project cachedir: /home/sweet/project/.pytest_cache --------------------------- cache values for '*' --------------------------- @@ -282,8 +289,6 @@ You can always peek at the content of the cache using the {'test_caching.py::test_function': True} cache/nodeids contains: ['test_caching.py::test_function'] - cache/stepwise contains: - [] example/value contains: 42 @@ -296,7 +301,7 @@ filtering: $ pytest --cache-show example/* =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-7.x.y, pluggy-1.x.y + platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y rootdir: /home/sweet/project cachedir: /home/sweet/project/.pytest_cache ----------------------- cache values for 'example/*' ----------------------- diff --git a/doc/en/how-to/capture-stdout-stderr.rst b/doc/en/how-to/capture-stdout-stderr.rst index 9ccea719b64..e6affd80ea1 100644 --- a/doc/en/how-to/capture-stdout-stderr.rst +++ b/doc/en/how-to/capture-stdout-stderr.rst @@ -4,6 +4,12 @@ How to capture stdout/stderr output ========================================================= +Pytest intercepts stdout and stderr as configured by the ``--capture=`` +command-line argument or by using fixtures. The ``--capture=`` flag configures +reporting, whereas the fixtures offer more granular control and allows +inspection of output during testing. The reports can be customized with the +`-r flag <../reference/reference.html#command-line-flags>`_. + Default stdout/stderr/stdin capturing behaviour --------------------------------------------------------- @@ -83,7 +89,7 @@ of the failing function and hide the other one: $ pytest =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-7.x.y, pluggy-1.x.y + platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y rootdir: /home/sweet/project collected 2 items @@ -106,9 +112,10 @@ of the failing function and hide the other one: Accessing captured output from a test function --------------------------------------------------- -The ``capsys``, ``capsysbinary``, ``capfd``, and ``capfdbinary`` fixtures -allow access to stdout/stderr output created during test execution. Here is -an example test function that performs some output related checks: +The :fixture:`capsys`, :fixture:`capteesys`, :fixture:`capsysbinary`, :fixture:`capfd`, and :fixture:`capfdbinary` +fixtures allow access to ``stdout``/``stderr`` output created during test execution. + +Here is an example test function that performs some output related checks: .. code-block:: python @@ -125,40 +132,27 @@ an example test function that performs some output related checks: The ``readouterr()`` call snapshots the output so far - and capturing will be continued. After the test function finishes the original streams will -be restored. Using ``capsys`` this way frees your +be restored. Using :fixture:`capsys` this way frees your test from having to care about setting/resetting output streams and also interacts well with pytest's own per-test capturing. -If you want to capture on filedescriptor level you can use -the ``capfd`` fixture which offers the exact -same interface but allows to also capture output from -libraries or subprocesses that directly write to operating -system level output streams (FD1 and FD2). - - - -The return value from ``readouterr`` changed to a ``namedtuple`` with two attributes, ``out`` and ``err``. - +The return value of ``readouterr()`` is a ``namedtuple`` with two attributes, ``out`` and ``err``. - -If the code under test writes non-textual data, you can capture this using -the ``capsysbinary`` fixture which instead returns ``bytes`` from +If the code under test writes non-textual data (``bytes``), you can capture this using +the :fixture:`capsysbinary` fixture which instead returns ``bytes`` from the ``readouterr`` method. +If you want to capture at the file descriptor level you can use +the :fixture:`capfd` fixture which offers the exact +same interface but allows to also capture output from +libraries or subprocesses that directly write to operating +system level output streams (FD1 and FD2). Similarly to :fixture:`capsysbinary`, :fixture:`capfdbinary` can be +used to capture ``bytes`` at the file descriptor level. - -If the code under test writes non-textual data, you can capture this using -the ``capfdbinary`` fixture which instead returns ``bytes`` from -the ``readouterr`` method. The ``capfdbinary`` fixture operates on the -filedescriptor level. - - - - -To temporarily disable capture within a test, both ``capsys`` -and ``capfd`` have a ``disabled()`` method that can be used +To temporarily disable capture within a test, the capture fixtures +have a ``disabled()`` method that can be used as a context manager, disabling capture inside the ``with`` block: .. code-block:: python diff --git a/doc/en/how-to/capture-warnings.rst b/doc/en/how-to/capture-warnings.rst index 0390230b8ea..a9bd894b6fd 100644 --- a/doc/en/how-to/capture-warnings.rst +++ b/doc/en/how-to/capture-warnings.rst @@ -28,7 +28,7 @@ Running pytest now produces this output: $ pytest test_show_warnings.py =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-7.x.y, pluggy-1.x.y + platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y rootdir: /home/sweet/project collected 1 item @@ -66,6 +66,7 @@ as an error: def test_one(): > assert api_v1() == 1 + ^^^^^^^^ test_show_warnings.py:10: _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ @@ -128,7 +129,7 @@ is performed. -You can use the ``@pytest.mark.filterwarnings`` to add warning filters to specific test items, +You can use the :ref:`@pytest.mark.filterwarnings ` mark to add warning filters to specific test items, allowing you to have finer control of which warnings should be captured at test, class or even module level: @@ -147,10 +148,30 @@ even module level: assert api_v1() == 1 +You can specify multiple filters with separate decorators: + +.. code-block:: python + + # Ignore "api v1" warnings, but fail on all other warnings + @pytest.mark.filterwarnings("ignore:api v1") + @pytest.mark.filterwarnings("error") + def test_one(): + assert api_v1() == 1 + +.. important:: + + Regarding decorator order and filter precedence: + it's important to remember that decorators are evaluated in reverse order, + so you have to list the warning filters in the reverse order + compared to traditional :py:func:`warnings.filterwarnings` and :option:`-W option ` usage. + This means in practice that filters from earlier :ref:`@pytest.mark.filterwarnings ` decorators + take precedence over filters from later decorators, as illustrated in the example above. + + Filters applied using a mark take precedence over filters passed on the command line or configured -by the ``filterwarnings`` ini option. +by the :confval:`filterwarnings` ini option. -You may apply a filter to all tests of a class by using the ``filterwarnings`` mark as a class +You may apply a filter to all tests of a class by using the :ref:`filterwarnings ` mark as a class decorator or to all tests in a module by setting the :globalvar:`pytestmark` variable: .. code-block:: python @@ -159,6 +180,13 @@ decorator or to all tests in a module by setting the :globalvar:`pytestmark` var pytestmark = pytest.mark.filterwarnings("error") +.. note:: + + If you want to apply multiple filters + (by assigning a list of :ref:`filterwarnings ` mark to :globalvar:`pytestmark`), + you must use the traditional :py:func:`warnings.filterwarnings` ordering approach (later filters take precedence), + which is the reverse of the decorator approach mentioned above. + *Credits go to Florian Schulze for the reference implementation in the* `pytest-warnings`_ *plugin.* @@ -195,7 +223,7 @@ user code and third-party libraries, as recommended by :pep:`565`. This helps users keep their code modern and avoid breakages when deprecated warnings are effectively removed. However, in the specific case where users capture any type of warnings in their test, either with -:func:`pytest.warns`, :func:`pytest.deprecated_call` or using the :ref:`recwarn ` fixture, +:func:`pytest.warns`, :func:`pytest.deprecated_call` or using the :fixture:`recwarn` fixture, no warning will be displayed at all. Sometimes it is useful to hide some specific deprecation warnings that happen in code that you have no control over @@ -223,7 +251,7 @@ See :ref:`@pytest.mark.filterwarnings ` and the :envvar:`python:PYTHONWARNINGS` environment variable or the ``-W`` command-line option, pytest will not configure any filters by default. - Also pytest doesn't follow :pep:`506` suggestion of resetting all warning filters because + Also pytest doesn't follow :pep:`565` suggestion of resetting all warning filters because it might break test suites that configure warning filters themselves by calling :func:`warnings.simplefilter` (see :issue:`2430` for an example of that). @@ -332,10 +360,10 @@ additional information: assert record[0].message.args[0] == "another warning" Alternatively, you can examine raised warnings in detail using the -:ref:`recwarn ` fixture (see below). +:fixture:`recwarn` fixture (see :ref:`below `). -The :ref:`recwarn ` fixture automatically ensures to reset the warnings +The :fixture:`recwarn` fixture automatically ensures to reset the warnings filter at the end of the test, so no global state is leaked. .. _`recording warnings`: @@ -345,8 +373,8 @@ filter at the end of the test, so no global state is leaked. Recording warnings ------------------ -You can record raised warnings either using :func:`pytest.warns` or with -the ``recwarn`` fixture. +You can record raised warnings either using the :func:`pytest.warns` context manager or with +the :fixture:`recwarn` fixture. To record with :func:`pytest.warns` without asserting anything about the warnings, pass no arguments as the expected warning type and it will default to a generic Warning: @@ -361,7 +389,7 @@ pass no arguments as the expected warning type and it will default to a generic assert str(record[0].message) == "user" assert str(record[1].message) == "runtime" -The ``recwarn`` fixture will record warnings for the whole function: +The :fixture:`recwarn` fixture will record warnings for the whole function: .. code-block:: python @@ -377,14 +405,11 @@ The ``recwarn`` fixture will record warnings for the whole function: assert w.filename assert w.lineno -Both ``recwarn`` and :func:`pytest.warns` return the same interface for recorded -warnings: a WarningsRecorder instance. To view the recorded warnings, you can +Both the :fixture:`recwarn` fixture and the :func:`pytest.warns` context manager return the same interface for recorded +warnings: a :class:`~_pytest.recwarn.WarningsRecorder` instance. To view the recorded warnings, you can iterate over this instance, call ``len`` on it to get the number of recorded warnings, or index into it to get a particular recorded warning. -.. currentmodule:: _pytest.warnings - -Full API: :class:`~_pytest.recwarn.WarningsRecorder`. .. _`warns use cases`: diff --git a/doc/en/how-to/doctest.rst b/doc/en/how-to/doctest.rst index 021ba274fbc..c2a6cc8e958 100644 --- a/doc/en/how-to/doctest.rst +++ b/doc/en/how-to/doctest.rst @@ -30,7 +30,7 @@ then you can just invoke ``pytest`` directly: $ pytest =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-7.x.y, pluggy-1.x.y + platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y rootdir: /home/sweet/project collected 1 item @@ -58,7 +58,7 @@ and functions, including from test modules: $ pytest --doctest-modules =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-7.x.y, pluggy-1.x.y + platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y rootdir: /home/sweet/project collected 2 items @@ -224,6 +224,7 @@ place the objects you want to appear in the doctest namespace: .. code-block:: python # content of conftest.py + import pytest import numpy diff --git a/doc/en/how-to/existingtestsuite.rst b/doc/en/how-to/existingtestsuite.rst index 9909e7d113a..1c37023c72a 100644 --- a/doc/en/how-to/existingtestsuite.rst +++ b/doc/en/how-to/existingtestsuite.rst @@ -4,8 +4,8 @@ How to use pytest with an existing test suite ============================================== Pytest can be used with most existing test suites, but its -behavior differs from other test runners such as :ref:`nose ` or -Python's default unittest framework. +behavior differs from other test runners such as Python's +default unittest framework. Before using this section you will want to :ref:`install pytest `. diff --git a/doc/en/how-to/failures.rst b/doc/en/how-to/failures.rst index ef87550915a..0c45cd7b118 100644 --- a/doc/en/how-to/failures.rst +++ b/doc/en/how-to/failures.rst @@ -112,7 +112,7 @@ on the command-line. Also the :confval:`faulthandler_timeout=X` configuration option can be used to dump the traceback of all threads if a test takes longer than ``X`` -seconds to finish (not available on Windows). +seconds to finish. .. note:: @@ -135,10 +135,6 @@ Warning about unraisable exceptions and unhandled thread exceptions .. versionadded:: 6.2 -.. note:: - - These features only work on Python>=3.8. - Unhandled exceptions are exceptions that are raised in a situation in which they cannot propagate to a caller. The most common case is an exception raised in a :meth:`__del__ ` implementation. diff --git a/doc/en/how-to/fixtures.rst b/doc/en/how-to/fixtures.rst index d8517c2c8f3..7f1a7610bb8 100644 --- a/doc/en/how-to/fixtures.rst +++ b/doc/en/how-to/fixtures.rst @@ -433,7 +433,7 @@ marked ``smtp_connection`` fixture function. Running the test looks like this: $ pytest test_module.py =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-7.x.y, pluggy-1.x.y + platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y rootdir: /home/sweet/project collected 2 items @@ -449,6 +449,7 @@ marked ``smtp_connection`` fixture function. Running the test looks like this: assert response == 250 assert b"smtp.gmail.com" in msg > assert 0 # for demo purposes + ^^^^^^^^ E assert 0 test_module.py:7: AssertionError @@ -460,6 +461,7 @@ marked ``smtp_connection`` fixture function. Running the test looks like this: response, msg = smtp_connection.noop() assert response == 250 > assert 0 # for demo purposes + ^^^^^^^^ E assert 0 test_module.py:13: AssertionError @@ -494,7 +496,7 @@ Fixtures are created when first requested by a test, and are destroyed based on * ``function``: the default scope, the fixture is destroyed at the end of the test. * ``class``: the fixture is destroyed during teardown of the last test in the class. * ``module``: the fixture is destroyed during teardown of the last test in the module. -* ``package``: the fixture is destroyed during teardown of the last test in the package. +* ``package``: the fixture is destroyed during teardown of the last test in the package where the fixture is defined, including sub-packages and sub-directories within it. * ``session``: the fixture is destroyed at the end of the test session. .. note:: @@ -771,7 +773,7 @@ For yield fixtures, the first teardown code to run is from the right-most fixtur $ pytest -s test_finalizers.py =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-7.x.y, pluggy-1.x.y + platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y rootdir: /home/sweet/project collected 1 item @@ -805,7 +807,7 @@ For finalizers, the first fixture to run is last call to `request.addfinalizer`. $ pytest -s test_finalizers.py =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-7.x.y, pluggy-1.x.y + platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y rootdir: /home/sweet/project collected 1 item @@ -1271,7 +1273,7 @@ configured in multiple ways. Extending the previous example, we can flag the fixture to create two ``smtp_connection`` fixture instances which will cause all tests using the fixture to run twice. The fixture function gets access to each parameter -through the special :py:class:`request ` object: +through the special :py:class:`request ` object: .. code-block:: python @@ -1308,6 +1310,7 @@ So let's just do another run: assert response == 250 assert b"smtp.gmail.com" in msg > assert 0 # for demo purposes + ^^^^^^^^ E assert 0 test_module.py:7: AssertionError @@ -1319,6 +1322,7 @@ So let's just do another run: response, msg = smtp_connection.noop() assert response == 250 > assert 0 # for demo purposes + ^^^^^^^^ E assert 0 test_module.py:13: AssertionError @@ -1343,6 +1347,7 @@ So let's just do another run: response, msg = smtp_connection.noop() assert response == 250 > assert 0 # for demo purposes + ^^^^^^^^ E assert 0 test_module.py:13: AssertionError @@ -1414,27 +1419,28 @@ Running the above tests results in the following test IDs being used: $ pytest --collect-only =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-7.x.y, pluggy-1.x.y + platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y rootdir: /home/sweet/project collected 12 items - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + ======================= 12 tests collected in 0.12s ======================== @@ -1468,7 +1474,7 @@ Running this test will *skip* the invocation of ``data_set`` with value ``2``: $ pytest test_fixture_marks.py -v =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-7.x.y, pluggy-1.x.y -- $PYTHON_PREFIX/bin/python + platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y -- $PYTHON_PREFIX/bin/python cachedir: .pytest_cache rootdir: /home/sweet/project collecting ... collected 3 items @@ -1486,7 +1492,7 @@ Modularity: using fixtures from a fixture function In addition to using fixtures in test functions, fixture functions can use other fixtures themselves. This contributes to a modular design -of your fixtures and allows re-use of framework-specific fixtures across +of your fixtures and allows reuse of framework-specific fixtures across many projects. As a simple example, we can extend the previous example and instantiate an object ``app`` where we stick the already defined ``smtp_connection`` resource into it: @@ -1518,7 +1524,7 @@ Here we declare an ``app`` fixture which receives the previously defined $ pytest -v test_appsetup.py =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-7.x.y, pluggy-1.x.y -- $PYTHON_PREFIX/bin/python + platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y -- $PYTHON_PREFIX/bin/python cachedir: .pytest_cache rootdir: /home/sweet/project collecting ... collected 2 items @@ -1598,7 +1604,7 @@ Let's run the tests in verbose mode and with looking at the print-output: $ pytest -v -s test_module.py =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-7.x.y, pluggy-1.x.y -- $PYTHON_PREFIX/bin/python + platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y -- $PYTHON_PREFIX/bin/python cachedir: .pytest_cache rootdir: /home/sweet/project collecting ... collected 8 items @@ -1698,7 +1704,7 @@ and declare its use in a test module via a ``usefixtures`` marker: class TestDirectoryInit: def test_cwd_starts_empty(self): assert os.listdir(os.getcwd()) == [] - with open("myfile", "w") as f: + with open("myfile", "w", encoding="utf-8") as f: f.write("hello") def test_cwd_again_starts_empty(self): @@ -1720,8 +1726,7 @@ You can specify multiple fixtures like this: .. code-block:: python @pytest.mark.usefixtures("cleandir", "anotherfixture") - def test(): - ... + def test(): ... and you may specify fixture usage at the test module level using :globalvar:`pytestmark`: @@ -1749,11 +1754,9 @@ into an ini-file: @pytest.mark.usefixtures("my_other_fixture") @pytest.fixture - def my_fixture_that_sadly_wont_use_my_other_fixture(): - ... + def my_fixture_that_sadly_wont_use_my_other_fixture(): ... - Currently this will not generate any error or warning, but this is intended - to be handled by :issue:`3664`. + This generates a deprecation warning, and will become an error in Pytest 8. .. _`override fixtures`: @@ -1933,7 +1936,7 @@ The same applies for the test folder level obviously. Using fixtures from other projects ---------------------------------- -Usually projects that provide pytest support will use :ref:`entry points `, +Usually projects that provide pytest support will use :ref:`entry points `, so just installing those projects into an environment will make those fixtures available for use. In case you want to use fixtures from a project that does not use entry points, you can diff --git a/doc/en/how-to/index.rst b/doc/en/how-to/index.rst index 6f52aaecdc3..225f289651e 100644 --- a/doc/en/how-to/index.rst +++ b/doc/en/how-to/index.rst @@ -52,7 +52,6 @@ pytest and other test systems existingtestsuite unittest - nose xunit_setup pytest development environment diff --git a/doc/en/how-to/logging.rst b/doc/en/how-to/logging.rst index 9957a9bb886..300e9f6e6c2 100644 --- a/doc/en/how-to/logging.rst +++ b/doc/en/how-to/logging.rst @@ -172,6 +172,13 @@ the records for the ``setup`` and ``call`` stages during teardown like so: The full API is available at :class:`pytest.LogCaptureFixture`. +.. warning:: + + The ``caplog`` fixture adds a handler to the root logger to capture logs. If the root logger is + modified during a test, for example with ``logging.config.dictConfig``, this handler may be + removed and cause no logs to be captured. To avoid this, ensure that any root logger configuration + only adds to the existing handlers. + .. _live_logs: @@ -199,8 +206,9 @@ option names are: * ``log_cli_date_format`` If you need to record the whole test suite logging calls to a file, you can pass -``--log-file=/path/to/log/file``. This log file is opened in write mode which +``--log-file=/path/to/log/file``. This log file is opened in write mode by default which means that it will be overwritten at each run tests session. +If you'd like the file opened in append mode instead, then you can pass ``--log-file-mode=a``. Note that relative paths for the log-file location, whether passed on the CLI or declared in a config file, are always resolved relative to the current working directory. @@ -216,12 +224,13 @@ All of the log file options can also be set in the configuration INI file. The option names are: * ``log_file`` +* ``log_file_mode`` * ``log_file_level`` * ``log_file_format`` * ``log_file_date_format`` You can call ``set_log_path()`` to customize the log_file path dynamically. This functionality -is considered **experimental**. +is considered **experimental**. Note that ``set_log_path()`` respects the ``log_file_mode`` option. .. _log_colors: @@ -234,7 +243,7 @@ through ``add_color_level()``. Example: .. code-block:: python - @pytest.hookimpl + @pytest.hookimpl(trylast=True) def pytest_configure(config): logging_plugin = config.pluginmanager.get_plugin("logging-plugin") diff --git a/doc/en/how-to/nose.rst b/doc/en/how-to/nose.rst deleted file mode 100644 index a736dfa55a7..00000000000 --- a/doc/en/how-to/nose.rst +++ /dev/null @@ -1,82 +0,0 @@ -.. _`noseintegration`: - -How to run tests written for nose -======================================= - -``pytest`` has basic support for running tests written for nose_. - -.. warning:: - This functionality has been deprecated and is likely to be removed in ``pytest 8.x``. - -.. _nosestyle: - -Usage -------------- - -After :ref:`installation` type: - -.. code-block:: bash - - python setup.py develop # make sure tests can import our package - pytest # instead of 'nosetests' - -and you should be able to run your nose style tests and -make use of pytest's capabilities. - -Supported nose Idioms ----------------------- - -* ``setup()`` and ``teardown()`` at module/class/method level: any function or method called ``setup`` will be called during the setup phase for each test, same for ``teardown``. -* ``SkipTest`` exceptions and markers -* setup/teardown decorators -* ``__test__`` attribute on modules/classes/functions -* general usage of nose utilities - -Unsupported idioms / known issues ----------------------------------- - -- unittest-style ``setUp, tearDown, setUpClass, tearDownClass`` - are recognized only on ``unittest.TestCase`` classes but not - on plain classes. ``nose`` supports these methods also on plain - classes but pytest deliberately does not. As nose and pytest already - both support ``setup_class, teardown_class, setup_method, teardown_method`` - it doesn't seem useful to duplicate the unittest-API like nose does. - If you however rather think pytest should support the unittest-spelling on - plain classes please post to :issue:`377`. - -- nose imports test modules with the same import path (e.g. - ``tests.test_mode``) but different file system paths - (e.g. ``tests/test_mode.py`` and ``other/tests/test_mode.py``) - by extending sys.path/import semantics. pytest does not do that - but there is discussion in :issue:`268` for adding some support. Note that - `nose2 choose to avoid this sys.path/import hackery `_. - - If you place a conftest.py file in the root directory of your project - (as determined by pytest) pytest will run tests "nose style" against - the code below that directory by adding it to your ``sys.path`` instead of - running against your installed code. - - You may find yourself wanting to do this if you ran ``python setup.py install`` - to set up your project, as opposed to ``python setup.py develop`` or any of - the package manager equivalents. Installing with develop in a - virtual environment like tox is recommended over this pattern. - -- nose-style doctests are not collected and executed correctly, - also doctest fixtures don't work. - -- no nose-configuration is recognized. - -- ``yield``-based methods are unsupported as of pytest 4.1.0. They are - fundamentally incompatible with pytest because they don't support fixtures - properly since collection and test execution are separated. - -Migrating from nose to pytest ------------------------------- - -`nose2pytest `_ is a Python script -and pytest plugin to help convert Nose-based tests into pytest-based tests. -Specifically, the script transforms nose.tools.assert_* function calls into -raw assert statements, while preserving format of original arguments -as much as possible. - -.. _nose: https://nose.readthedocs.io/en/latest/ diff --git a/doc/en/how-to/output.rst b/doc/en/how-to/output.rst index e8e9af0c70b..bc98b816484 100644 --- a/doc/en/how-to/output.rst +++ b/doc/en/how-to/output.rst @@ -16,6 +16,12 @@ Examples for modifying traceback printing: pytest -l # show local variables (shortcut) pytest --no-showlocals # hide local variables (if addopts enables them) + pytest --capture=fd # default, capture at the file descriptor level + pytest --capture=sys # capture at the sys level + pytest --capture=no # don't capture + pytest -s # don't capture (shortcut) + pytest --capture=tee-sys # capture to logs but also output to sys level streams + pytest --tb=auto # (default) 'long' tracebacks for the first and last # entry, but 'short' style for the other entries pytest --tb=long # exhaustive, informative traceback formatting @@ -36,6 +42,16 @@ option you make sure a trace is shown. Verbosity -------------------------------------------------- +Examples for modifying printing verbosity: + +.. code-block:: bash + + pytest --quiet # quiet - less verbose - mode + pytest -q # quiet - less verbose - mode (shortcut) + pytest -v # increase verbosity, display individual test names + pytest -vv # more verbose, display more details from the test output + pytest -vvv # not a standard , but may be used for even more detail in certain setups + The ``-v`` flag controls the verbosity of pytest output in various aspects: test session progress, assertion details when tests fail, fixtures details with ``--fixtures``, etc. @@ -84,6 +100,7 @@ Executing pytest normally gives us this output (we are skipping the header to fo fruits2 = ["banana", "apple", "orange", "melon", "kiwi"] > assert fruits1 == fruits2 E AssertionError: assert ['banana', 'a...elon', 'kiwi'] == ['banana', 'a...elon', 'kiwi'] + E E At index 2 diff: 'grapes' != 'orange' E Use -v to get more diff @@ -95,6 +112,7 @@ Executing pytest normally gives us this output (we are skipping the header to fo number_to_text2 = {str(x * 10): x * 10 for x in range(5)} > assert number_to_text1 == number_to_text2 E AssertionError: assert {'0': 0, '1':..., '3': 3, ...} == {'0': 0, '10'...'30': 30, ...} + E E Omitting 1 identical items, use -vv to show E Left contains 4 more items: E {'1': 1, '2': 2, '3': 3, '4': 4} @@ -146,12 +164,15 @@ Now we can increase pytest's verbosity: fruits2 = ["banana", "apple", "orange", "melon", "kiwi"] > assert fruits1 == fruits2 E AssertionError: assert ['banana', 'a...elon', 'kiwi'] == ['banana', 'a...elon', 'kiwi'] + E E At index 2 diff: 'grapes' != 'orange' + E E Full diff: - E - ['banana', 'apple', 'orange', 'melon', 'kiwi'] - E ? ^ ^^ - E + ['banana', 'apple', 'grapes', 'melon', 'kiwi'] - E ? ^ ^ + + E [ + E 'banana', + E 'apple',... + E + E ...Full output truncated (7 lines hidden), use '-vv' to show test_verbosity_example.py:8: AssertionError ____________________________ test_numbers_fail _____________________________ @@ -161,15 +182,15 @@ Now we can increase pytest's verbosity: number_to_text2 = {str(x * 10): x * 10 for x in range(5)} > assert number_to_text1 == number_to_text2 E AssertionError: assert {'0': 0, '1':..., '3': 3, ...} == {'0': 0, '10'...'30': 30, ...} + E E Omitting 1 identical items, use -vv to show E Left contains 4 more items: E {'1': 1, '2': 2, '3': 3, '4': 4} E Right contains 4 more items: E {'10': 10, '20': 20, '30': 30, '40': 40} - E Full diff: - E - {'0': 0, '10': 10, '20': 20, '30': 30, '40': 40} - E ? - - - - - - - - - E + {'0': 0, '1': 1, '2': 2, '3': 3, '4': 4} + E ... + E + E ...Full output truncated (16 lines hidden), use '-vv' to show test_verbosity_example.py:14: AssertionError ___________________________ test_long_text_fail ____________________________ @@ -215,12 +236,20 @@ Now if we increase verbosity even more: fruits2 = ["banana", "apple", "orange", "melon", "kiwi"] > assert fruits1 == fruits2 E AssertionError: assert ['banana', 'apple', 'grapes', 'melon', 'kiwi'] == ['banana', 'apple', 'orange', 'melon', 'kiwi'] + E E At index 2 diff: 'grapes' != 'orange' + E E Full diff: - E - ['banana', 'apple', 'orange', 'melon', 'kiwi'] - E ? ^ ^^ - E + ['banana', 'apple', 'grapes', 'melon', 'kiwi'] - E ? ^ ^ + + E [ + E 'banana', + E 'apple', + E - 'orange', + E ? ^ ^^ + E + 'grapes', + E ? ^ ^ + + E 'melon', + E 'kiwi', + E ] test_verbosity_example.py:8: AssertionError ____________________________ test_numbers_fail _____________________________ @@ -230,16 +259,30 @@ Now if we increase verbosity even more: number_to_text2 = {str(x * 10): x * 10 for x in range(5)} > assert number_to_text1 == number_to_text2 E AssertionError: assert {'0': 0, '1': 1, '2': 2, '3': 3, '4': 4} == {'0': 0, '10': 10, '20': 20, '30': 30, '40': 40} + E E Common items: E {'0': 0} E Left contains 4 more items: E {'1': 1, '2': 2, '3': 3, '4': 4} E Right contains 4 more items: E {'10': 10, '20': 20, '30': 30, '40': 40} + E E Full diff: - E - {'0': 0, '10': 10, '20': 20, '30': 30, '40': 40} - E ? - - - - - - - - - E + {'0': 0, '1': 1, '2': 2, '3': 3, '4': 4} + E { + E '0': 0, + E - '10': 10, + E ? - - + E + '1': 1, + E - '20': 20, + E ? - - + E + '2': 2, + E - '30': 30, + E ? - - + E + '3': 3, + E - '40': 40, + E ? - - + E + '4': 4, + E } test_verbosity_example.py:14: AssertionError ___________________________ test_long_text_fail ____________________________ @@ -251,9 +294,47 @@ Now if we increase verbosity even more: test_verbosity_example.py:19: AssertionError ========================= short test summary info ========================== - FAILED test_verbosity_example.py::test_words_fail - AssertionError: asser... - FAILED test_verbosity_example.py::test_numbers_fail - AssertionError: ass... - FAILED test_verbosity_example.py::test_long_text_fail - AssertionError: a... + FAILED test_verbosity_example.py::test_words_fail - AssertionError: assert ['banana', 'apple', 'grapes', 'melon', 'kiwi'] == ['banana', 'apple', 'orange', 'melon', 'kiwi'] + + At index 2 diff: 'grapes' != 'orange' + + Full diff: + [ + 'banana', + 'apple', + - 'orange', + ? ^ ^^ + + 'grapes', + ? ^ ^ + + 'melon', + 'kiwi', + ] + FAILED test_verbosity_example.py::test_numbers_fail - AssertionError: assert {'0': 0, '1': 1, '2': 2, '3': 3, '4': 4} == {'0': 0, '10': 10, '20': 20, '30': 30, '40': 40} + + Common items: + {'0': 0} + Left contains 4 more items: + {'1': 1, '2': 2, '3': 3, '4': 4} + Right contains 4 more items: + {'10': 10, '20': 20, '30': 30, '40': 40} + + Full diff: + { + '0': 0, + - '10': 10, + ? - - + + '1': 1, + - '20': 20, + ? - - + + '2': 2, + - '30': 30, + ? - - + + '3': 3, + - '40': 40, + ? - - + + '4': 4, + } + FAILED test_verbosity_example.py::test_long_text_fail - AssertionError: assert 'hello world' in 'Lorem ipsum dolor sit amet Lorem ipsum dolor sit amet Lorem ipsum dolor sit amet Lorem ipsum dolor sit amet Lorem ipsum dolor sit amet Lorem ipsum dolor sit amet Lorem ipsum dolor sit amet Lorem ipsum dolor sit amet Lorem ipsum dolor sit amet Lorem ipsum dolor sit amet ' ======================= 3 failed, 1 passed in 0.12s ======================== Notice now that: @@ -270,6 +351,22 @@ situations, for example you are shown even fixtures that start with ``_`` if you Using higher verbosity levels (``-vvv``, ``-vvvv``, ...) is supported, but has no effect in pytest itself at the moment, however some plugins might make use of higher verbosity. +.. _`pytest.fine_grained_verbosity`: + +Fine-grained verbosity +~~~~~~~~~~~~~~~~~~~~~~ + +In addition to specifying the application wide verbosity level, it is possible to control specific aspects independently. +This is done by setting a verbosity level in the configuration file for the specific aspect of the output. + +:confval:`verbosity_assertions`: Controls how verbose the assertion output should be when pytest is executed. Running +``pytest --no-header`` with a value of ``2`` would have the same output as the previous example, but each test inside +the file is shown by a single character in the output. + +:confval:`verbosity_test_cases`: Controls how verbose the test execution output should be when pytest is executed. +Running ``pytest --no-header`` with a value of ``2`` would have the same output as the first verbosity example, but each +test inside the file gets its own line in the output. + .. _`pytest.detailed_failed_tests_usage`: Producing a detailed summary report @@ -324,7 +421,7 @@ Example: $ pytest -ra =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-7.x.y, pluggy-1.x.y + platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y rootdir: /home/sweet/project collected 6 items @@ -347,10 +444,11 @@ Example: E assert 0 test_example.py:14: AssertionError + ================================= XPASSES ================================== ========================= short test summary info ========================== SKIPPED [1] test_example.py:22: skipping this test - XFAIL test_example.py::test_xfail - reason: xfailing this test - XPASS test_example.py::test_xpass always xfail + XFAIL test_example.py::test_xfail - xfailing this test + XPASS test_example.py::test_xpass - always xfail ERROR test_example.py::test_error - assert 0 FAILED test_example.py::test_fail - assert 0 == 1 failed, 1 passed, 1 skipped, 1 xfailed, 1 xpassed, 1 error in 0.12s === @@ -380,7 +478,7 @@ More than one character can be used, so for example to only see failed and skipp $ pytest -rfs =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-7.x.y, pluggy-1.x.y + platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y rootdir: /home/sweet/project collected 6 items @@ -415,7 +513,7 @@ captured output: $ pytest -rpP =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-7.x.y, pluggy-1.x.y + platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y rootdir: /home/sweet/project collected 6 items @@ -446,28 +544,32 @@ captured output: PASSED test_example.py::test_ok == 1 failed, 1 passed, 1 skipped, 1 xfailed, 1 xpassed, 1 error in 0.12s === -Creating resultlog format files --------------------------------------------------- +.. note:: -To create plain-text machine-readable result files you can issue: + By default, parametrized variants of skipped tests are grouped together if + they share the same skip reason. You can use ``--no-fold-skipped`` to print each skipped test separately. -.. code-block:: bash - pytest --resultlog=path +.. _truncation-params: -and look at the content at the ``path`` location. Such files are used e.g. -by the `PyPy-test`_ web page to show test results over several revisions. +Modifying truncation limits +-------------------------------------------------- -.. warning:: +.. versionadded: 8.4 - This option is rarely used and is scheduled for removal in pytest 6.0. +Default truncation limits are 8 lines or 640 characters, whichever comes first. +To set custom truncation limits you can use following ``pytest.ini`` file options: - If you use this option, consider using the new `pytest-reportlog `__ plugin instead. +.. code-block:: ini - See :ref:`the deprecation docs ` for more information. + [pytest] + truncation_limit_lines = 10 + truncation_limit_chars = 90 +That will cause pytest to truncate the assertions to 10 lines or 90 characters, whichever comes first. -.. _`PyPy-test`: http://buildbot.pypy.org/summary +Setting both :confval:`truncation_limit_lines` and :confval:`truncation_limit_chars` to ``0`` will disable the truncation. +However, setting only one of those values will disable one truncation mode, but will leave the other one intact. Creating JUnitXML format files @@ -478,7 +580,7 @@ integration servers, use this invocation: .. code-block:: bash - pytest --junitxml=path + pytest --junit-xml=path to create an XML file at ``path``. diff --git a/doc/en/how-to/parametrize.rst b/doc/en/how-to/parametrize.rst index a0c9968428c..fe186146434 100644 --- a/doc/en/how-to/parametrize.rst +++ b/doc/en/how-to/parametrize.rst @@ -29,10 +29,6 @@ pytest enables test parametrization at several levels: .. regendoc: wipe - - - Several improvements. - The builtin :ref:`pytest.mark.parametrize ref` decorator enables parametrization of arguments for a test function. Here is a typical example of a test function that implements checking that a certain input leads @@ -56,7 +52,7 @@ them in turn: $ pytest =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-7.x.y, pluggy-1.x.y + platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y rootdir: /home/sweet/project collected 3 items @@ -167,7 +163,7 @@ Let's run this: $ pytest =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-7.x.y, pluggy-1.x.y + platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y rootdir: /home/sweet/project collected 3 items @@ -244,6 +240,13 @@ command line option and the parametrization of our test function: if "stringinput" in metafunc.fixturenames: metafunc.parametrize("stringinput", metafunc.config.getoption("stringinput")) +.. note:: + + The :hook:`pytest_generate_tests` hook can also be implemented directly in a test + module or inside a test class; unlike other hooks, pytest will discover it there + as well. Other hooks must live in a :ref:`conftest.py ` or a plugin. + See :ref:`writinghooks`. + If we now pass two stringinput values, our test will run twice: .. code-block:: pytest @@ -285,7 +288,7 @@ list: $ pytest -q -rs test_strings.py s [100%] ========================= short test summary info ========================== - SKIPPED [1] test_strings.py: got empty parameter set ['stringinput'], function test_valid_string at /home/sweet/project/test_strings.py:2 + SKIPPED [1] test_strings.py: got empty parameter set for (stringinput) 1 skipped in 0.12s Note that when calling ``metafunc.parametrize`` multiple times with different parameter sets, all parameter names across diff --git a/doc/en/how-to/plugins.rst b/doc/en/how-to/plugins.rst index 7d5bcd85a31..b2cb1cda5c3 100644 --- a/doc/en/how-to/plugins.rst +++ b/doc/en/how-to/plugins.rst @@ -133,4 +133,32 @@ CI server), you can set ``PYTEST_ADDOPTS`` environment variable to See :ref:`findpluginname` for how to obtain the name of a plugin. -.. _`builtin plugins`: +.. _`disable_plugin_autoload`: + +Disabling plugins from autoloading +---------------------------------- + +If you want to disable plugins from loading automatically, instead of requiring you to +manually specify each plugin with ``-p`` or :envvar:`PYTEST_PLUGINS`, you can use ``--disable-plugin-autoload`` or :envvar:`PYTEST_DISABLE_PLUGIN_AUTOLOAD`. + +.. code-block:: bash + + export PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 + export PYTEST_PLUGINS=NAME,NAME2 + pytest + +.. code-block:: bash + + pytest --disable-plugin-autoload -p NAME,NAME2 + +.. code-block:: ini + + [pytest] + addopts = + --disable-plugin-autoload + -p NAME + -p NAME2 + +.. versionadded:: 8.4 + + The ``--disable-plugin-autoload`` command-line flag. diff --git a/doc/en/how-to/skipping.rst b/doc/en/how-to/skipping.rst index 1fc6f5e0889..09a19766f99 100644 --- a/doc/en/how-to/skipping.rst +++ b/doc/en/how-to/skipping.rst @@ -47,8 +47,7 @@ which may be passed an optional ``reason``: .. code-block:: python @pytest.mark.skip(reason="no way of currently testing this") - def test_the_unknown(): - ... + def test_the_unknown(): ... Alternatively, it is also possible to skip imperatively during test execution or setup @@ -93,8 +92,7 @@ when run on an interpreter earlier than Python3.10: @pytest.mark.skipif(sys.version_info < (3, 10), reason="requires python3.10 or higher") - def test_function(): - ... + def test_function(): ... If the condition evaluates to ``True`` during collection, the test function will be skipped, with the specified reason appearing in the summary when using ``-rs``. @@ -112,8 +110,7 @@ You can share ``skipif`` markers between modules. Consider this test module: @minversion - def test_function(): - ... + def test_function(): ... You can import the marker and reuse it in another test module: @@ -124,8 +121,7 @@ You can import the marker and reuse it in another test module: @minversion - def test_anotherfunction(): - ... + def test_anotherfunction(): ... For larger test suites it's usually a good idea to have one file where you define the markers which you then consistently apply @@ -232,8 +228,7 @@ expect a test to fail: .. code-block:: python @pytest.mark.xfail - def test_function(): - ... + def test_function(): ... This test will run but no traceback will be reported when it fails. Instead, terminal reporting will list it in the "expected to fail" (``XFAIL``) or "unexpectedly @@ -275,8 +270,7 @@ that condition as the first parameter: .. code-block:: python @pytest.mark.xfail(sys.platform == "win32", reason="bug in a 3rd party library") - def test_function(): - ... + def test_function(): ... Note that you have to pass a reason as well (see the parameter description at :ref:`pytest.mark.xfail ref`). @@ -289,8 +283,7 @@ You can specify the motive of an expected failure with the ``reason`` parameter: .. code-block:: python @pytest.mark.xfail(reason="known parser issue") - def test_function(): - ... + def test_function(): ... ``raises`` parameter @@ -302,8 +295,7 @@ a single exception, or a tuple of exceptions, in the ``raises`` argument. .. code-block:: python @pytest.mark.xfail(raises=RuntimeError) - def test_function(): - ... + def test_function(): ... Then the test will be reported as a regular failure if it fails with an exception not mentioned in ``raises``. @@ -317,8 +309,7 @@ even executed, use the ``run`` parameter as ``False``: .. code-block:: python @pytest.mark.xfail(run=False) - def test_function(): - ... + def test_function(): ... This is specially useful for xfailing tests that are crashing the interpreter and should be investigated later. @@ -334,8 +325,7 @@ You can change this by setting the ``strict`` keyword-only parameter to ``True`` .. code-block:: python @pytest.mark.xfail(strict=True) - def test_function(): - ... + def test_function(): ... This will make ``XPASS`` ("unexpectedly passing") results from this test to fail the test suite. diff --git a/doc/en/how-to/tmp_path.rst b/doc/en/how-to/tmp_path.rst index 792933dd87e..d19950431e5 100644 --- a/doc/en/how-to/tmp_path.rst +++ b/doc/en/how-to/tmp_path.rst @@ -8,9 +8,8 @@ How to use temporary directories and files in tests The ``tmp_path`` fixture ------------------------ -You can use the ``tmp_path`` fixture which will -provide a temporary directory unique to the test invocation, -created in the `base temporary directory`_. +You can use the ``tmp_path`` fixture which will provide a temporary directory +unique to each test function. ``tmp_path`` is a :class:`pathlib.Path` object. Here is an example test usage: @@ -24,8 +23,8 @@ created in the `base temporary directory`_. d = tmp_path / "sub" d.mkdir() p = d / "hello.txt" - p.write_text(CONTENT) - assert p.read_text() == CONTENT + p.write_text(CONTENT, encoding="utf-8") + assert p.read_text(encoding="utf-8") == CONTENT assert len(list(tmp_path.iterdir())) == 1 assert 0 @@ -36,7 +35,7 @@ Running this would result in a passed test except for the last $ pytest test_tmp_path.py =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-7.x.y, pluggy-1.x.y + platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y rootdir: /home/sweet/project collected 1 item @@ -51,8 +50,8 @@ Running this would result in a passed test except for the last d = tmp_path / "sub" d.mkdir() p = d / "hello.txt" - p.write_text(CONTENT) - assert p.read_text() == CONTENT + p.write_text(CONTENT, encoding="utf-8") + assert p.read_text(encoding="utf-8") == CONTENT assert len(list(tmp_path.iterdir())) == 1 > assert 0 E assert 0 @@ -62,6 +61,11 @@ Running this would result in a passed test except for the last FAILED test_tmp_path.py::test_create_file - assert 0 ============================ 1 failed in 0.12s ============================= +By default, ``pytest`` retains the temporary directory for the last 3 ``pytest`` +invocations. Concurrent invocations of the same test function are supported by +configuring the base temporary directory to be unique for each concurrent +run. See `temporary directory location and retention`_ for details. + .. _`tmp_path_factory example`: The ``tmp_path_factory`` fixture @@ -100,7 +104,7 @@ See :ref:`tmp_path_factory API ` for details. .. _tmpdir: The ``tmpdir`` and ``tmpdir_factory`` fixtures ---------------------------------------------------- +---------------------------------------------- The ``tmpdir`` and ``tmpdir_factory`` fixtures are similar to ``tmp_path`` and ``tmp_path_factory``, but use/return legacy `py.path.local`_ objects @@ -124,35 +128,55 @@ See :fixture:`tmpdir ` :fixture:`tmpdir_factory ` API for details. -.. _`base temporary directory`: +.. _`temporary directory location and retention`: + +Temporary directory location and retention +------------------------------------------ + +The temporary directories, +as returned by the :fixture:`tmp_path` and (now deprecated) :fixture:`tmpdir` fixtures, +are automatically created under a base temporary directory, +in a structure that depends on the ``--basetemp`` option: + +- By default (when the ``--basetemp`` option is not set), + the temporary directories will follow this template: + + .. code-block:: text + + {temproot}/pytest-of-{user}/pytest-{num}/{testname}/ + + where: -The default base temporary directory ------------------------------------------------ + - ``{temproot}`` is the system temporary directory + as determined by :py:func:`tempfile.gettempdir`. + It can be overridden by the :envvar:`PYTEST_DEBUG_TEMPROOT` environment variable. + - ``{user}`` is the user name running the tests, + - ``{num}`` is a number that is incremented with each test suite run + - ``{testname}`` is a sanitized version of :py:attr:`the name of the current test <_pytest.nodes.Node.name>`. -Temporary directories are by default created as sub-directories of -the system temporary directory. The base name will be ``pytest-NUM`` where -``NUM`` will be incremented with each test run. -By default, entries older than 3 temporary directories will be removed. -This behavior can be configured with :confval:`tmp_path_retention_count` and -:confval:`tmp_path_retention_policy`. + The auto-incrementing ``{num}`` placeholder provides a basic retention feature + and avoids that existing results of previous test runs are blindly removed. + By default, the last 3 temporary directories are kept, + but this behavior can be configured with + :confval:`tmp_path_retention_count` and :confval:`tmp_path_retention_policy`. -Using the ``--basetemp`` -option will remove the directory before every run, effectively meaning the temporary directories -of only the most recent run will be kept. +- When the ``--basetemp`` option is used (e.g. ``pytest --basetemp=mydir``), + it will be used directly as base temporary directory: -You can override the default temporary directory setting like this: + .. code-block:: text -.. code-block:: bash + {basetemp}/{testname}/ - pytest --basetemp=mydir + Note that there is no retention feature in this case: + only the results of the most recent run will be kept. -.. warning:: + .. warning:: - The contents of ``mydir`` will be completely removed, so make sure to use a directory - for that purpose only. + The directory given to ``--basetemp`` will be cleared blindly before each test run, + so make sure to use a directory for that purpose only. When distributing tests on the local machine using ``pytest-xdist``, care is taken to -automatically configure a basetemp directory for the sub processes such that all temporary -data lands below a single per-test run basetemp directory. +automatically configure a `basetemp` directory for the sub processes such that all temporary +data lands below a single per-test run temporary directory. .. _`py.path.local`: https://py.readthedocs.io/en/latest/path.html diff --git a/doc/en/how-to/unittest.rst b/doc/en/how-to/unittest.rst index 37caf6e9fb7..ba98b366d04 100644 --- a/doc/en/how-to/unittest.rst +++ b/doc/en/how-to/unittest.rst @@ -109,7 +109,7 @@ achieves this by receiving a special ``request`` object which gives access to :ref:`the requesting test context ` such as the ``cls`` attribute, denoting the class from which the fixture is used. This architecture de-couples fixture writing from actual test -code and allows re-use of the fixture by a minimal reference, the fixture +code and allows reuse of the fixture by a minimal reference, the fixture name. So let's write an actual ``unittest.TestCase`` class using our fixture definition: @@ -140,7 +140,7 @@ the ``self.db`` values in the traceback: $ pytest test_unittest_db.py =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-7.x.y, pluggy-1.x.y + platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y rootdir: /home/sweet/project collected 2 items @@ -154,6 +154,7 @@ the ``self.db`` values in the traceback: def test_method1(self): assert hasattr(self, "db") > assert 0, self.db # fail for demo purposes + ^^^^^^^^^^^^^^^^^ E AssertionError: .DummyDB object at 0xdeadbeef0001> E assert 0 @@ -164,6 +165,7 @@ the ``self.db`` values in the traceback: def test_method2(self): > assert 0, self.db # fail for demo purposes + ^^^^^^^^^^^^^^^^^ E AssertionError: .DummyDB object at 0xdeadbeef0001> E assert 0 @@ -207,10 +209,10 @@ creation of a per-test temporary directory: @pytest.fixture(autouse=True) def initdir(self, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) # change to pytest-provided temporary directory - tmp_path.joinpath("samplefile.ini").write_text("# testdata") + tmp_path.joinpath("samplefile.ini").write_text("# testdata", encoding="utf-8") def test_method(self): - with open("samplefile.ini") as f: + with open("samplefile.ini", encoding="utf-8") as f: s = f.read() assert "testdata" in s diff --git a/doc/en/how-to/usage.rst b/doc/en/how-to/usage.rst index 8e2dd86736f..0e0a0310fd8 100644 --- a/doc/en/how-to/usage.rst +++ b/doc/en/how-to/usage.rst @@ -17,7 +17,8 @@ in the current directory and its subdirectories. More generally, pytest follows Specifying which tests to run ------------------------------ -Pytest supports several ways to run and select tests from the command-line. +Pytest supports several ways to run and select tests from the command-line or from a file +(see below for :ref:`reading arguments from file `). **Run tests in a module** @@ -44,31 +45,50 @@ Use ``""`` instead of ``''`` in expression when running this on Windows .. _nodeids: -**Run tests by node ids** +**Run tests by collection arguments** -Each collected test is assigned a unique ``nodeid`` which consist of the module filename followed -by specifiers like class names, function names and parameters from parametrization, separated by ``::`` characters. +Pass the module filename relative to the working directory, followed by specifiers like the class name and function name +separated by ``::`` characters, and parameters from parameterization enclosed in ``[]``. To run a specific test within a module: .. code-block:: bash - pytest test_mod.py::test_func + pytest tests/test_mod.py::test_func +To run all tests in a class: -Another example specifying a test method in the command line: +.. code-block:: bash + + pytest tests/test_mod.py::TestClass + +Specifying a specific test method: + +.. code-block:: bash + + pytest tests/test_mod.py::TestClass::test_method + +Specifying a specific parametrization of a test: .. code-block:: bash - pytest test_mod.py::TestClass::test_method + pytest tests/test_mod.py::test_func[x1,y2] **Run tests by marker expressions** +To run all tests which are decorated with the ``@pytest.mark.slow`` decorator: + .. code-block:: bash pytest -m slow -Will run all tests which are decorated with the ``@pytest.mark.slow`` decorator. + +To run all tests which are decorated with the annotated ``@pytest.mark.slow(phase=1)`` decorator, +with the ``phase`` keyword argument set to ``1``: + +.. code-block:: bash + + pytest -m "slow(phase=1)" For more information see :ref:`marks `. @@ -80,6 +100,28 @@ For more information see :ref:`marks `. This will import ``pkg.testing`` and use its filesystem location to find and run tests from. +.. _args-from-file: + +**Read arguments from file** + +.. versionadded:: 8.2 + +All of the above can be read from a file using the ``@`` prefix: + +.. code-block:: bash + + pytest @tests_to_run.txt + +where ``tests_to_run.txt`` contains an entry per line, e.g.: + +.. code-block:: text + + tests/test_file.py + tests/test_mod.py::test_func[x1,y2] + tests/test_mod.py::TestClass + -m slow + +This file can also be generated using ``pytest --collect-only -q`` and modified as needed. Getting help on version, option names, environment variables -------------------------------------------------------------- @@ -120,7 +162,7 @@ You can early-load plugins (internal and external) explicitly in the command-lin The option receives a ``name`` parameter, which can be: * A full module dotted name, for example ``myproject.plugins``. This dotted name must be importable. -* The entry-point name of a plugin. This is the name passed to ``setuptools`` when the plugin is +* The entry-point name of a plugin. This is the name passed to ``importlib`` when the plugin is registered. For example to early-load the :pypi:`pytest-cov` plugin you can use:: pytest -p pytest_cov @@ -173,7 +215,8 @@ You can invoke ``pytest`` from Python code directly: this acts as if you would call "pytest" from the command line. It will not raise :class:`SystemExit` but return the :ref:`exit code ` instead. -You can pass in options and arguments: +If you don't pass it any arguments, ``main`` reads the arguments from the command line arguments of the process (:data:`sys.argv`), which may be undesirable. +You can pass in options and arguments explicitly: .. code-block:: python diff --git a/doc/en/how-to/writing_hook_functions.rst b/doc/en/how-to/writing_hook_functions.rst index 71379016f2e..cd18301ce84 100644 --- a/doc/en/how-to/writing_hook_functions.rst +++ b/doc/en/how-to/writing_hook_functions.rst @@ -56,23 +56,17 @@ The remaining hook functions will not be called in this case. .. _`hookwrapper`: -hookwrapper: executing around other hooks +hook wrappers: executing around other hooks ------------------------------------------------- -.. currentmodule:: _pytest.core - - - pytest plugins can implement hook wrappers which wrap the execution of other hook implementations. A hook wrapper is a generator function which yields exactly once. When pytest invokes hooks it first executes hook wrappers and passes the same arguments as to the regular hooks. At the yield point of the hook wrapper pytest will execute the next hook -implementations and return their result to the yield point in the form of -a :py:class:`Result ` instance which encapsulates a result or -exception info. The yield point itself will thus typically not raise -exceptions (unless there are bugs). +implementations and return their result to the yield point, or will +propagate an exception if they raised. Here is an example definition of a hook wrapper: @@ -81,26 +75,35 @@ Here is an example definition of a hook wrapper: import pytest - @pytest.hookimpl(hookwrapper=True) + @pytest.hookimpl(wrapper=True) def pytest_pyfunc_call(pyfuncitem): do_something_before_next_hook_executes() - outcome = yield - # outcome.excinfo may be None or a (cls, val, tb) tuple + # If the outcome is an exception, will raise the exception. + res = yield + + new_res = post_process_result(res) - res = outcome.get_result() # will raise if outcome was exception + # Override the return value to the plugin system. + return new_res - post_process_result(res) +The hook wrapper needs to return a result for the hook, or raise an exception. - outcome.force_result(new_res) # to override the return value to the plugin system +In many cases, the wrapper only needs to perform tracing or other side effects +around the actual hook implementations, in which case it can return the result +value of the ``yield``. The simplest (though useless) hook wrapper is +``return (yield)``. -Note that hook wrappers don't return results themselves, they merely -perform tracing or other side effects around the actual hook implementations. -If the result of the underlying hook is a mutable object, they may modify -that result but it's probably better to avoid it. +In other cases, the wrapper wants the adjust or adapt the result, in which case +it can return a new value. If the result of the underlying hook is a mutable +object, the wrapper may modify that result, but it's probably better to avoid it. + +If the hook implementation failed with an exception, the wrapper can handle that +exception using a ``try-catch-finally`` around the ``yield``, by propagating it, +suppressing it, or raising a different exception entirely. For more information, consult the -:ref:`pluggy documentation about hookwrappers `. +:ref:`pluggy documentation about hook wrappers `. .. _plugin-hookorder: @@ -130,11 +133,14 @@ after others, i.e. the position in the ``N``-sized list of functions: # Plugin 3 - @pytest.hookimpl(hookwrapper=True) + @pytest.hookimpl(wrapper=True) def pytest_collection_modifyitems(items): # will execute even before the tryfirst one above! - outcome = yield - # will execute after all non-hookwrappers executed + try: + return (yield) + finally: + # will execute after all non-wrappers executed + ... Here is the order of execution: @@ -149,13 +155,13 @@ Here is the order of execution: Plugin1). 4. Plugin3's pytest_collection_modifyitems then executing the code after the yield - point. The yield receives a :py:class:`Result ` instance which encapsulates - the result from calling the non-wrappers. Wrappers shall not modify the result. + point. The yield receives the result from calling the non-wrappers, or raises + an exception if the non-wrappers raised. -It's possible to use ``tryfirst`` and ``trylast`` also in conjunction with -``hookwrapper=True`` in which case it will influence the ordering of hookwrappers -among each other. +It's possible to use ``tryfirst`` and ``trylast`` also on hook wrappers +in which case it will influence the ordering of hook wrappers among each other. +.. _`declaringhooks`: Declaring new hooks ------------------------ @@ -165,13 +171,11 @@ Declaring new hooks This is a quick overview on how to add new hooks and how they work in general, but a more complete overview can be found in `the pluggy documentation `__. -.. currentmodule:: _pytest.hookspec - Plugins and ``conftest.py`` files may declare new hooks that can then be implemented by other plugins in order to alter behaviour or interact with the new plugin: -.. autofunction:: pytest_addhooks +.. autofunction:: _pytest.hookspec.pytest_addhooks :noindex: Hooks are usually declared as do-nothing functions that contain only @@ -231,6 +235,12 @@ Example: """ print(config.hook) +.. note:: + + Unlike other hooks, the :hook:`pytest_generate_tests` hook is also discovered when + defined inside a test module or test class. Other hooks must live in + :ref:`conftest.py plugins ` or external plugins. + See :ref:`parametrize-basics` and the :ref:`hook-reference`. .. _`addoptionhooks`: diff --git a/doc/en/how-to/writing_plugins.rst b/doc/en/how-to/writing_plugins.rst index 6f321110718..1bba9644649 100644 --- a/doc/en/how-to/writing_plugins.rst +++ b/doc/en/how-to/writing_plugins.rst @@ -16,8 +16,8 @@ reporting by calling :ref:`well specified hooks ` of the followi * builtin plugins: loaded from pytest's internal ``_pytest`` directory. -* :ref:`external plugins `: modules discovered through - `setuptools entry points`_ +* :ref:`external plugins `: installed third-party modules discovered + through :ref:`entry points ` in their packaging metadata * `conftest.py plugins`_: modules auto-discovered in test directories @@ -42,28 +42,24 @@ Plugin discovery order at tool startup 3. by scanning the command line for the ``-p name`` option and loading the specified plugin. This happens before normal command-line parsing. -4. by loading all plugins registered through `setuptools entry points`_. +4. by loading all plugins registered through installed third-party package + :ref:`entry points `, unless the + :envvar:`PYTEST_DISABLE_PLUGIN_AUTOLOAD` environment variable is set. 5. by loading all plugins specified through the :envvar:`PYTEST_PLUGINS` environment variable. -6. by loading all :file:`conftest.py` files as inferred by the command line - invocation: +6. by loading all "initial ":file:`conftest.py` files: - - if no test paths are specified, use the current dir as a test path - - if exists, load ``conftest.py`` and ``test*/conftest.py`` relative - to the directory part of the first test path. After the ``conftest.py`` - file is loaded, load all plugins specified in its - :globalvar:`pytest_plugins` variable if present. + - determine the test paths: specified on the command line, otherwise in + :confval:`testpaths` if defined and running from the rootdir, otherwise the + current dir + - for each test path, load ``conftest.py`` and ``test*/conftest.py`` relative + to the directory part of the test path, if exist. Before a ``conftest.py`` + file is loaded, load ``conftest.py`` files in all of its parent directories. + After a ``conftest.py`` file is loaded, recursively load all plugins specified + in its :globalvar:`pytest_plugins` variable if present. - Note that pytest does not find ``conftest.py`` files in deeper nested - sub directories at tool startup. It is usually a good idea to keep - your ``conftest.py`` file in the top level test or project root directory. -7. by recursively loading all plugins specified by the - :globalvar:`pytest_plugins` variable in ``conftest.py`` files. - - -.. _`pytest/plugin`: http://bitbucket.org/pytest-dev/pytest/src/tip/pytest/plugin/ .. _`conftest.py plugins`: .. _`localplugin`: .. _`local conftest plugins`: @@ -108,9 +104,9 @@ Here is how you might run it:: See also: :ref:`pythonpath`. .. note:: - Some hooks should be implemented only in plugins or conftest.py files situated at the - tests root directory due to how pytest discovers plugins during startup, - see the documentation of each hook for details. + Some hooks cannot be implemented in conftest.py files which are not + :ref:`initial ` due to how pytest discovers plugins during + startup. See the documentation of each hook for details. Writing your own plugin ----------------------- @@ -148,7 +144,8 @@ Making your plugin installable by others If you want to make your plugin externally available, you may define a so-called entry point for your distribution so that ``pytest`` finds your plugin module. Entry points are -a feature that is provided by :std:doc:`setuptools `. +a feature that is provided by :std:doc:`packaging tools +`. pytest looks up the ``pytest11`` entrypoint to discover its plugins, thus you can make your plugin available by defining @@ -271,8 +268,9 @@ of the variable will also be loaded as plugins, and so on. tests root directory is deprecated, and will raise a warning. This mechanism makes it easy to share fixtures within applications or even -external applications without the need to create external plugins using -the ``setuptools``'s entry point technique. +external applications without the need to create external plugins using the +:std:doc:`entry point packaging metadata +` technique. Plugins imported by :globalvar:`pytest_plugins` will also automatically be marked for assertion rewriting (see :func:`pytest.register_assert_rewrite`). @@ -448,7 +446,7 @@ in our ``pytest.ini`` to tell pytest where to look for example files. $ pytest =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-7.x.y, pluggy-1.x.y + platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y rootdir: /home/sweet/project configfile: pytest.ini collected 2 items diff --git a/doc/en/index.rst b/doc/en/index.rst index de07831ac14..2b58bebc20f 100644 --- a/doc/en/index.rst +++ b/doc/en/index.rst @@ -1,28 +1,55 @@ -:orphan: - -.. sidebar:: Next Open Trainings +.. _features: - - `pytest tips and tricks for a better testsuite `_, at `Europython 2023 `_, July 18th (3h), Prague/Remote - - `Professional Testing with Python `_, via `Python Academy `_, March 5th to 7th 2024 (3 day in-depth training), Leipzig/Remote +.. sidebar:: **Next Open Trainings and Events** - Also see :doc:`previous talks and blogposts `. + - `Testen mit pytest `_ (German), via `Letsboot `_ (3 day in-depth training), **October 29th -- 31st**, Zurich (CH) + - `Professional Testing with Python `_, via `Python Academy `_ (3 day in-depth training), **March 3th -- 5th 2026**, Leipzig (DE) / Remote -.. _features: + Also see :doc:`previous talks and blogposts ` pytest: helps you write better programs ======================================= +.. toctree:: + :hidden: + + getting-started + how-to/index + reference/index + explanation/index + example/index + +.. toctree:: + :caption: About the project + :hidden: + + changelog + contributing + backwards-compatibility + sponsor + tidelift + license + contact + +.. toctree:: + :caption: Useful links + :hidden: + + pytest @ PyPI + pytest @ GitHub + Issue Tracker + PDF Documentation + .. module:: pytest The ``pytest`` framework makes it easy to write small, readable tests, and can scale to support complex functional testing for applications and libraries. -``pytest`` requires: Python 3.7+ or PyPy3. +``pytest`` requires: Python 3.8+ or PyPy3. **PyPI package name**: :pypi:`pytest` - A quick example --------------- @@ -43,7 +70,7 @@ To execute it: $ pytest =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-7.x.y, pluggy-1.x.y + platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y rootdir: /home/sweet/project collected 1 item @@ -75,17 +102,17 @@ Features - :ref:`Modular fixtures ` for managing small or parametrized long-lived test resources -- Can run :ref:`unittest ` (including trial) and :ref:`nose ` test suites out of the box +- Can run :ref:`unittest ` (including trial) test suites out of the box -- Python 3.7+ or PyPy 3 +- Python 3.8+ or PyPy 3 -- Rich plugin architecture, with over 800+ :ref:`external plugins ` and thriving community +- Rich plugin architecture, with over 1300+ :ref:`external plugins ` and thriving community Documentation ------------- -* :ref:`Get started ` - install pytest and grasp its basics just twenty minutes +* :ref:`Get started ` - install pytest and grasp its basics in just twenty minutes * :ref:`How-to guides ` - step-by-step guides, covering a vast range of use-cases and needs * :ref:`Reference guides ` - includes the complete pytest API reference, lists of plugins and more * :ref:`Explanation ` - background, discussion of key topics, answers to higher-level questions diff --git a/doc/en/naming20.rst b/doc/en/naming20.rst index 5a81df2698d..11213066384 100644 --- a/doc/en/naming20.rst +++ b/doc/en/naming20.rst @@ -8,7 +8,7 @@ If you used older version of the ``py`` distribution (which included the py.test command line tool and Python name space) you accessed helpers and possibly collection classes through the ``py.test`` Python namespaces. The new ``pytest`` -Python module flaty provides the same objects, following +Python module flatly provides the same objects, following these renaming rules:: py.test.XYZ -> pytest.XYZ diff --git a/doc/en/reference/customize.rst b/doc/en/reference/customize.rst index b794d646b8e..373223ec913 100644 --- a/doc/en/reference/customize.rst +++ b/doc/en/reference/customize.rst @@ -90,7 +90,7 @@ and can also be used to hold pytest configuration if they have a ``[pytest]`` se setup.cfg ~~~~~~~~~ -``setup.cfg`` files are general purpose configuration files, used originally by :doc:`distutils `, and can also be used to hold pytest configuration +``setup.cfg`` files are general purpose configuration files, used originally by ``distutils`` (now deprecated) and :std:doc:`setuptools `, and can also be used to hold pytest configuration if they have a ``[tool:pytest]`` section. .. code-block:: ini @@ -177,13 +177,20 @@ Files will only be matched for configuration if: * ``tox.ini``: contains a ``[pytest]`` section. * ``setup.cfg``: contains a ``[tool:pytest]`` section. +Finally, a ``pyproject.toml`` file will be considered the ``configfile`` if no other match was found, in this case +even if it does not contain a ``[tool.pytest.ini_options]`` table (this was added in ``8.1``). + The files are considered in the order above. Options from multiple ``configfiles`` candidates are never merged - the first match wins. +The configuration file also determines the value of the ``rootpath``. + The :class:`Config ` object (accessible via hooks or through the :fixture:`pytestconfig` fixture) will subsequently carry these attributes: -- :attr:`config.rootpath `: the determined root directory, guaranteed to exist. +- :attr:`config.rootpath `: the determined root directory, guaranteed to exist. It is used as + a reference directory for constructing test addresses ("nodeids") and can be used also by plugins for storing + per-testrun information. - :attr:`config.inipath `: the determined ``configfile``, may be ``None`` (it is named ``inipath`` for historical reasons). @@ -193,9 +200,7 @@ will subsequently carry these attributes: versions of the older ``config.rootdir`` and ``config.inifile``, which have type ``py.path.local``, and still exist for backward compatibility. -The ``rootdir`` is used as a reference directory for constructing test -addresses ("nodeids") and can be used also by plugins for storing -per-testrun information. + Example: diff --git a/doc/en/reference/fixtures.rst b/doc/en/reference/fixtures.rst index 01f825222ea..566304d3330 100644 --- a/doc/en/reference/fixtures.rst +++ b/doc/en/reference/fixtures.rst @@ -11,9 +11,6 @@ Fixtures reference .. seealso:: :ref:`about-fixtures` .. seealso:: :ref:`how-to-fixtures` - -.. currentmodule:: _pytest.python - .. _`Dependency injection`: https://en.wikipedia.org/wiki/Dependency_injection @@ -35,6 +32,10 @@ Built-in fixtures :fixture:`capsys` Capture, as text, output to ``sys.stdout`` and ``sys.stderr``. + :fixture:`capteesys` + Capture in the same manner as :fixture:`capsys`, but also pass text + through according to ``--capture=``. + :fixture:`capsysbinary` Capture, as bytes, output to ``sys.stdout`` and ``sys.stderr``. @@ -42,7 +43,7 @@ Built-in fixtures Store and retrieve values across pytest runs. :fixture:`doctest_namespace` - Provide a dict injected into the docstests namespace. + Provide a dict injected into the doctests namespace. :fixture:`monkeypatch` Temporarily modify classes, functions, dictionaries, @@ -76,15 +77,13 @@ Built-in fixtures :class:`pathlib.Path` objects. :fixture:`tmpdir` - Provide a :class:`py.path.local` object to a temporary + Provide a `py.path.local `_ object to a temporary directory which is unique to each test function; replaced by :fixture:`tmp_path`. - .. _`py.path.local`: https://py.readthedocs.io/en/latest/path.html - :fixture:`tmpdir_factory` Make session-scoped temporary directories and return - :class:`py.path.local` objects; + ``py.path.local`` objects; replaced by :fixture:`tmp_path_factory`. @@ -98,7 +97,7 @@ Fixture availability is determined from the perspective of the test. A fixture is only available for tests to request if they are in the scope that fixture is defined in. If a fixture is defined inside a class, it can only be requested by tests inside that class. But if a fixture is defined inside the global scope of -the module, than every test in that module, even if it's defined inside a class, +the module, then every test in that module, even if it's defined inside a class, can request it. Similarly, a test can also only be affected by an autouse fixture if that test diff --git a/doc/en/reference/plugin_list.rst b/doc/en/reference/plugin_list.rst index c882130b03e..d0bf2083fe3 100644 --- a/doc/en/reference/plugin_list.rst +++ b/doc/en/reference/plugin_list.rst @@ -1,1303 +1,1712 @@ +.. Note this file is autogenerated by scripts/update-plugin-list.py - usually weekly via github action + .. _plugin-list: -Plugin List -=========== +Pytest Plugin List +================== -PyPI projects that match "pytest-\*" are considered plugins and are listed -automatically together with a manually-maintained list in `the source -code `_. +Below is an automated compilation of ``pytest``` plugins available on `PyPI `_. +It includes PyPI projects whose names begin with ``pytest-`` or ``pytest_`` and a handful of manually selected projects. Packages classified as inactive are excluded. +For detailed insights into how this list is generated, +please refer to `the update script `_. + +.. warning:: + + Please be aware that this list is not a curated collection of projects + and does not undergo a systematic review process. + It serves purely as an informational resource to aid in the discovery of ``pytest`` plugins. + + Do not presume any endorsement from the ``pytest`` project or its developers, + and always conduct your own quality assessment before incorporating any of these plugins into your own projects. + + .. The following conditional uses a different format for this list when creating a PDF, because otherwise the table gets far too wide for the page. -This list contains 1267 plugins. +This list contains 1641 plugins. .. only:: not latex - =============================================== ======================================================================================================================================================================================================== ============== ===================== ================================================ - name summary last release status requires - =============================================== ======================================================================================================================================================================================================== ============== ===================== ================================================ - :pypi:`logassert` Simple but powerful assertion and verification of logged lines. May 20, 2022 5 - Production/Stable N/A - :pypi:`pytest-abq` Pytest integration for the ABQ universal test runner. Apr 07, 2023 N/A N/A - :pypi:`pytest-abstracts` A contextmanager pytest fixture for handling multiple mock abstracts May 25, 2022 N/A N/A - :pypi:`pytest-accept` A pytest-plugin for updating doctest outputs Dec 21, 2022 N/A pytest (>=6,<8) - :pypi:`pytest-adaptavist` pytest plugin for generating test execution results within Jira Test Management (tm4j) Oct 13, 2022 N/A pytest (>=5.4.0) - :pypi:`pytest-addons-test` 用于测试pytest的插件 Aug 02, 2021 N/A pytest (>=6.2.4,<7.0.0) - :pypi:`pytest-adf` Pytest plugin for writing Azure Data Factory integration tests May 10, 2021 4 - Beta pytest (>=3.5.0) - :pypi:`pytest-adf-azure-identity` Pytest plugin for writing Azure Data Factory integration tests Mar 06, 2021 4 - Beta pytest (>=3.5.0) - :pypi:`pytest-ads-testplan` Azure DevOps Test Case reporting for pytest tests Sep 15, 2022 N/A N/A - :pypi:`pytest-agent` Service that exposes a REST API that can be used to interract remotely with Pytest. It is shipped with a dashboard that enables running tests in a more convenient way. Nov 25, 2021 N/A N/A - :pypi:`pytest-aggreport` pytest plugin for pytest-repeat that generate aggregate report of the same test cases with additional statistics details. Mar 07, 2021 4 - Beta pytest (>=6.2.2) - :pypi:`pytest-aio` Pytest plugin for testing async python code Feb 03, 2023 4 - Beta pytest - :pypi:`pytest-aiofiles` pytest fixtures for writing aiofiles tests with pyfakefs May 14, 2017 5 - Production/Stable N/A - :pypi:`pytest-aiogram` May 06, 2023 N/A N/A - :pypi:`pytest-aiohttp` Pytest plugin for aiohttp support Feb 12, 2022 4 - Beta pytest (>=6.1.0) - :pypi:`pytest-aiohttp-client` Pytest \`client\` fixture for the Aiohttp Jan 10, 2023 N/A pytest (>=7.2.0,<8.0.0) - :pypi:`pytest-aiomoto` pytest-aiomoto Nov 09, 2022 N/A pytest (>=7.0,<8.0) - :pypi:`pytest-aioresponses` py.test integration for aioresponses Jul 29, 2021 4 - Beta pytest (>=3.5.0) - :pypi:`pytest-aioworkers` A plugin to test aioworkers project with pytest May 01, 2023 5 - Production/Stable pytest>=6.1.0 - :pypi:`pytest-airflow` pytest support for airflow. Apr 03, 2019 3 - Alpha pytest (>=4.4.0) - :pypi:`pytest-airflow-utils` Nov 15, 2021 N/A N/A - :pypi:`pytest-alembic` A pytest plugin for verifying alembic migrations. May 23, 2023 N/A pytest (>=6.0) - :pypi:`pytest-allclose` Pytest fixture extending Numpy's allclose function Jul 30, 2019 5 - Production/Stable pytest - :pypi:`pytest-allure-adaptor` Plugin for py.test to generate allure xml reports Jan 10, 2018 N/A pytest (>=2.7.3) - :pypi:`pytest-allure-adaptor2` Plugin for py.test to generate allure xml reports Oct 14, 2020 N/A pytest (>=2.7.3) - :pypi:`pytest-allure-collection` pytest plugin to collect allure markers without running any tests Apr 13, 2023 N/A pytest - :pypi:`pytest-allure-dsl` pytest plugin to test case doc string dls instructions Oct 25, 2020 4 - Beta pytest - :pypi:`pytest-allure-intersection` Oct 27, 2022 N/A pytest (<5) - :pypi:`pytest-allure-spec-coverage` The pytest plugin aimed to display test coverage of the specs(requirements) in Allure Oct 26, 2021 N/A pytest - :pypi:`pytest-alphamoon` Static code checks used at Alphamoon Dec 30, 2021 5 - Production/Stable pytest (>=3.5.0) - :pypi:`pytest-android` This fixture provides a configured "driver" for Android Automated Testing, using uiautomator2. Feb 21, 2019 3 - Alpha pytest - :pypi:`pytest-anki` A pytest plugin for testing Anki add-ons Jul 31, 2022 4 - Beta pytest (>=3.5.0) - :pypi:`pytest-annotate` pytest-annotate: Generate PyAnnotate annotations from your pytest tests. Jun 07, 2022 3 - Alpha pytest (<8.0.0,>=3.2.0) - :pypi:`pytest-ansible` Plugin for pytest to simplify calling ansible modules from tests or fixtures May 15, 2023 5 - Production/Stable pytest (<8.0.0,>=6) - :pypi:`pytest-ansible-playbook` Pytest fixture which runs given ansible playbook file. Mar 08, 2019 4 - Beta N/A - :pypi:`pytest-ansible-playbook-runner` Pytest fixture which runs given ansible playbook file. Dec 02, 2020 4 - Beta pytest (>=3.1.0) - :pypi:`pytest-ansible-units` A pytest plugin for running unit tests within an ansible collection Apr 14, 2022 N/A N/A - :pypi:`pytest-antilru` Bust functools.lru_cache when running pytest to avoid test pollution Jul 05, 2022 5 - Production/Stable pytest - :pypi:`pytest-anyio` The pytest anyio plugin is built into anyio. You don't need this package. Jun 29, 2021 N/A pytest - :pypi:`pytest-anything` Pytest fixtures to assert anything and something Oct 13, 2022 N/A pytest - :pypi:`pytest-aoc` Downloads puzzle inputs for Advent of Code and synthesizes PyTest fixtures Dec 08, 2022 N/A pytest ; extra == 'test' - :pypi:`pytest-aoreporter` pytest report Jun 27, 2022 N/A N/A - :pypi:`pytest-api` An ASGI middleware to populate OpenAPI Specification examples from pytest functions May 12, 2022 N/A pytest (>=7.1.1,<8.0.0) - :pypi:`pytest-api-soup` Validate multiple endpoints with unit testing using a single source of truth. Aug 27, 2022 N/A N/A - :pypi:`pytest-apistellar` apistellar plugin for pytest. Jun 18, 2019 N/A N/A - :pypi:`pytest-appengine` AppEngine integration that works well with pytest-django Feb 27, 2017 N/A N/A - :pypi:`pytest-appium` Pytest plugin for appium Dec 05, 2019 N/A N/A - :pypi:`pytest-approvaltests` A plugin to use approvaltests with pytest May 08, 2022 4 - Beta pytest (>=7.0.1) - :pypi:`pytest-approvaltests-geo` Extension for ApprovalTests.Python specific to geo data verification Mar 04, 2023 5 - Production/Stable pytest - :pypi:`pytest-archon` Rule your architecture like a real developer Jan 31, 2023 5 - Production/Stable pytest (>=7.2) - :pypi:`pytest-argus` pyest results colection plugin Jun 24, 2021 5 - Production/Stable pytest (>=6.2.4) - :pypi:`pytest-arraydiff` pytest plugin to help with comparing array output from tests Jan 13, 2022 4 - Beta pytest (>=4.6) - :pypi:`pytest-asgi-server` Convenient ASGI client/server fixtures for Pytest Dec 12, 2020 N/A pytest (>=5.4.1) - :pypi:`pytest-asptest` test Answer Set Programming programs Apr 28, 2018 4 - Beta N/A - :pypi:`pytest-assertcount` Plugin to count actual number of asserts in pytest Oct 23, 2022 N/A pytest (>=5.0.0) - :pypi:`pytest-assertions` Pytest Assertions Apr 27, 2022 N/A N/A - :pypi:`pytest-assertutil` pytest-assertutil May 10, 2019 N/A N/A - :pypi:`pytest-assert-utils` Useful assertion utilities for use with pytest Apr 14, 2022 3 - Alpha N/A - :pypi:`pytest-assume` A pytest plugin that allows multiple failures per test Jun 24, 2021 N/A pytest (>=2.7) - :pypi:`pytest-assurka` A pytest plugin for Assurka Studio Aug 04, 2022 N/A N/A - :pypi:`pytest-ast-back-to-python` A plugin for pytest devs to view how assertion rewriting recodes the AST Sep 29, 2019 4 - Beta N/A - :pypi:`pytest-asteroid` PyTest plugin for docker-based testing on database images Aug 15, 2022 N/A pytest (>=6.2.5,<8.0.0) - :pypi:`pytest-astropy` Meta-package containing dependencies for testing Apr 12, 2022 5 - Production/Stable pytest (>=4.6) - :pypi:`pytest-astropy-header` pytest plugin to add diagnostic information to the header of the test output Sep 06, 2022 3 - Alpha pytest (>=4.6) - :pypi:`pytest-ast-transformer` May 04, 2019 3 - Alpha pytest - :pypi:`pytest-asyncio` Pytest support for asyncio Mar 19, 2023 4 - Beta pytest (>=7.0.0) - :pypi:`pytest-asyncio-cooperative` Run all your asynchronous tests cooperatively. May 31, 2023 N/A N/A - :pypi:`pytest-asyncio-network-simulator` pytest-asyncio-network-simulator: Plugin for pytest for simulator the network in tests Jul 31, 2018 3 - Alpha pytest (<3.7.0,>=3.3.2) - :pypi:`pytest-async-mongodb` pytest plugin for async MongoDB Oct 18, 2017 5 - Production/Stable pytest (>=2.5.2) - :pypi:`pytest-async-sqlalchemy` Database testing fixtures using the SQLAlchemy asyncio API Oct 07, 2021 4 - Beta pytest (>=6.0.0) - :pypi:`pytest-atomic` Skip rest of tests if previous test failed. Nov 24, 2018 4 - Beta N/A - :pypi:`pytest-attrib` pytest plugin to select tests based on attributes similar to the nose-attrib plugin May 24, 2016 4 - Beta N/A - :pypi:`pytest-austin` Austin plugin for pytest Oct 11, 2020 4 - Beta N/A - :pypi:`pytest-autocap` automatically capture test & fixture stdout/stderr to files May 15, 2022 N/A pytest (<7.2,>=7.1.2) - :pypi:`pytest-autochecklog` automatically check condition and log all the checks Apr 25, 2015 4 - Beta N/A - :pypi:`pytest-automation` pytest plugin for building a test suite, using YAML files to extend pytest parameterize functionality. May 20, 2022 N/A pytest (>=7.0.0) - :pypi:`pytest-automock` Pytest plugin for automatical mocks creation May 16, 2023 N/A pytest ; extra == 'dev' - :pypi:`pytest-auto-parametrize` pytest plugin: avoid repeating arguments in parametrize Oct 02, 2016 3 - Alpha N/A - :pypi:`pytest-autotest` This fixture provides a configured "driver" for Android Automated Testing, using uiautomator2. Aug 25, 2021 N/A pytest - :pypi:`pytest-aviator` Aviator's Flakybot pytest plugin that automatically reruns flaky tests. Nov 04, 2022 4 - Beta pytest - :pypi:`pytest-avoidance` Makes pytest skip tests that don not need rerunning May 23, 2019 4 - Beta pytest (>=3.5.0) - :pypi:`pytest-aws` pytest plugin for testing AWS resource configurations Oct 04, 2017 4 - Beta N/A - :pypi:`pytest-aws-config` Protect your AWS credentials in unit tests May 28, 2021 N/A N/A - :pypi:`pytest-axe` pytest plugin for axe-selenium-python Nov 12, 2018 N/A pytest (>=3.0.0) - :pypi:`pytest-azure` Pytest utilities and mocks for Azure Jan 18, 2023 3 - Alpha pytest - :pypi:`pytest-azure-devops` Simplifies using azure devops parallel strategy (https://docs.microsoft.com/en-us/azure/devops/pipelines/test/parallel-testing-any-test-runner) with pytest. Jun 20, 2022 4 - Beta pytest (>=3.5.0) - :pypi:`pytest-azurepipelines` Formatting PyTest output for Azure Pipelines UI Oct 20, 2022 5 - Production/Stable pytest (>=5.0.0) - :pypi:`pytest-bandit` A bandit plugin for pytest Feb 23, 2021 4 - Beta pytest (>=3.5.0) - :pypi:`pytest-bandit-xayon` A bandit plugin for pytest Oct 17, 2022 4 - Beta pytest (>=3.5.0) - :pypi:`pytest-base-url` pytest plugin for URL based testing Mar 27, 2022 5 - Production/Stable pytest (>=3.0.0,<8.0.0) - :pypi:`pytest-bdd` BDD for pytest Nov 08, 2022 6 - Mature pytest (>=6.2.0) - :pypi:`pytest-bdd-html` pytest plugin to display BDD info in HTML test report Nov 22, 2022 3 - Alpha pytest (!=6.0.0,>=5.0) - :pypi:`pytest-bdd-ng` BDD for pytest Oct 06, 2022 4 - Beta pytest (>=5.0) - :pypi:`pytest-bdd-splinter` Common steps for pytest bdd and splinter integration Aug 12, 2019 5 - Production/Stable pytest (>=4.0.0) - :pypi:`pytest-bdd-web` A simple plugin to use with pytest Jan 02, 2020 4 - Beta pytest (>=3.5.0) - :pypi:`pytest-bdd-wrappers` Feb 11, 2020 2 - Pre-Alpha N/A - :pypi:`pytest-beakerlib` A pytest plugin that reports test results to the BeakerLib framework Mar 17, 2017 5 - Production/Stable pytest - :pypi:`pytest-beds` Fixtures for testing Google Appengine (GAE) apps Jun 07, 2016 4 - Beta N/A - :pypi:`pytest-beeprint` use icdiff for better error messages in pytest assertions Jun 09, 2023 4 - Beta N/A - :pypi:`pytest-bench` Benchmark utility that plugs into pytest. Jul 21, 2014 3 - Alpha N/A - :pypi:`pytest-benchmark` A \`\`pytest\`\` fixture for benchmarking code. It will group the tests into rounds that are calibrated to the chosen timer. Oct 25, 2022 5 - Production/Stable pytest (>=3.8) - :pypi:`pytest-better-datadir` A small example package Mar 13, 2023 N/A N/A - :pypi:`pytest-bg-process` Pytest plugin to initialize background process Jan 24, 2022 4 - Beta pytest (>=3.5.0) - :pypi:`pytest-bigchaindb` A BigchainDB plugin for pytest. Jan 24, 2022 4 - Beta N/A - :pypi:`pytest-bigquery-mock` Provides a mock fixture for python bigquery client Dec 28, 2022 N/A pytest (>=5.0) - :pypi:`pytest-black` A pytest plugin to enable format checking with black Oct 05, 2020 4 - Beta N/A - :pypi:`pytest-black-multipy` Allow '--black' on older Pythons Jan 14, 2021 5 - Production/Stable pytest (!=3.7.3,>=3.5) ; extra == 'testing' - :pypi:`pytest-black-ng` A pytest plugin to enable format checking with black Oct 20, 2022 4 - Beta pytest (>=7.0.0) - :pypi:`pytest-blame` A pytest plugin helps developers to debug by providing useful commits history. May 04, 2019 N/A pytest (>=4.4.0) - :pypi:`pytest-blender` Blender Pytest plugin. Jan 04, 2023 N/A pytest ; extra == 'dev' - :pypi:`pytest-blink1` Pytest plugin to emit notifications via the Blink(1) RGB LED Jan 07, 2018 4 - Beta N/A - :pypi:`pytest-blockage` Disable network requests during a test run. Dec 21, 2021 N/A pytest - :pypi:`pytest-blocker` pytest plugin to mark a test as blocker and skip all other tests Sep 07, 2015 4 - Beta N/A - :pypi:`pytest-blue` A pytest plugin that adds a \`blue\` fixture for printing stuff in blue. Sep 05, 2022 N/A N/A - :pypi:`pytest-board` Local continuous test runner with pytest and watchdog. Jan 20, 2019 N/A N/A - :pypi:`pytest-boost-xml` Plugin for pytest to generate boost xml reports Nov 30, 2022 4 - Beta N/A - :pypi:`pytest-bootstrap` Mar 04, 2022 N/A N/A - :pypi:`pytest-bpdb` A py.test plug-in to enable drop to bpdb debugger on test failure. Jan 19, 2015 2 - Pre-Alpha N/A - :pypi:`pytest-bravado` Pytest-bravado automatically generates from OpenAPI specification client fixtures. Feb 15, 2022 N/A N/A - :pypi:`pytest-breakword` Use breakword with pytest Aug 04, 2021 N/A pytest (>=6.2.4,<7.0.0) - :pypi:`pytest-breed-adapter` A simple plugin to connect with breed-server Nov 07, 2018 4 - Beta pytest (>=3.5.0) - :pypi:`pytest-briefcase` A pytest plugin for running tests on a Briefcase project. Jun 14, 2020 4 - Beta pytest (>=3.5.0) - :pypi:`pytest-browser` A pytest plugin for console based browser test selection just after the collection phase Dec 10, 2016 3 - Alpha N/A - :pypi:`pytest-browsermob-proxy` BrowserMob proxy plugin for py.test. Jun 11, 2013 4 - Beta N/A - :pypi:`pytest-browserstack-local` \`\`py.test\`\` plugin to run \`\`BrowserStackLocal\`\` in background. Feb 09, 2018 N/A N/A - :pypi:`pytest-budosystems` Budo Systems is a martial arts school management system. This module is the Budo Systems Pytest Plugin. May 07, 2023 3 - Alpha pytest - :pypi:`pytest-bug` Pytest plugin for marking tests as a bug Jan 29, 2023 5 - Production/Stable pytest (>=6.2.0) - :pypi:`pytest-bugtong-tag` pytest-bugtong-tag is a plugin for pytest Jan 16, 2022 N/A N/A - :pypi:`pytest-bugzilla` py.test bugzilla integration plugin May 05, 2010 4 - Beta N/A - :pypi:`pytest-bugzilla-notifier` A plugin that allows you to execute create, update, and read information from BugZilla bugs Jun 15, 2018 4 - Beta pytest (>=2.9.2) - :pypi:`pytest-buildkite` Plugin for pytest that automatically publishes coverage and pytest report annotations to Buildkite. Jul 13, 2019 4 - Beta pytest (>=3.5.0) - :pypi:`pytest-builtin-types` Nov 17, 2021 N/A pytest - :pypi:`pytest-bwrap` Run your tests in Bubblewrap sandboxes Oct 26, 2018 3 - Alpha N/A - :pypi:`pytest-cache` pytest plugin with mechanisms for caching across test runs Jun 04, 2013 3 - Alpha N/A - :pypi:`pytest-cache-assert` Cache assertion data to simplify regression testing of complex serializable data Feb 26, 2023 5 - Production/Stable pytest (>=5.0.0) - :pypi:`pytest-cagoule` Pytest plugin to only run tests affected by changes Jan 01, 2020 3 - Alpha N/A - :pypi:`pytest-cairo` Pytest support for cairo-lang and starknet Apr 17, 2022 N/A pytest - :pypi:`pytest-call-checker` Small pytest utility to easily create test doubles Oct 16, 2022 4 - Beta pytest (>=7.1.3,<8.0.0) - :pypi:`pytest-camel-collect` Enable CamelCase-aware pytest class collection Aug 02, 2020 N/A pytest (>=2.9) - :pypi:`pytest-canonical-data` A plugin which allows to compare results with canonical results, based on previous runs May 08, 2020 2 - Pre-Alpha pytest (>=3.5.0) - :pypi:`pytest-caprng` A plugin that replays pRNG state on failure. May 02, 2018 4 - Beta N/A - :pypi:`pytest-capture-deprecatedwarnings` pytest plugin to capture all deprecatedwarnings and put them in one file Apr 30, 2019 N/A N/A - :pypi:`pytest-capture-warnings` pytest plugin to capture all warnings and put them in one file of your choice May 03, 2022 N/A pytest - :pypi:`pytest-cases` Separate test code from test cases in pytest. Feb 23, 2023 5 - Production/Stable N/A - :pypi:`pytest-cassandra` Cassandra CCM Test Fixtures for pytest Nov 04, 2017 1 - Planning N/A - :pypi:`pytest-catchlog` py.test plugin to catch log messages. This is a fork of pytest-capturelog. Jan 24, 2016 4 - Beta pytest (>=2.6) - :pypi:`pytest-catch-server` Pytest plugin with server for catching HTTP requests. Dec 12, 2019 5 - Production/Stable N/A - :pypi:`pytest-celery` pytest-celery a shim pytest plugin to enable celery.contrib.pytest May 06, 2021 N/A N/A - :pypi:`pytest-chainmaker` pytest plugin for chainmaker Oct 15, 2021 N/A N/A - :pypi:`pytest-chalice` A set of py.test fixtures for AWS Chalice Jul 01, 2020 4 - Beta N/A - :pypi:`pytest-change-assert` 修改报错中文为英文 Oct 19, 2022 N/A N/A - :pypi:`pytest-change-demo` turn . into √,turn F into x Mar 02, 2022 N/A pytest - :pypi:`pytest-change-report` turn . into √,turn F into x Sep 14, 2020 N/A pytest - :pypi:`pytest-change-xds` turn . into √,turn F into x Apr 16, 2022 N/A pytest - :pypi:`pytest-chdir` A pytest fixture for changing current working directory Jan 28, 2020 N/A pytest (>=5.0.0,<6.0.0) - :pypi:`pytest-check` A pytest plugin that allows multiple failures per test. Jun 06, 2023 N/A pytest - :pypi:`pytest-checkdocs` check the README when running tests Oct 09, 2022 5 - Production/Stable pytest (>=6) ; extra == 'testing' - :pypi:`pytest-checkipdb` plugin to check if there are ipdb debugs left Jul 22, 2020 5 - Production/Stable pytest (>=2.9.2) - :pypi:`pytest-check-library` check your missing library Jul 17, 2022 N/A N/A - :pypi:`pytest-check-libs` check your missing library Jul 17, 2022 N/A N/A - :pypi:`pytest-check-links` Check links in files Jul 29, 2020 N/A pytest>=7.0 - :pypi:`pytest-check-mk` pytest plugin to test Check_MK checks Nov 19, 2015 4 - Beta pytest - :pypi:`pytest-check-requirements` A package to prevent Dependency Confusion attacks against Yandex. Feb 10, 2023 N/A N/A - :pypi:`pytest-chic-report` A pytest plugin to send a report and printing summary of tests. Jan 31, 2023 5 - Production/Stable N/A - :pypi:`pytest-chunks` Run only a chunk of your test suite Jul 05, 2022 N/A pytest (>=6.0.0) - :pypi:`pytest-circleci` py.test plugin for CircleCI May 03, 2019 N/A N/A - :pypi:`pytest-circleci-parallelized` Parallelize pytest across CircleCI workers. Oct 20, 2022 N/A N/A - :pypi:`pytest-circleci-parallelized-rjp` Parallelize pytest across CircleCI workers. Jun 21, 2022 N/A pytest - :pypi:`pytest-ckan` Backport of CKAN 2.9 pytest plugin and fixtures to CAKN 2.8 Apr 28, 2020 4 - Beta pytest - :pypi:`pytest-clarity` A plugin providing an alternative, colourful diff output for failing assertions. Jun 11, 2021 N/A N/A - :pypi:`pytest-cldf` Easy quality control for CLDF datasets using pytest Nov 07, 2022 N/A pytest (>=3.6) - :pypi:`pytest-click` Pytest plugin for Click Feb 11, 2022 5 - Production/Stable pytest (>=5.0) - :pypi:`pytest-cli-fixtures` Automatically register fixtures for custom CLI arguments Jul 28, 2022 N/A pytest (~=7.0) - :pypi:`pytest-clld` Jul 06, 2022 N/A pytest (>=3.6) - :pypi:`pytest-cloud` Distributed tests planner plugin for pytest testing framework. Oct 05, 2020 6 - Mature N/A - :pypi:`pytest-cloudflare-worker` pytest plugin for testing cloudflare workers Mar 30, 2021 4 - Beta pytest (>=6.0.0) - :pypi:`pytest-cloudist` Distribute tests to cloud machines without fuss Sep 02, 2022 4 - Beta pytest (>=7.1.2,<8.0.0) - :pypi:`pytest-cmake` Provide CMake module for Pytest Jan 21, 2023 N/A pytest<8,>=4 - :pypi:`pytest-cmake-presets` Execute CMake Presets via pytest Dec 26, 2022 N/A pytest (>=7.2.0,<8.0.0) - :pypi:`pytest-cobra` PyTest plugin for testing Smart Contracts for Ethereum blockchain. Jun 29, 2019 3 - Alpha pytest (<4.0.0,>=3.7.1) - :pypi:`pytest-codecarbon` Pytest plugin for measuring carbon emissions Jun 15, 2022 N/A pytest - :pypi:`pytest-codecheckers` pytest plugin to add source code sanity checks (pep8 and friends) Feb 13, 2010 N/A N/A - :pypi:`pytest-codecov` Pytest plugin for uploading pytest-cov results to codecov.io Nov 29, 2022 4 - Beta pytest (>=4.6.0) - :pypi:`pytest-codegen` Automatically create pytest test signatures Aug 23, 2020 2 - Pre-Alpha N/A - :pypi:`pytest-codeowners` Pytest plugin for selecting tests by GitHub CODEOWNERS. Mar 30, 2022 4 - Beta pytest (>=6.0.0) - :pypi:`pytest-codestyle` pytest plugin to run pycodestyle Mar 23, 2020 3 - Alpha N/A - :pypi:`pytest-codspeed` Pytest plugin to create CodSpeed benchmarks Dec 02, 2022 5 - Production/Stable pytest>=3.8 - :pypi:`pytest-collect-formatter` Formatter for pytest collect output Mar 29, 2021 5 - Production/Stable N/A - :pypi:`pytest-collect-formatter2` Formatter for pytest collect output May 31, 2021 5 - Production/Stable N/A - :pypi:`pytest-collector` Python package for collecting pytest. Aug 02, 2022 N/A pytest (>=7.0,<8.0) - :pypi:`pytest-colordots` Colorizes the progress indicators Oct 06, 2017 5 - Production/Stable N/A - :pypi:`pytest-commander` An interactive GUI test runner for PyTest Aug 17, 2021 N/A pytest (<7.0.0,>=6.2.4) - :pypi:`pytest-common-subject` pytest framework for testing different aspects of a common method May 15, 2022 N/A pytest (>=3.6,<8) - :pypi:`pytest-compare` pytest plugin for comparing call arguments. Mar 30, 2023 5 - Production/Stable N/A - :pypi:`pytest-concurrent` Concurrently execute test cases with multithread, multiprocess and gevent Jan 12, 2019 4 - Beta pytest (>=3.1.1) - :pypi:`pytest-config` Base configurations and utilities for developing your Python project test suite with pytest. Nov 07, 2014 5 - Production/Stable N/A - :pypi:`pytest-confluence-report` Package stands for pytest plugin to upload results into Confluence page. Apr 17, 2022 N/A N/A - :pypi:`pytest-console-scripts` Pytest plugin for testing console scripts May 31, 2023 4 - Beta pytest (>=4.0.0) - :pypi:`pytest-consul` pytest plugin with fixtures for testing consul aware apps Nov 24, 2018 3 - Alpha pytest - :pypi:`pytest-container` Pytest fixtures for writing container based tests Mar 21, 2023 4 - Beta pytest (>=3.10) - :pypi:`pytest-contextfixture` Define pytest fixtures as context managers. Mar 12, 2013 4 - Beta N/A - :pypi:`pytest-contexts` A plugin to run tests written with the Contexts framework using pytest May 19, 2021 4 - Beta N/A - :pypi:`pytest-cookies` The pytest plugin for your Cookiecutter templates. 🍪 Mar 22, 2023 5 - Production/Stable pytest (>=3.9.0) - :pypi:`pytest-couchdbkit` py.test extension for per-test couchdb databases using couchdbkit Apr 17, 2012 N/A N/A - :pypi:`pytest-count` count erros and send email Jan 12, 2018 4 - Beta N/A - :pypi:`pytest-cov` Pytest plugin for measuring coverage. May 24, 2023 5 - Production/Stable pytest (>=4.6) - :pypi:`pytest-cover` Pytest plugin for measuring coverage. Forked from \`pytest-cov\`. Aug 01, 2015 5 - Production/Stable N/A - :pypi:`pytest-coverage` Jun 17, 2015 N/A N/A - :pypi:`pytest-coverage-context` Coverage dynamic context support for PyTest, including sub-processes Jan 04, 2021 4 - Beta pytest (>=6.1.0) - :pypi:`pytest-coveragemarkers` Using pytest markers to track functional coverage and filtering of tests Nov 29, 2022 N/A pytest (>=7.1.2,<8.0.0) - :pypi:`pytest-cov-exclude` Pytest plugin for excluding tests based on coverage data Apr 29, 2016 4 - Beta pytest (>=2.8.0,<2.9.0); extra == 'dev' - :pypi:`pytest-cpp` Use pytest's runner to discover and execute C++ tests Jan 30, 2023 5 - Production/Stable pytest (>=7.0) - :pypi:`pytest-cppython` A pytest plugin that imports CPPython testing types Jun 14, 2023 N/A N/A - :pypi:`pytest-cqase` Custom qase pytest plugin Aug 22, 2022 N/A pytest (>=7.1.2,<8.0.0) - :pypi:`pytest-cram` Run cram tests with pytest. Aug 08, 2020 N/A N/A - :pypi:`pytest-crate` Manages CrateDB instances during your integration tests May 28, 2019 3 - Alpha pytest (>=4.0) - :pypi:`pytest-crayons` A pytest plugin for colorful print statements Mar 19, 2023 N/A pytest - :pypi:`pytest-create` pytest-create Feb 15, 2023 1 - Planning N/A - :pypi:`pytest-cricri` A Cricri plugin for pytest. Jan 27, 2018 N/A pytest - :pypi:`pytest-crontab` add crontab task in crontab Dec 09, 2019 N/A N/A - :pypi:`pytest-csv` CSV output for pytest. Apr 22, 2021 N/A pytest (>=6.0) - :pypi:`pytest-csv-params` Pytest plugin for Test Case Parametrization with CSV files Aug 28, 2022 5 - Production/Stable pytest (>=7.1.2,<8.0.0) - :pypi:`pytest-curio` Pytest support for curio. Oct 07, 2020 N/A N/A - :pypi:`pytest-curl-report` pytest plugin to generate curl command line report Dec 11, 2016 4 - Beta N/A - :pypi:`pytest-custom-concurrency` Custom grouping concurrence for pytest Feb 08, 2021 N/A N/A - :pypi:`pytest-custom-exit-code` Exit pytest test session with custom exit code in different scenarios Aug 07, 2019 4 - Beta pytest (>=4.0.2) - :pypi:`pytest-custom-nodeid` Custom grouping for pytest-xdist, rename test cases name and test cases nodeid, support allure report Mar 07, 2021 N/A N/A - :pypi:`pytest-custom-report` Configure the symbols displayed for test outcomes Jan 30, 2019 N/A pytest - :pypi:`pytest-custom-scheduling` Custom grouping for pytest-xdist, rename test cases name and test cases nodeid, support allure report Mar 01, 2021 N/A N/A - :pypi:`pytest-cython` A plugin for testing Cython extension modules Feb 16, 2023 5 - Production/Stable pytest (>=4.6.0) - :pypi:`pytest-cython-collect` Jun 17, 2022 N/A pytest - :pypi:`pytest-darker` A pytest plugin for checking of modified code using Darker Aug 16, 2020 N/A pytest (>=6.0.1) ; extra == 'test' - :pypi:`pytest-dash` pytest fixtures to run dash applications. Mar 18, 2019 N/A N/A - :pypi:`pytest-data` Useful functions for managing data for pytest fixtures Nov 01, 2016 5 - Production/Stable N/A - :pypi:`pytest-databricks` Pytest plugin for remote Databricks notebooks testing Jul 29, 2020 N/A pytest - :pypi:`pytest-datadir` pytest plugin for test data directories and files Oct 25, 2022 5 - Production/Stable pytest (>=5.0) - :pypi:`pytest-datadir-mgr` Manager for test data: downloads, artifact caching, and a tmpdir context. Apr 06, 2023 5 - Production/Stable pytest (>=7.1) - :pypi:`pytest-datadir-ng` Fixtures for pytest allowing test functions/methods to easily retrieve test resources from the local filesystem. Dec 25, 2019 5 - Production/Stable pytest - :pypi:`pytest-datadir-nng` Fixtures for pytest allowing test functions/methods to easily retrieve test resources from the local filesystem. Nov 09, 2022 5 - Production/Stable pytest (>=7.0.0,<8.0.0) - :pypi:`pytest-data-extractor` A pytest plugin to extract relevant metadata about tests into an external file (currently only json support) Jul 19, 2022 N/A pytest (>=7.0.1) - :pypi:`pytest-data-file` Fixture "data" and "case_data" for test from yaml file Dec 04, 2019 N/A N/A - :pypi:`pytest-datafiles` py.test plugin to create a 'tmp_path' containing predefined files/directories. Feb 24, 2023 5 - Production/Stable pytest (>=3.6) - :pypi:`pytest-datafixtures` Data fixtures for pytest made simple Dec 05, 2020 5 - Production/Stable N/A - :pypi:`pytest-data-from-files` pytest plugin to provide data from files loaded automatically Oct 13, 2021 4 - Beta pytest - :pypi:`pytest-dataplugin` A pytest plugin for managing an archive of test data. Sep 16, 2017 1 - Planning N/A - :pypi:`pytest-datarecorder` A py.test plugin recording and comparing test output. Jan 08, 2023 5 - Production/Stable pytest - :pypi:`pytest-dataset` Plugin for loading different datasets for pytest by prefix from json or yaml files May 01, 2023 5 - Production/Stable N/A - :pypi:`pytest-data-suites` Class-based pytest parametrization Jul 24, 2022 N/A pytest (>=6.0,<8.0) - :pypi:`pytest-datatest` A pytest plugin for test driven data-wrangling (this is the development version of datatest's pytest integration). Oct 15, 2020 4 - Beta pytest (>=3.3) - :pypi:`pytest-db` Session scope fixture "db" for mysql query or change Dec 04, 2019 N/A N/A - :pypi:`pytest-dbfixtures` Databases fixtures plugin for py.test. Dec 07, 2016 4 - Beta N/A - :pypi:`pytest-db-plugin` Nov 27, 2021 N/A pytest (>=5.0) - :pypi:`pytest-dbt` Unit test dbt models with standard python tooling Jun 08, 2023 2 - Pre-Alpha pytest (>=7.0.0,<8.0.0) - :pypi:`pytest-dbt-adapter` A pytest plugin for testing dbt adapter plugins Nov 24, 2021 N/A pytest (<7,>=6) - :pypi:`pytest-dbt-conventions` A pytest plugin for linting a dbt project's conventions Mar 02, 2022 N/A pytest (>=6.2.5,<7.0.0) - :pypi:`pytest-dbt-core` Pytest extension for dbt. May 03, 2023 N/A pytest (>=6.2.5) ; extra == 'test' - :pypi:`pytest-dbus-notification` D-BUS notifications for pytest results. Mar 05, 2014 5 - Production/Stable N/A - :pypi:`pytest-dbx` Pytest plugin to run unit tests for dbx (Databricks CLI extensions) related code Nov 29, 2022 N/A pytest (>=7.1.3,<8.0.0) - :pypi:`pytest-deadfixtures` A simple plugin to list unused fixtures in pytest Jul 23, 2020 5 - Production/Stable N/A - :pypi:`pytest-deepcov` deepcov Mar 30, 2021 N/A N/A - :pypi:`pytest-defer` Aug 24, 2021 N/A N/A - :pypi:`pytest-demo-plugin` pytest示例插件 May 15, 2021 N/A N/A - :pypi:`pytest-dependency` Manage dependencies of tests Feb 14, 2020 4 - Beta N/A - :pypi:`pytest-depends` Tests that depend on other tests Apr 05, 2020 5 - Production/Stable pytest (>=3) - :pypi:`pytest-deprecate` Mark tests as testing a deprecated feature with a warning note. Jul 01, 2019 N/A N/A - :pypi:`pytest-describe` Describe-style plugin for pytest Apr 09, 2023 5 - Production/Stable pytest (<8,>=4.6) - :pypi:`pytest-describe-it` plugin for rich text descriptions Jul 19, 2019 4 - Beta pytest - :pypi:`pytest-devpi-server` DevPI server fixture for py.test May 28, 2019 5 - Production/Stable pytest - :pypi:`pytest-dhos` Common fixtures for pytest in DHOS services and libraries Sep 07, 2022 N/A N/A - :pypi:`pytest-diamond` pytest plugin for diamond Aug 31, 2015 4 - Beta N/A - :pypi:`pytest-dicom` pytest plugin to provide DICOM fixtures Dec 19, 2018 3 - Alpha pytest - :pypi:`pytest-dictsdiff` Jul 26, 2019 N/A N/A - :pypi:`pytest-diff` A simple plugin to use with pytest Mar 30, 2019 4 - Beta pytest (>=3.5.0) - :pypi:`pytest-diffeo` A package to prevent Dependency Confusion attacks against Yandex. Feb 10, 2023 N/A N/A - :pypi:`pytest-diff-selector` Get tests affected by code changes (using git) Feb 24, 2022 4 - Beta pytest (>=6.2.2) ; extra == 'all' - :pypi:`pytest-difido` PyTest plugin for generating Difido reports Oct 23, 2022 4 - Beta pytest (>=4.0.0) - :pypi:`pytest-disable` pytest plugin to disable a test and skip it from testrun Sep 10, 2015 4 - Beta N/A - :pypi:`pytest-disable-plugin` Disable plugins per test Feb 28, 2019 4 - Beta pytest (>=3.5.0) - :pypi:`pytest-discord` A pytest plugin to notify test results to a Discord channel. Feb 05, 2023 4 - Beta pytest (!=6.0.0,<8,>=3.3.2) - :pypi:`pytest-django` A Django plugin for pytest. Dec 07, 2021 5 - Production/Stable pytest (>=5.4.0) - :pypi:`pytest-django-ahead` A Django plugin for pytest. Oct 27, 2016 5 - Production/Stable pytest (>=2.9) - :pypi:`pytest-djangoapp` Nice pytest plugin to help you with Django pluggable application testing. May 19, 2023 4 - Beta pytest - :pypi:`pytest-django-cache-xdist` A djangocachexdist plugin for pytest May 12, 2020 4 - Beta N/A - :pypi:`pytest-django-casperjs` Integrate CasperJS with your django tests as a pytest fixture. Mar 15, 2015 2 - Pre-Alpha N/A - :pypi:`pytest-django-dotenv` Pytest plugin used to setup environment variables with django-dotenv Nov 26, 2019 4 - Beta pytest (>=2.6.0) - :pypi:`pytest-django-factories` Factories for your Django models that can be used as Pytest fixtures. Nov 12, 2020 4 - Beta N/A - :pypi:`pytest-django-filefield` Replaces FileField.storage with something you can patch globally. May 09, 2022 5 - Production/Stable pytest >= 5.2 - :pypi:`pytest-django-gcir` A Django plugin for pytest. Mar 06, 2018 5 - Production/Stable N/A - :pypi:`pytest-django-haystack` Cleanup your Haystack indexes between tests Sep 03, 2017 5 - Production/Stable pytest (>=2.3.4) - :pypi:`pytest-django-ifactory` A model instance factory for pytest-django Jun 06, 2023 5 - Production/Stable N/A - :pypi:`pytest-django-lite` The bare minimum to integrate py.test with Django. Jan 30, 2014 N/A N/A - :pypi:`pytest-django-liveserver-ssl` Jan 20, 2022 3 - Alpha N/A - :pypi:`pytest-django-model` A Simple Way to Test your Django Models Feb 14, 2019 4 - Beta N/A - :pypi:`pytest-django-ordering` A pytest plugin for preserving the order in which Django runs tests. Jul 25, 2019 5 - Production/Stable pytest (>=2.3.0) - :pypi:`pytest-django-queries` Generate performance reports from your django database performance tests. Mar 01, 2021 N/A N/A - :pypi:`pytest-djangorestframework` A djangorestframework plugin for pytest Aug 11, 2019 4 - Beta N/A - :pypi:`pytest-django-rq` A pytest plugin to help writing unit test for django-rq Apr 13, 2020 4 - Beta N/A - :pypi:`pytest-django-sqlcounts` py.test plugin for reporting the number of SQLs executed per django testcase. Jun 16, 2015 4 - Beta N/A - :pypi:`pytest-django-testing-postgresql` Use a temporary PostgreSQL database with pytest-django Jan 31, 2022 4 - Beta N/A - :pypi:`pytest-doc` A documentation plugin for py.test. Jun 28, 2015 5 - Production/Stable N/A - :pypi:`pytest-docfiles` pytest plugin to test codeblocks in your documentation. Dec 22, 2021 4 - Beta pytest (>=3.7.0) - :pypi:`pytest-docgen` An RST Documentation Generator for pytest-based test suites Apr 17, 2020 N/A N/A - :pypi:`pytest-docker` Simple pytest fixtures for Docker and docker-compose based tests Sep 14, 2022 N/A pytest (<8.0,>=4.0) - :pypi:`pytest-docker-apache-fixtures` Pytest fixtures for testing with apache2 (httpd). Feb 16, 2022 4 - Beta pytest - :pypi:`pytest-docker-butla` Jun 16, 2019 3 - Alpha N/A - :pypi:`pytest-dockerc` Run, manage and stop Docker Compose project from Docker API Oct 09, 2020 5 - Production/Stable pytest (>=3.0) - :pypi:`pytest-docker-compose` Manages Docker containers during your integration tests Jan 26, 2021 5 - Production/Stable pytest (>=3.3) - :pypi:`pytest-docker-db` A plugin to use docker databases for pytests Mar 20, 2021 5 - Production/Stable pytest (>=3.1.1) - :pypi:`pytest-docker-fixtures` pytest docker fixtures May 02, 2023 3 - Alpha pytest - :pypi:`pytest-docker-git-fixtures` Pytest fixtures for testing with git scm. Feb 09, 2022 4 - Beta pytest - :pypi:`pytest-docker-haproxy-fixtures` Pytest fixtures for testing with haproxy. Feb 09, 2022 4 - Beta pytest - :pypi:`pytest-docker-pexpect` pytest plugin for writing functional tests with pexpect and docker Jan 14, 2019 N/A pytest - :pypi:`pytest-docker-postgresql` A simple plugin to use with pytest Sep 24, 2019 4 - Beta pytest (>=3.5.0) - :pypi:`pytest-docker-py` Easy to use, simple to extend, pytest plugin that minimally leverages docker-py. Nov 27, 2018 N/A pytest (==4.0.0) - :pypi:`pytest-docker-registry-fixtures` Pytest fixtures for testing with docker registries. Apr 08, 2022 4 - Beta pytest - :pypi:`pytest-docker-service` pytest plugin to start docker container Feb 22, 2023 3 - Alpha pytest (>=7.1.3) - :pypi:`pytest-docker-squid-fixtures` Pytest fixtures for testing with squid. Feb 09, 2022 4 - Beta pytest - :pypi:`pytest-docker-tools` Docker integration tests for pytest Feb 17, 2022 4 - Beta pytest (>=6.0.1) - :pypi:`pytest-docs` Documentation tool for pytest Nov 11, 2018 4 - Beta pytest (>=3.5.0) - :pypi:`pytest-docstyle` pytest plugin to run pydocstyle Mar 23, 2020 3 - Alpha N/A - :pypi:`pytest-doctest-custom` A py.test plugin for customizing string representations of doctest results. Jul 25, 2016 4 - Beta N/A - :pypi:`pytest-doctest-ellipsis-markers` Setup additional values for ELLIPSIS_MARKER for doctests Jan 12, 2018 4 - Beta N/A - :pypi:`pytest-doctest-import` A simple pytest plugin to import names and add them to the doctest namespace. Nov 13, 2018 4 - Beta pytest (>=3.3.0) - :pypi:`pytest-doctestplus` Pytest plugin with advanced doctest features. Jun 08, 2023 3 - Alpha pytest (>=4.6) - :pypi:`pytest-dolphin` Some extra stuff that we use ininternally Nov 30, 2016 4 - Beta pytest (==3.0.4) - :pypi:`pytest-doorstop` A pytest plugin for adding test results into doorstop items. Jun 09, 2020 4 - Beta pytest (>=3.5.0) - :pypi:`pytest-dotenv` A py.test plugin that parses environment files before running tests Jun 16, 2020 4 - Beta pytest (>=5.0.0) - :pypi:`pytest-draw` Pytest plugin for randomly selecting a specific number of tests Mar 21, 2023 3 - Alpha pytest - :pypi:`pytest-drf` A Django REST framework plugin for pytest. Jul 12, 2022 5 - Production/Stable pytest (>=3.7) - :pypi:`pytest-drivings` Tool to allow webdriver automation to be ran locally or remotely Jan 13, 2021 N/A N/A - :pypi:`pytest-drop-dup-tests` A Pytest plugin to drop duplicated tests during collection May 23, 2020 4 - Beta pytest (>=2.7) - :pypi:`pytest-dummynet` A py.test plugin providing access to a dummynet. Dec 15, 2021 5 - Production/Stable pytest - :pypi:`pytest-dump2json` A pytest plugin for dumping test results to json. Jun 29, 2015 N/A N/A - :pypi:`pytest-duration-insights` Jun 25, 2021 N/A N/A - :pypi:`pytest-durations` Pytest plugin reporting fixtures and test functions execution time. Apr 22, 2022 5 - Production/Stable pytest (>=4.6) - :pypi:`pytest-dynamicrerun` A pytest plugin to rerun tests dynamically based off of test outcome and output. Aug 15, 2020 4 - Beta N/A - :pypi:`pytest-dynamodb` DynamoDB fixtures for pytest Jun 12, 2023 5 - Production/Stable pytest - :pypi:`pytest-easy-addoption` pytest-easy-addoption: Easy way to work with pytest addoption Jan 22, 2020 N/A N/A - :pypi:`pytest-easy-api` Simple API testing with pytest Mar 26, 2018 N/A N/A - :pypi:`pytest-easyMPI` Package that supports mpi tests in pytest Oct 21, 2020 N/A N/A - :pypi:`pytest-easyread` pytest plugin that makes terminal printouts of the reports easier to read Nov 17, 2017 N/A N/A - :pypi:`pytest-easy-server` Pytest plugin for easy testing against servers May 01, 2021 4 - Beta pytest (<5.0.0,>=4.3.1) ; python_version < "3.5" - :pypi:`pytest-ebics-sandbox` A pytest plugin for testing against an EBICS sandbox server. Requires docker. Aug 15, 2022 N/A N/A - :pypi:`pytest-ec2` Pytest execution on EC2 instance Oct 22, 2019 3 - Alpha N/A - :pypi:`pytest-echo` pytest plugin with mechanisms for echoing environment variables, package version and generic attributes Jan 08, 2020 5 - Production/Stable N/A - :pypi:`pytest-ekstazi` Pytest plugin to select test using Ekstazi algorithm Sep 10, 2022 N/A pytest - :pypi:`pytest-elasticsearch` Elasticsearch fixtures and fixture factories for Pytest. Mar 01, 2022 5 - Production/Stable pytest (>=6.2.0) - :pypi:`pytest-elements` Tool to help automate user interfaces Jan 13, 2021 N/A pytest (>=5.4,<6.0) - :pypi:`pytest-eliot` An eliot plugin for pytest. Aug 31, 2022 1 - Planning pytest (>=5.4.0) - :pypi:`pytest-elk-reporter` A simple plugin to use with pytest Jan 24, 2021 4 - Beta pytest (>=3.5.0) - :pypi:`pytest-email` Send execution result email Jul 08, 2020 N/A pytest - :pypi:`pytest-embedded` A pytest plugin that designed for embedded testing. Jun 14, 2023 5 - Production/Stable pytest>=7.0 - :pypi:`pytest-embedded-arduino` Make pytest-embedded plugin work with Arduino. Jun 14, 2023 5 - Production/Stable N/A - :pypi:`pytest-embedded-idf` Make pytest-embedded plugin work with ESP-IDF. Jun 14, 2023 5 - Production/Stable N/A - :pypi:`pytest-embedded-jtag` Make pytest-embedded plugin work with JTAG. Jun 14, 2023 5 - Production/Stable N/A - :pypi:`pytest-embedded-qemu` Make pytest-embedded plugin work with QEMU. Jun 14, 2023 5 - Production/Stable N/A - :pypi:`pytest-embedded-serial` Make pytest-embedded plugin work with Serial. Jun 14, 2023 5 - Production/Stable N/A - :pypi:`pytest-embedded-serial-esp` Make pytest-embedded plugin work with Espressif target boards. Jun 14, 2023 5 - Production/Stable N/A - :pypi:`pytest-embrace` 💝 Dataclasses-as-tests. Describe the runtime once and multiply coverage with no boilerplate. Mar 25, 2023 N/A pytest (>=7.0,<8.0) - :pypi:`pytest-emoji` A pytest plugin that adds emojis to your test result report Feb 19, 2019 4 - Beta pytest (>=4.2.1) - :pypi:`pytest-emoji-output` Pytest plugin to represent test output with emoji support Apr 09, 2023 4 - Beta pytest (==7.0.1) - :pypi:`pytest-enabler` Enable installed pytest plugins May 12, 2023 5 - Production/Stable pytest (>=6) ; extra == 'testing' - :pypi:`pytest-encode` set your encoding and logger Nov 06, 2021 N/A N/A - :pypi:`pytest-encode-kane` set your encoding and logger Nov 16, 2021 N/A pytest - :pypi:`pytest-enhanced-reports` Enhanced test reports for pytest Dec 15, 2022 N/A N/A - :pypi:`pytest-enhancements` Improvements for pytest (rejected upstream) Oct 30, 2019 4 - Beta N/A - :pypi:`pytest-env` py.test plugin that allows you to add environment variables. Jun 15, 2023 5 - Production/Stable pytest>=7.3.1 - :pypi:`pytest-envfiles` A py.test plugin that parses environment files before running tests Oct 08, 2015 3 - Alpha N/A - :pypi:`pytest-env-info` Push information about the running pytest into envvars Nov 25, 2017 4 - Beta pytest (>=3.1.1) - :pypi:`pytest-envraw` py.test plugin that allows you to add environment variables. Aug 27, 2020 4 - Beta pytest (>=2.6.0) - :pypi:`pytest-envvars` Pytest plugin to validate use of envvars on your tests Jun 13, 2020 5 - Production/Stable pytest (>=3.0.0) - :pypi:`pytest-env-yaml` Apr 02, 2019 N/A N/A - :pypi:`pytest-eradicate` pytest plugin to check for commented out code Sep 08, 2020 N/A pytest (>=2.4.2) - :pypi:`pytest-error-for-skips` Pytest plugin to treat skipped tests a test failure Dec 19, 2019 4 - Beta pytest (>=4.6) - :pypi:`pytest-eth` PyTest plugin for testing Smart Contracts for Ethereum Virtual Machine (EVM). Aug 14, 2020 1 - Planning N/A - :pypi:`pytest-ethereum` pytest-ethereum: Pytest library for ethereum projects. Jun 24, 2019 3 - Alpha pytest (==3.3.2); extra == 'dev' - :pypi:`pytest-eucalyptus` Pytest Plugin for BDD Jun 28, 2022 N/A pytest (>=4.2.0) - :pypi:`pytest-eventlet` Applies eventlet monkey-patch as a pytest plugin. Oct 04, 2021 N/A pytest ; extra == 'dev' - :pypi:`pytest-examples` Pytest plugin for testing examples in docstrings and markdown files. May 05, 2023 4 - Beta pytest>=7 - :pypi:`pytest-excel` pytest plugin for generating excel reports Jan 31, 2022 5 - Production/Stable N/A - :pypi:`pytest-exceptional` Better exceptions Mar 16, 2017 4 - Beta N/A - :pypi:`pytest-exception-script` Walk your code through exception script to check it's resiliency to failures. Aug 04, 2020 3 - Alpha pytest - :pypi:`pytest-executable` pytest plugin for testing executables Mar 25, 2023 N/A pytest (<8,>=4.3) - :pypi:`pytest-execution-timer` A timer for the phases of Pytest's execution. Dec 24, 2021 4 - Beta N/A - :pypi:`pytest-expect` py.test plugin to store test expectations and mark tests based on them Apr 21, 2016 4 - Beta N/A - :pypi:`pytest-expectdir` A pytest plugin to provide initial/expected directories, and check a test transforms the initial directory to the expected one Mar 19, 2023 5 - Production/Stable pytest (>=5.0) - :pypi:`pytest-expecter` Better testing with expecter and pytest. Sep 18, 2022 5 - Production/Stable N/A - :pypi:`pytest-expectr` This plugin is used to expect multiple assert using pytest framework. Oct 05, 2018 N/A pytest (>=2.4.2) - :pypi:`pytest-expect-test` A fixture to support expect tests in pytest Apr 10, 2023 4 - Beta pytest (>=3.5.0) - :pypi:`pytest-experiments` A pytest plugin to help developers of research-oriented software projects keep track of the results of their numerical experiments. Dec 13, 2021 4 - Beta pytest (>=6.2.5,<7.0.0) - :pypi:`pytest-explicit` A Pytest plugin to ignore certain marked tests by default Jun 15, 2021 5 - Production/Stable pytest - :pypi:`pytest-exploratory` Interactive console for pytest. Feb 21, 2022 N/A pytest (>=6.2) - :pypi:`pytest-extensions` A collection of helpers for pytest to ease testing Aug 17, 2022 4 - Beta pytest ; extra == 'testing' - :pypi:`pytest-external-blockers` a special outcome for tests that are blocked for external reasons Oct 05, 2021 N/A pytest - :pypi:`pytest-extra-durations` A pytest plugin to get durations on a per-function basis and per module basis. Apr 21, 2020 4 - Beta pytest (>=3.5.0) - :pypi:`pytest-extra-markers` Additional pytest markers to dynamically enable/disable tests viia CLI flags Mar 05, 2023 4 - Beta pytest - :pypi:`pytest-fabric` Provides test utilities to run fabric task tests by using docker containers Sep 12, 2018 5 - Production/Stable N/A - :pypi:`pytest-factor` A package to prevent Dependency Confusion attacks against Yandex. Feb 10, 2023 N/A N/A - :pypi:`pytest-factory` Use factories for test setup with py.test Sep 06, 2020 3 - Alpha pytest (>4.3) - :pypi:`pytest-factoryboy` Factory Boy support for pytest. Dec 01, 2022 6 - Mature pytest (>=5.0.0) - :pypi:`pytest-factoryboy-fixtures` Generates pytest fixtures that allow the use of type hinting Jun 25, 2020 N/A N/A - :pypi:`pytest-factoryboy-state` Simple factoryboy random state management Mar 22, 2022 5 - Production/Stable pytest (>=5.0) - :pypi:`pytest-failed-screen-record` Create a video of the screen when pytest fails Jan 05, 2023 4 - Beta pytest (>=7.1.2d,<8.0.0) - :pypi:`pytest-failed-screenshot` Test case fails,take a screenshot,save it,attach it to the allure Apr 21, 2021 N/A N/A - :pypi:`pytest-failed-to-verify` A pytest plugin that helps better distinguishing real test failures from setup flakiness. Aug 08, 2019 5 - Production/Stable pytest (>=4.1.0) - :pypi:`pytest-fail-slow` Fail tests that take too long to run Aug 13, 2022 4 - Beta pytest (>=6.0) - :pypi:`pytest-faker` Faker integration with the pytest framework. Dec 19, 2016 6 - Mature N/A - :pypi:`pytest-falcon` Pytest helpers for Falcon. Sep 07, 2016 4 - Beta N/A - :pypi:`pytest-falcon-client` Pytest \`client\` fixture for the Falcon Framework Mar 19, 2019 N/A N/A - :pypi:`pytest-fantasy` Pytest plugin for Flask Fantasy Framework Mar 14, 2019 N/A N/A - :pypi:`pytest-fastapi` Dec 27, 2020 N/A N/A - :pypi:`pytest-fastapi-deps` A fixture which allows easy replacement of fastapi dependencies for testing Jul 20, 2022 5 - Production/Stable pytest - :pypi:`pytest-fastest` Use SCM and coverage to run only needed tests Jun 15, 2023 4 - Beta pytest (>=4.4) - :pypi:`pytest-fast-first` Pytest plugin that runs fast tests first Jan 19, 2023 3 - Alpha pytest - :pypi:`pytest-faulthandler` py.test plugin that activates the fault handler module for tests (dummy package) Jul 04, 2019 6 - Mature pytest (>=5.0) - :pypi:`pytest-fauxfactory` Integration of fauxfactory into pytest. Dec 06, 2017 5 - Production/Stable pytest (>=3.2) - :pypi:`pytest-figleaf` py.test figleaf coverage plugin Jan 18, 2010 5 - Production/Stable N/A - :pypi:`pytest-filecov` A pytest plugin to detect unused files Jun 27, 2021 4 - Beta pytest - :pypi:`pytest-filedata` easily load data from files Jan 17, 2019 4 - Beta N/A - :pypi:`pytest-filemarker` A pytest plugin that runs marked tests when files change. Dec 01, 2020 N/A pytest - :pypi:`pytest-file-watcher` Pytest-File-Watcher is a CLI tool that watches for changes in your code and runs pytest on the changed files. Mar 23, 2023 N/A pytest - :pypi:`pytest-filter-case` run test cases filter by mark Nov 05, 2020 N/A N/A - :pypi:`pytest-filter-subpackage` Pytest plugin for filtering based on sub-packages Dec 12, 2022 3 - Alpha pytest (>=3.0) - :pypi:`pytest-find-dependencies` A pytest plugin to find dependencies between tests Apr 09, 2022 4 - Beta pytest (>=4.3.0) - :pypi:`pytest-finer-verdicts` A pytest plugin to treat non-assertion failures as test errors. Jun 18, 2020 N/A pytest (>=5.4.3) - :pypi:`pytest-firefox` pytest plugin to manipulate firefox Aug 08, 2017 3 - Alpha pytest (>=3.0.2) - :pypi:`pytest-fixture-classes` Fixtures as classes that work well with dependency injection, autocompletetion, type checkers, and language servers Jan 20, 2023 4 - Beta pytest - :pypi:`pytest-fixture-config` Fixture configuration utils for py.test May 28, 2019 5 - Production/Stable pytest - :pypi:`pytest-fixture-maker` Pytest plugin to load fixtures from YAML files Sep 21, 2021 N/A N/A - :pypi:`pytest-fixture-marker` A pytest plugin to add markers based on fixtures used. Oct 11, 2020 5 - Production/Stable N/A - :pypi:`pytest-fixture-order` pytest plugin to control fixture evaluation order May 16, 2022 5 - Production/Stable pytest (>=3.0) - :pypi:`pytest-fixture-ref` Lets users reference fixtures without name matching magic. Nov 17, 2022 4 - Beta N/A - :pypi:`pytest-fixture-rtttg` Warn or fail on fixture name clash Feb 23, 2022 N/A pytest (>=7.0.1,<8.0.0) - :pypi:`pytest-fixtures` Common fixtures for pytest May 01, 2019 5 - Production/Stable N/A - :pypi:`pytest-fixture-tools` Plugin for pytest which provides tools for fixtures Aug 18, 2020 6 - Mature pytest - :pypi:`pytest-fixture-typecheck` A pytest plugin to assert type annotations at runtime. Aug 24, 2021 N/A pytest - :pypi:`pytest-flake8` pytest plugin to check FLAKE8 requirements Mar 18, 2022 4 - Beta pytest (>=7.0) - :pypi:`pytest-flake8-path` A pytest fixture for testing flake8 plugins. Jun 16, 2023 5 - Production/Stable pytest - :pypi:`pytest-flake8-v2` pytest plugin to check FLAKE8 requirements Mar 01, 2022 5 - Production/Stable pytest (>=7.0) - :pypi:`pytest-flakefinder` Runs tests multiple times to expose flakiness. Oct 26, 2022 4 - Beta pytest (>=2.7.1) - :pypi:`pytest-flakes` pytest plugin to check source code with pyflakes Dec 02, 2021 5 - Production/Stable pytest (>=5) - :pypi:`pytest-flaptastic` Flaptastic py.test plugin Mar 17, 2019 N/A N/A - :pypi:`pytest-flask` A set of py.test fixtures to test Flask applications. Feb 27, 2021 5 - Production/Stable pytest (>=5.2) - :pypi:`pytest-flask-ligand` Pytest fixtures and helper functions to use for testing flask-ligand microservices. Apr 25, 2023 4 - Beta pytest (~=7.3) - :pypi:`pytest-flask-sqlalchemy` A pytest plugin for preserving test isolation in Flask-SQlAlchemy using database transactions. Apr 30, 2022 4 - Beta pytest (>=3.2.1) - :pypi:`pytest-flask-sqlalchemy-transactions` Run tests in transactions using pytest, Flask, and SQLalchemy. Aug 02, 2018 4 - Beta pytest (>=3.2.1) - :pypi:`pytest-flexreport` Apr 15, 2023 4 - Beta pytest - :pypi:`pytest-fluent` A pytest plugin in order to provide logs via fluentd Jul 12, 2022 4 - Beta pytest - :pypi:`pytest-flyte` Pytest fixtures for simplifying Flyte integration testing May 03, 2021 N/A pytest - :pypi:`pytest-focus` A pytest plugin that alerts user of failed test cases with screen notifications May 04, 2019 4 - Beta pytest - :pypi:`pytest-forbid` Mar 07, 2023 N/A pytest (>=7.2.2,<8.0.0) - :pypi:`pytest-forcefail` py.test plugin to make the test failing regardless of pytest.mark.xfail May 15, 2018 4 - Beta N/A - :pypi:`pytest-forward-compatability` A name to avoid typosquating pytest-foward-compatibility Sep 06, 2020 N/A N/A - :pypi:`pytest-forward-compatibility` A pytest plugin to shim pytest commandline options for fowards compatibility Sep 29, 2020 N/A N/A - :pypi:`pytest-frappe` Pytest Frappe Plugin - A set of pytest fixtures to test Frappe applications May 03, 2023 4 - Beta pytest>=7.0.0 - :pypi:`pytest-freezegun` Wrap tests with fixtures in freeze_time Jul 19, 2020 4 - Beta pytest (>=3.0.0) - :pypi:`pytest-freezer` Pytest plugin providing a fixture interface for spulec/freezegun Jun 17, 2023 N/A pytest>=3.6 - :pypi:`pytest-freeze-reqs` Check if requirement files are frozen Apr 29, 2021 N/A N/A - :pypi:`pytest-frozen-uuids` Deterministically frozen UUID's for your tests Apr 17, 2022 N/A pytest (>=3.0) - :pypi:`pytest-func-cov` Pytest plugin for measuring function coverage Apr 15, 2021 3 - Alpha pytest (>=5) - :pypi:`pytest-funparam` An alternative way to parametrize test cases. Dec 02, 2021 4 - Beta pytest >=4.6.0 - :pypi:`pytest-fxa` pytest plugin for Firefox Accounts Aug 28, 2018 5 - Production/Stable N/A - :pypi:`pytest-fxtest` Oct 27, 2020 N/A N/A - :pypi:`pytest-fzf` fzf-based test selector for pytest Aug 17, 2022 1 - Planning pytest (>=7.1.2) - :pypi:`pytest-gather-fixtures` set up asynchronous pytest fixtures concurrently Apr 12, 2022 N/A pytest (>=6.0.0) - :pypi:`pytest-gc` The garbage collector plugin for py.test Feb 01, 2018 N/A N/A - :pypi:`pytest-gcov` Uses gcov to measure test coverage of a C library Feb 01, 2018 3 - Alpha N/A - :pypi:`pytest-gevent` Ensure that gevent is properly patched when invoking pytest Feb 25, 2020 N/A pytest - :pypi:`pytest-gherkin` A flexible framework for executing BDD gherkin tests Jul 27, 2019 3 - Alpha pytest (>=5.0.0) - :pypi:`pytest-gh-log-group` pytest plugin for gh actions Jan 11, 2022 3 - Alpha pytest - :pypi:`pytest-ghostinspector` For finding/executing Ghost Inspector tests May 17, 2016 3 - Alpha N/A - :pypi:`pytest-girder` A set of pytest fixtures for testing Girder applications. Jun 14, 2023 N/A N/A - :pypi:`pytest-git` Git repository fixture for py.test May 28, 2019 5 - Production/Stable pytest - :pypi:`pytest-gitcov` Pytest plugin for reporting on coverage of the last git commit. Jan 11, 2020 2 - Pre-Alpha N/A - :pypi:`pytest-git-fixtures` Pytest fixtures for testing with git. Mar 11, 2021 4 - Beta pytest - :pypi:`pytest-github` Plugin for py.test that associates tests with github issues using a marker. Mar 07, 2019 5 - Production/Stable N/A - :pypi:`pytest-github-actions-annotate-failures` pytest plugin to annotate failed tests with a workflow command for GitHub Actions May 04, 2023 5 - Production/Stable pytest (>=4.0.0) - :pypi:`pytest-github-report` Generate a GitHub report using pytest in GitHub Workflows Jun 03, 2022 4 - Beta N/A - :pypi:`pytest-gitignore` py.test plugin to ignore the same files as git Jul 17, 2015 4 - Beta N/A - :pypi:`pytest-gitlabci-parallelized` Parallelize pytest across GitLab CI workers. Mar 08, 2023 N/A N/A - :pypi:`pytest-git-selector` Utility to select tests that have had its dependencies modified (as identified by git diff) Nov 17, 2022 N/A N/A - :pypi:`pytest-glamor-allure` Extends allure-pytest functionality Jul 22, 2022 4 - Beta pytest - :pypi:`pytest-gnupg-fixtures` Pytest fixtures for testing with gnupg. Mar 04, 2021 4 - Beta pytest - :pypi:`pytest-golden` Plugin for pytest that offloads expected outputs to data files Jul 18, 2022 N/A pytest (>=6.1.2) - :pypi:`pytest-goldie` A plugin to support golden tests with pytest. May 23, 2023 4 - Beta pytest (>=3.5.0) - :pypi:`pytest-google-chat` Notify google chat channel for test results Mar 27, 2022 4 - Beta pytest - :pypi:`pytest-graphql-schema` Get graphql schema as fixture for pytest Oct 18, 2019 N/A N/A - :pypi:`pytest-greendots` Green progress dots Feb 08, 2014 3 - Alpha N/A - :pypi:`pytest-growl` Growl notifications for pytest results. Jan 13, 2014 5 - Production/Stable N/A - :pypi:`pytest-grpc` pytest plugin for grpc May 01, 2020 N/A pytest (>=3.6.0) - :pypi:`pytest-grunnur` Py.Test plugin for Grunnur-based packages. Feb 05, 2023 N/A N/A - :pypi:`pytest-hammertime` Display "🔨 " instead of "." for passed pytest tests. Jul 28, 2018 N/A pytest - :pypi:`pytest-harmony` Chain tests and data with pytest Jan 17, 2023 N/A pytest (>=7.2.1,<8.0.0) - :pypi:`pytest-harvest` Store data created during your pytest tests execution, and retrieve it at the end of the session, e.g. for applicative benchmarking purposes. Jun 10, 2022 5 - Production/Stable N/A - :pypi:`pytest-helm-chart` A plugin to provide different types and configs of Kubernetes clusters that can be used for testing. Jun 15, 2020 4 - Beta pytest (>=5.4.2,<6.0.0) - :pypi:`pytest-helm-charts` A plugin to provide different types and configs of Kubernetes clusters that can be used for testing. Mar 08, 2023 4 - Beta pytest (>=7.1.2,<8.0.0) - :pypi:`pytest-helper` Functions to help in using the pytest testing framework May 31, 2019 5 - Production/Stable N/A - :pypi:`pytest-helpers` pytest helpers May 17, 2020 N/A pytest - :pypi:`pytest-helpers-namespace` Pytest Helpers Namespace Plugin Dec 29, 2021 5 - Production/Stable pytest (>=6.0.0) - :pypi:`pytest-hidecaptured` Hide captured output May 04, 2018 4 - Beta pytest (>=2.8.5) - :pypi:`pytest-historic` Custom report to display pytest historical execution records Apr 08, 2020 N/A pytest - :pypi:`pytest-historic-hook` Custom listener to store execution results into MYSQL DB, which is used for pytest-historic report Apr 08, 2020 N/A pytest - :pypi:`pytest-homeassistant` A pytest plugin for use with homeassistant custom components. Aug 12, 2020 4 - Beta N/A - :pypi:`pytest-homeassistant-custom-component` Experimental package to automatically extract test plugins for Home Assistant custom components Jun 16, 2023 3 - Alpha pytest (==7.3.1) - :pypi:`pytest-honey` A simple plugin to use with pytest Jan 07, 2022 4 - Beta pytest (>=3.5.0) - :pypi:`pytest-honors` Report on tests that honor constraints, and guard against regressions Mar 06, 2020 4 - Beta N/A - :pypi:`pytest-hot-reloading` Jun 16, 2023 N/A N/A - :pypi:`pytest-hot-test` A plugin that tracks test changes Dec 10, 2022 4 - Beta pytest (>=3.5.0) - :pypi:`pytest-hoverfly` Simplify working with Hoverfly from pytest Jan 30, 2023 N/A pytest (>=5.0) - :pypi:`pytest-hoverfly-wrapper` Integrates the Hoverfly HTTP proxy into Pytest Feb 27, 2023 5 - Production/Stable pytest (>=3.7.0) - :pypi:`pytest-hpfeeds` Helpers for testing hpfeeds in your python project Feb 28, 2023 4 - Beta pytest (>=6.2.4,<7.0.0) - :pypi:`pytest-html` pytest plugin for generating HTML reports Apr 08, 2023 5 - Production/Stable pytest (!=6.0.0,>=5.0) - :pypi:`pytest-html-lee` optimized pytest plugin for generating HTML reports Jun 30, 2020 5 - Production/Stable pytest (>=5.0) - :pypi:`pytest-html-merger` Pytest HTML reports merging utility Apr 03, 2022 N/A N/A - :pypi:`pytest-html-object-storage` Pytest report plugin for send HTML report on object-storage Mar 04, 2022 5 - Production/Stable N/A - :pypi:`pytest-html-profiling` Pytest plugin for generating HTML reports with per-test profiling and optionally call graph visualizations. Based on pytest-html by Dave Hunt. Feb 11, 2020 5 - Production/Stable pytest (>=3.0) - :pypi:`pytest-html-reporter` Generates a static html report based on pytest framework Feb 13, 2022 N/A N/A - :pypi:`pytest-html-report-merger` Aug 31, 2022 N/A N/A - :pypi:`pytest-html-thread` pytest plugin for generating HTML reports Dec 29, 2020 5 - Production/Stable N/A - :pypi:`pytest-http` Fixture "http" for http requests Dec 05, 2019 N/A N/A - :pypi:`pytest-httpbin` Easily test your HTTP library against a local copy of httpbin May 08, 2023 5 - Production/Stable pytest ; extra == 'test' - :pypi:`pytest-httpdbg` A pytest plugin to record HTTP(S) requests with stack trace May 09, 2023 3 - Alpha pytest (>=7.0.0) - :pypi:`pytest-http-mocker` Pytest plugin for http mocking (via https://github.com/vilus/mocker) Oct 20, 2019 N/A N/A - :pypi:`pytest-httpretty` A thin wrapper of HTTPretty for pytest Feb 16, 2014 3 - Alpha N/A - :pypi:`pytest-httpserver` pytest-httpserver is a httpserver for pytest May 22, 2023 3 - Alpha N/A - :pypi:`pytest-httptesting` http_testing framework on top of pytest Jun 03, 2023 N/A pytest (>=7.2.0,<8.0.0) - :pypi:`pytest-httpx` Send responses to httpx. Apr 12, 2023 5 - Production/Stable pytest (<8.0,>=6.0) - :pypi:`pytest-httpx-blockage` Disable httpx requests during a test run Feb 16, 2023 N/A pytest (>=7.2.1) - :pypi:`pytest-hue` Visualise PyTest status via your Phillips Hue lights May 09, 2019 N/A N/A - :pypi:`pytest-hylang` Pytest plugin to allow running tests written in hylang Mar 28, 2021 N/A pytest - :pypi:`pytest-hypo-25` help hypo module for pytest Jan 12, 2020 3 - Alpha N/A - :pypi:`pytest-ibutsu` A plugin to sent pytest results to an Ibutsu server Aug 05, 2022 4 - Beta pytest>=7.1 - :pypi:`pytest-icdiff` use icdiff for better error messages in pytest assertions Aug 09, 2022 4 - Beta N/A - :pypi:`pytest-idapro` A pytest plugin for idapython. Allows a pytest setup to run tests outside and inside IDA in an automated manner by runnig pytest inside IDA and by mocking idapython api Nov 03, 2018 N/A N/A - :pypi:`pytest-idem` A pytest plugin to help with testing idem projects Sep 07, 2022 5 - Production/Stable N/A - :pypi:`pytest-idempotent` Pytest plugin for testing function idempotence. Jul 25, 2022 N/A N/A - :pypi:`pytest-ignore-flaky` ignore failures from flaky tests (pytest plugin) Apr 23, 2021 5 - Production/Stable N/A - :pypi:`pytest-image-diff` Mar 09, 2023 3 - Alpha pytest - :pypi:`pytest-incremental` an incremental test runner (pytest plugin) Apr 24, 2021 5 - Production/Stable N/A - :pypi:`pytest-influxdb` Plugin for influxdb and pytest integration. Apr 20, 2021 N/A N/A - :pypi:`pytest-info-collector` pytest plugin to collect information from tests May 26, 2019 3 - Alpha N/A - :pypi:`pytest-informative-node` display more node ininformation. Apr 25, 2019 4 - Beta N/A - :pypi:`pytest-infrastructure` pytest stack validation prior to testing executing Apr 12, 2020 4 - Beta N/A - :pypi:`pytest-ini` Reuse pytest.ini to store env variables Apr 26, 2022 N/A N/A - :pypi:`pytest-inline` A pytest plugin for writing inline tests. Feb 08, 2023 4 - Beta pytest (>=7.0.0) - :pypi:`pytest-inmanta` A py.test plugin providing fixtures to simplify inmanta modules testing. Feb 23, 2023 5 - Production/Stable N/A - :pypi:`pytest-inmanta-extensions` Inmanta tests package Apr 12, 2023 5 - Production/Stable N/A - :pypi:`pytest-inmanta-lsm` Common fixtures for inmanta LSM related modules May 17, 2023 5 - Production/Stable N/A - :pypi:`pytest-inmanta-yang` Common fixtures used in inmanta yang related modules Jun 16, 2022 4 - Beta N/A - :pypi:`pytest-Inomaly` A simple image diff plugin for pytest Feb 13, 2018 4 - Beta N/A - :pypi:`pytest-insta` A practical snapshot testing plugin for pytest Nov 02, 2022 N/A pytest (>=7.2.0,<8.0.0) - :pypi:`pytest-instafail` pytest plugin to show failures instantly Mar 31, 2023 4 - Beta pytest (>=5) - :pypi:`pytest-instrument` pytest plugin to instrument tests Apr 05, 2020 5 - Production/Stable pytest (>=5.1.0) - :pypi:`pytest-integration` Organizing pytests by integration or not Nov 17, 2022 N/A N/A - :pypi:`pytest-integration-mark` Automatic integration test marking and excluding plugin for pytest May 22, 2023 N/A pytest (>=5.2) - :pypi:`pytest-interactive` A pytest plugin for console based interactive test selection just after the collection phase Nov 30, 2017 3 - Alpha N/A - :pypi:`pytest-intercept-remote` Pytest plugin for intercepting outgoing connection requests during pytest run. May 24, 2021 4 - Beta pytest (>=4.6) - :pypi:`pytest-interface-tester` Pytest plugin for checking charm relation interface protocol compliance. May 09, 2023 4 - Beta pytest - :pypi:`pytest-invenio` Pytest fixtures for Invenio. Jun 02, 2023 5 - Production/Stable pytest (<7.2.0,>=6) - :pypi:`pytest-involve` Run tests covering a specific file or changeset Feb 02, 2020 4 - Beta pytest (>=3.5.0) - :pypi:`pytest-ipdb` A py.test plug-in to enable drop to ipdb debugger on test failure. Mar 20, 2013 2 - Pre-Alpha N/A - :pypi:`pytest-ipynb` THIS PROJECT IS ABANDONED Jan 29, 2019 3 - Alpha N/A - :pypi:`pytest-isolate` Feb 20, 2023 4 - Beta pytest - :pypi:`pytest-isort` py.test plugin to check import ordering using isort Oct 31, 2022 5 - Production/Stable pytest (>=5.0) - :pypi:`pytest-is-running` pytest plugin providing a function to check if pytest is running. Jun 16, 2023 5 - Production/Stable N/A - :pypi:`pytest-it` Pytest plugin to display test reports as a plaintext spec, inspired by Rspec: https://github.com/mattduck/pytest-it. Jan 22, 2020 4 - Beta N/A - :pypi:`pytest-iterassert` Nicer list and iterable assertion messages for pytest May 11, 2020 3 - Alpha N/A - :pypi:`pytest-iters` A contextmanager pytest fixture for handling multiple mock iters May 24, 2022 N/A N/A - :pypi:`pytest-jasmine` Run jasmine tests from your pytest test suite Nov 04, 2017 1 - Planning N/A - :pypi:`pytest-jelastic` Pytest plugin defining the necessary command-line options to pass to pytests testing a Jelastic environment. Nov 16, 2022 N/A pytest (>=7.2.0,<8.0.0) - :pypi:`pytest-jest` A custom jest-pytest oriented Pytest reporter May 22, 2018 4 - Beta pytest (>=3.3.2) - :pypi:`pytest-jinja` A plugin to generate customizable jinja-based HTML reports in pytest Oct 04, 2022 3 - Alpha pytest (>=6.2.5,<7.0.0) - :pypi:`pytest-jira` py.test JIRA integration plugin, using markers Jun 12, 2023 3 - Alpha N/A - :pypi:`pytest-jira-xfail` Plugin skips (xfail) tests if unresolved Jira issue(s) linked Jun 14, 2023 N/A pytest (>=7.2.0) - :pypi:`pytest-jira-xray` pytest plugin to integrate tests with JIRA XRAY Jun 06, 2023 4 - Beta pytest - :pypi:`pytest-job-selection` A pytest plugin for load balancing test suites Jan 30, 2023 4 - Beta pytest (>=3.5.0) - :pypi:`pytest-jobserver` Limit parallel tests with posix jobserver. May 15, 2019 5 - Production/Stable pytest - :pypi:`pytest-joke` Test failures are better served with humor. Oct 08, 2019 4 - Beta pytest (>=4.2.1) - :pypi:`pytest-json` Generate JSON test reports Jan 18, 2016 4 - Beta N/A - :pypi:`pytest-json-fixtures` JSON output for the --fixtures flag Mar 14, 2023 4 - Beta N/A - :pypi:`pytest-jsonlint` UNKNOWN Aug 04, 2016 N/A N/A - :pypi:`pytest-json-report` A pytest plugin to report test results as JSON files Mar 15, 2022 4 - Beta pytest (>=3.8.0) - :pypi:`pytest-jtr` pytest plugin supporting json test report output Nov 29, 2022 N/A pytest (>=7.1.2,<8.0.0) - :pypi:`pytest-jupyter` A pytest plugin for testing Jupyter libraries and extensions. Mar 30, 2023 4 - Beta pytest - :pypi:`pytest-jupyterhub` A reusable JupyterHub pytest plugin Apr 25, 2023 5 - Production/Stable pytest - :pypi:`pytest-kafka` Zookeeper, Kafka server, and Kafka consumer fixtures for Pytest Jun 14, 2023 N/A pytest - :pypi:`pytest-kafkavents` A plugin to send pytest events to Kafka Sep 08, 2021 4 - Beta pytest - :pypi:`pytest-kasima` Display horizontal lines above and below the captured standard output for easy viewing. Jan 26, 2023 5 - Production/Stable pytest (>=7.2.1,<8.0.0) - :pypi:`pytest-keep-together` Pytest plugin to customize test ordering by running all 'related' tests together Dec 07, 2022 5 - Production/Stable pytest - :pypi:`pytest-kexi` Apr 29, 2022 N/A pytest (>=7.1.2,<8.0.0) - :pypi:`pytest-kind` Kubernetes test support with KIND for pytest Nov 30, 2022 5 - Production/Stable N/A - :pypi:`pytest-kivy` Kivy GUI tests fixtures using pytest Jul 06, 2021 4 - Beta pytest (>=3.6) - :pypi:`pytest-knows` A pytest plugin that can automaticly skip test case based on dependence info calculated by trace Aug 22, 2014 N/A N/A - :pypi:`pytest-konira` Run Konira DSL tests with py.test Oct 09, 2011 N/A N/A - :pypi:`pytest-koopmans` A plugin for testing the koopmans package Nov 21, 2022 4 - Beta pytest (>=3.5.0) - :pypi:`pytest-krtech-common` pytest krtech common library Nov 28, 2016 4 - Beta N/A - :pypi:`pytest-kubernetes` May 17, 2023 N/A pytest (>=7.2.1,<8.0.0) - :pypi:`pytest-kwparametrize` Alternate syntax for @pytest.mark.parametrize with test cases as dictionaries and default value fallbacks Jan 22, 2021 N/A pytest (>=6) - :pypi:`pytest-lambda` Define pytest fixtures with lambda functions. Aug 20, 2022 3 - Alpha pytest (>=3.6,<8) - :pypi:`pytest-lamp` Jan 06, 2017 3 - Alpha N/A - :pypi:`pytest-langchain` Pytest-style test runner for langchain agents Feb 26, 2023 N/A pytest - :pypi:`pytest-lark` A package for enhancing pytest Nov 20, 2022 N/A N/A - :pypi:`pytest-launchable` Launchable Pytest Plugin Apr 05, 2023 N/A pytest (>=4.2.0) - :pypi:`pytest-layab` Pytest fixtures for layab. Oct 05, 2020 5 - Production/Stable N/A - :pypi:`pytest-lazy-fixture` It helps to use fixtures in pytest.mark.parametrize Feb 01, 2020 4 - Beta pytest (>=3.2.5) - :pypi:`pytest-lazy-fixtures` Allows you to use fixtures in @pytest.mark.parametrize. May 28, 2023 N/A pytest (>=7.2.1,<8.0.0) - :pypi:`pytest-ldap` python-ldap fixtures for pytest Aug 18, 2020 N/A pytest - :pypi:`pytest-leak-finder` Find the test that's leaking before the one that fails Feb 15, 2023 4 - Beta pytest (>=3.5.0) - :pypi:`pytest-leaks` A pytest plugin to trace resource leaks. Nov 27, 2019 1 - Planning N/A - :pypi:`pytest-level` Select tests of a given level or lower Oct 21, 2019 N/A pytest - :pypi:`pytest-libfaketime` A python-libfaketime plugin for pytest. Dec 22, 2018 4 - Beta pytest (>=3.0.0) - :pypi:`pytest-libiio` A pytest plugin to manage interfacing with libiio contexts Jul 11, 2022 4 - Beta N/A - :pypi:`pytest-libnotify` Pytest plugin that shows notifications about the test run Apr 02, 2021 3 - Alpha pytest - :pypi:`pytest-ligo` Jan 16, 2020 4 - Beta N/A - :pypi:`pytest-lineno` A pytest plugin to show the line numbers of test functions Dec 04, 2020 N/A pytest - :pypi:`pytest-line-profiler` Profile code executed by pytest May 03, 2021 4 - Beta pytest (>=3.5.0) - :pypi:`pytest-line-profiler-apn` Profile code executed by pytest Dec 05, 2022 N/A pytest (>=3.5.0) - :pypi:`pytest-lisa` Pytest plugin for organizing tests. Jan 21, 2021 3 - Alpha pytest (>=6.1.2,<7.0.0) - :pypi:`pytest-listener` A simple network listener May 28, 2019 5 - Production/Stable pytest - :pypi:`pytest-litf` A pytest plugin that stream output in LITF format Jan 18, 2021 4 - Beta pytest (>=3.1.1) - :pypi:`pytest-live` Live results for pytest Mar 08, 2020 N/A pytest - :pypi:`pytest-local-badge` Generate local badges (shields) reporting your test suite status. Jan 15, 2023 N/A pytest (>=6.1.0) - :pypi:`pytest-localftpserver` A PyTest plugin which provides an FTP fixture for your tests Oct 04, 2022 5 - Production/Stable pytest - :pypi:`pytest-localserver` pytest plugin to test server connections locally. Jan 30, 2023 4 - Beta N/A - :pypi:`pytest-localstack` Pytest plugin for AWS integration tests Jun 07, 2023 4 - Beta pytest (>=6.0.0,<7.0.0) - :pypi:`pytest-lockable` lockable resource plugin for pytest Jul 20, 2022 5 - Production/Stable pytest - :pypi:`pytest-locker` Used to lock object during testing. Essentially changing assertions from being hard coded to asserting that nothing changed Oct 29, 2021 N/A pytest (>=5.4) - :pypi:`pytest-log` print log Aug 15, 2021 N/A pytest (>=3.8) - :pypi:`pytest-logbook` py.test plugin to capture logbook log messages Nov 23, 2015 5 - Production/Stable pytest (>=2.8) - :pypi:`pytest-logdog` Pytest plugin to test logging Jun 15, 2021 1 - Planning pytest (>=6.2.0) - :pypi:`pytest-logfest` Pytest plugin providing three logger fixtures with basic or full writing to log files Jul 21, 2019 4 - Beta pytest (>=3.5.0) - :pypi:`pytest-logger` Plugin configuring handlers for loggers from Python logging module. Jul 25, 2019 4 - Beta pytest (>=3.2) - :pypi:`pytest-logging` Configures logging and allows tweaking the log level with a py.test flag Nov 04, 2015 4 - Beta N/A - :pypi:`pytest-logging-end-to-end-test-tool` Sep 23, 2022 N/A pytest (>=7.1.2,<8.0.0) - :pypi:`pytest-logikal` Common testing environment Jun 04, 2023 5 - Production/Stable pytest (==7.3.1) - :pypi:`pytest-log-report` Package for creating a pytest test run reprot Dec 26, 2019 N/A N/A - :pypi:`pytest-loguru` Pytest Loguru Apr 12, 2022 5 - Production/Stable N/A - :pypi:`pytest-loop` pytest plugin for looping tests Jul 22, 2022 5 - Production/Stable pytest (>=6) - :pypi:`pytest-lsp` pytest plugin for end-to-end testing of language servers May 19, 2023 3 - Alpha pytest - :pypi:`pytest-manual-marker` pytest marker for marking manual tests Aug 04, 2022 3 - Alpha pytest>=7 - :pypi:`pytest-markdoctest` A pytest plugin to doctest your markdown files Jul 22, 2022 4 - Beta pytest (>=6) - :pypi:`pytest-markdown` Test your markdown docs with pytest Jan 15, 2021 4 - Beta pytest (>=6.0.1,<7.0.0) - :pypi:`pytest-markdown-docs` Run markdown code fences through pytest Mar 09, 2023 N/A N/A - :pypi:`pytest-marker-bugzilla` py.test bugzilla integration plugin, using markers Jan 09, 2020 N/A N/A - :pypi:`pytest-markers-presence` A simple plugin to detect missed pytest tags and markers" Feb 04, 2021 4 - Beta pytest (>=6.0) - :pypi:`pytest-markfiltration` UNKNOWN Nov 08, 2011 3 - Alpha N/A - :pypi:`pytest-mark-no-py3` pytest plugin and bowler codemod to help migrate tests to Python 3 May 17, 2019 N/A pytest - :pypi:`pytest-marks` UNKNOWN Nov 23, 2012 3 - Alpha N/A - :pypi:`pytest-matcher` Match test output against patterns stored in files Dec 10, 2021 5 - Production/Stable N/A - :pypi:`pytest-match-skip` Skip matching marks. Matches partial marks using wildcards. May 15, 2019 4 - Beta pytest (>=4.4.1) - :pypi:`pytest-mat-report` this is report Jan 20, 2021 N/A N/A - :pypi:`pytest-matrix` Provide tools for generating tests from combinations of fixtures. Jun 24, 2020 5 - Production/Stable pytest (>=5.4.3,<6.0.0) - :pypi:`pytest-maybe-context` Simplify tests with warning and exception cases. Apr 16, 2023 N/A pytest (>=7,<8) - :pypi:`pytest-maybe-raises` Pytest fixture for optional exception testing. May 27, 2022 N/A pytest ; extra == 'dev' - :pypi:`pytest-mccabe` pytest plugin to run the mccabe code complexity checker. Jul 22, 2020 3 - Alpha pytest (>=5.4.0) - :pypi:`pytest-md` Plugin for generating Markdown reports for pytest results Jul 11, 2019 3 - Alpha pytest (>=4.2.1) - :pypi:`pytest-md-report` A pytest plugin to make a test results report with Markdown table format. May 28, 2023 4 - Beta pytest (!=6.0.0,<8,>=3.3.2) - :pypi:`pytest-memlog` Log memory usage during tests May 03, 2023 N/A pytest (>=7.3.0,<8.0.0) - :pypi:`pytest-memprof` Estimates memory consumption of test functions Mar 29, 2019 4 - Beta N/A - :pypi:`pytest-memray` A simple plugin to use with pytest Jun 06, 2023 N/A pytest>=7.2 - :pypi:`pytest-menu` A pytest plugin for console based interactive test selection just after the collection phase Oct 04, 2017 3 - Alpha pytest (>=2.4.2) - :pypi:`pytest-mercurial` pytest plugin to write integration tests for projects using Mercurial Python internals Nov 21, 2020 1 - Planning N/A - :pypi:`pytest-mesh` pytest_mesh插件 Aug 05, 2022 N/A pytest (==7.1.2) - :pypi:`pytest-message` Pytest plugin for sending report message of marked tests execution Aug 04, 2022 N/A pytest (>=6.2.5) - :pypi:`pytest-messenger` Pytest to Slack reporting plugin Nov 24, 2022 5 - Production/Stable N/A - :pypi:`pytest-metadata` pytest plugin for test session metadata May 27, 2023 5 - Production/Stable pytest>=7.0.0 - :pypi:`pytest-metrics` Custom metrics report for pytest Apr 04, 2020 N/A pytest - :pypi:`pytest-mh` Pytest multihost plugin Jun 08, 2023 N/A pytest - :pypi:`pytest-mimesis` Mimesis integration with the pytest test runner Mar 21, 2020 5 - Production/Stable pytest (>=4.2) - :pypi:`pytest-minecraft` A pytest plugin for running tests against Minecraft releases Apr 06, 2022 N/A pytest (>=6.0.1) - :pypi:`pytest-mini` A plugin to test mp Feb 06, 2023 N/A pytest (>=7.2.0,<8.0.0) - :pypi:`pytest-missing-fixtures` Pytest plugin that creates missing fixtures Oct 14, 2020 4 - Beta pytest (>=3.5.0) - :pypi:`pytest-ml` Test your machine learning! May 04, 2019 4 - Beta N/A - :pypi:`pytest-mocha` pytest plugin to display test execution output like a mochajs Apr 02, 2020 4 - Beta pytest (>=5.4.0) - :pypi:`pytest-mock` Thin-wrapper around the mock package for easier use with pytest Jun 15, 2023 5 - Production/Stable pytest (>=5.0) - :pypi:`pytest-mock-api` A mock API server with configurable routes and responses available as a fixture. Feb 13, 2019 1 - Planning pytest (>=4.0.0) - :pypi:`pytest-mock-generator` A pytest fixture wrapper for https://pypi.org/project/mock-generator May 16, 2022 5 - Production/Stable N/A - :pypi:`pytest-mock-helper` Help you mock HTTP call and generate mock code Jan 24, 2018 N/A pytest - :pypi:`pytest-mockito` Base fixtures for mockito Jul 11, 2018 4 - Beta N/A - :pypi:`pytest-mockredis` An in-memory mock of a Redis server that runs in a separate thread. This is to be used for unit-tests that require a Redis database. Jan 02, 2018 2 - Pre-Alpha N/A - :pypi:`pytest-mock-resources` A pytest plugin for easily instantiating reproducible mock resources. Jun 09, 2023 N/A pytest (>=1.0) - :pypi:`pytest-mock-server` Mock server plugin for pytest Jan 09, 2022 4 - Beta pytest (>=3.5.0) - :pypi:`pytest-mockservers` A set of fixtures to test your requests to HTTP/UDP servers Mar 31, 2020 N/A pytest (>=4.3.0) - :pypi:`pytest-mocktcp` A pytest plugin for testing TCP clients Oct 11, 2022 N/A pytest - :pypi:`pytest-modified-env` Pytest plugin to fail a test if it leaves modified \`os.environ\` afterwards. Jan 29, 2022 4 - Beta N/A - :pypi:`pytest-modifyjunit` Utility for adding additional properties to junit xml for IDM QE Jan 10, 2019 N/A N/A - :pypi:`pytest-modifyscope` pytest plugin to modify fixture scope Apr 12, 2020 N/A pytest - :pypi:`pytest-molecule` PyTest Molecule Plugin :: discover and run molecule tests Mar 29, 2022 5 - Production/Stable pytest (>=7.0.0) - :pypi:`pytest-mongo` MongoDB process and client fixtures plugin for Pytest. Jun 07, 2021 5 - Production/Stable pytest - :pypi:`pytest-mongodb` pytest plugin for MongoDB fixtures May 16, 2023 5 - Production/Stable N/A - :pypi:`pytest-monitor` Pytest plugin for analyzing resource usage. Oct 22, 2022 5 - Production/Stable pytest - :pypi:`pytest-monkeyplus` pytest's monkeypatch subclass with extra functionalities Sep 18, 2012 5 - Production/Stable N/A - :pypi:`pytest-monkeytype` pytest-monkeytype: Generate Monkeytype annotations from your pytest tests. Jul 29, 2020 4 - Beta N/A - :pypi:`pytest-moto` Fixtures for integration tests of AWS services,uses moto mocking library. Aug 28, 2015 1 - Planning N/A - :pypi:`pytest-motor` A pytest plugin for motor, the non-blocking MongoDB driver. Jul 21, 2021 3 - Alpha pytest - :pypi:`pytest-mp` A test batcher for multiprocessed Pytest runs May 23, 2018 4 - Beta pytest - :pypi:`pytest-mpi` pytest plugin to collect information from tests Jan 08, 2022 3 - Alpha pytest - :pypi:`pytest-mpiexec` pytest plugin for running individual tests with mpiexec Apr 13, 2023 3 - Alpha pytest - :pypi:`pytest-mpl` pytest plugin to help with testing figures output from Matplotlib Jul 23, 2022 4 - Beta pytest - :pypi:`pytest-mproc` low-startup-overhead, scalable, distributed-testing pytest plugin Nov 15, 2022 4 - Beta pytest (>=6) - :pypi:`pytest-mqtt` pytest-mqtt supports testing systems based on MQTT Mar 15, 2023 4 - Beta pytest (<8) ; extra == 'test' - :pypi:`pytest-multihost` Utility for writing multi-host tests for pytest Apr 07, 2020 4 - Beta N/A - :pypi:`pytest-multilog` Multi-process logs handling and other helpers for pytest Jan 17, 2023 N/A pytest - :pypi:`pytest-multithreading` a pytest plugin for th and concurrent testing Dec 07, 2022 N/A N/A - :pypi:`pytest-multithreading-allure` pytest_multithreading_allure Nov 25, 2022 N/A N/A - :pypi:`pytest-mutagen` Add the mutation testing feature to pytest Jul 24, 2020 N/A pytest (>=5.4) - :pypi:`pytest-mypy` Mypy static type checker plugin for Pytest Dec 18, 2022 4 - Beta pytest (>=6.2) ; python_version >= "3.10" - :pypi:`pytest-mypyd` Mypy static type checker plugin for Pytest Aug 20, 2019 4 - Beta pytest (<4.7,>=2.8) ; python_version < "3.5" - :pypi:`pytest-mypy-plugins` pytest plugin for writing tests for mypy plugins May 05, 2023 4 - Beta pytest (>=6.2.0) - :pypi:`pytest-mypy-plugins-shim` Substitute for "pytest-mypy-plugins" for Python implementations which aren't supported by mypy. Apr 12, 2021 N/A pytest>=6.0.0 - :pypi:`pytest-mypy-testing` Pytest plugin to check mypy output. Feb 25, 2023 N/A pytest>=7,<8 - :pypi:`pytest-mysql` MySQL process and client fixtures for pytest Mar 27, 2023 5 - Production/Stable pytest (>=6.2) - :pypi:`pytest-needle` pytest plugin for visual testing websites using selenium Dec 10, 2018 4 - Beta pytest (<5.0.0,>=3.0.0) - :pypi:`pytest-neo` pytest-neo is a plugin for pytest that shows tests like screen of Matrix. Jan 08, 2022 3 - Alpha pytest (>=6.2.0) - :pypi:`pytest-netdut` "Automated software testing for switches using pytest" Jun 05, 2023 N/A pytest (>=3.5.0) - :pypi:`pytest-network` A simple plugin to disable network on socket level. May 07, 2020 N/A N/A - :pypi:`pytest-network-endpoints` Network endpoints plugin for pytest Mar 06, 2022 N/A pytest - :pypi:`pytest-never-sleep` pytest plugin helps to avoid adding tests without mock \`time.sleep\` May 05, 2021 3 - Alpha pytest (>=3.5.1) - :pypi:`pytest-nginx` nginx fixture for pytest Aug 12, 2017 5 - Production/Stable N/A - :pypi:`pytest-nginx-iplweb` nginx fixture for pytest - iplweb temporary fork Mar 01, 2019 5 - Production/Stable N/A - :pypi:`pytest-ngrok` Jan 20, 2022 3 - Alpha pytest - :pypi:`pytest-ngsfixtures` pytest ngs fixtures Sep 06, 2019 2 - Pre-Alpha pytest (>=5.0.0) - :pypi:`pytest-nhsd-apim` Pytest plugin accessing NHSDigital's APIM proxies Jun 07, 2023 N/A pytest (==6.2.5) - :pypi:`pytest-nice` A pytest plugin that alerts user of failed test cases with screen notifications May 04, 2019 4 - Beta pytest - :pypi:`pytest-nice-parametrize` A small snippet for nicer PyTest's Parametrize Apr 17, 2021 5 - Production/Stable N/A - :pypi:`pytest-nlcov` Pytest plugin to get the coverage of the new lines (based on git diff) only Jul 07, 2021 N/A N/A - :pypi:`pytest-nocustom` Run all tests without custom markers Jul 07, 2021 5 - Production/Stable N/A - :pypi:`pytest-nodev` Test-driven source code search for Python. Jul 21, 2016 4 - Beta pytest (>=2.8.1) - :pypi:`pytest-nogarbage` Ensure a test produces no garbage Aug 29, 2021 5 - Production/Stable pytest (>=4.6.0) - :pypi:`pytest-notice` Send pytest execution result email Nov 05, 2020 N/A N/A - :pypi:`pytest-notification` A pytest plugin for sending a desktop notification and playing a sound upon completion of tests Jun 19, 2020 N/A pytest (>=4) - :pypi:`pytest-notifier` A pytest plugin to notify test result Jun 12, 2020 3 - Alpha pytest - :pypi:`pytest-notimplemented` Pytest markers for not implemented features and tests. Aug 27, 2019 N/A pytest (>=5.1,<6.0) - :pypi:`pytest-notion` A PyTest Reporter to send test runs to Notion.so Aug 07, 2019 N/A N/A - :pypi:`pytest-nunit` A pytest plugin for generating NUnit3 test result XML output Oct 20, 2022 5 - Production/Stable pytest (>=4.6.0) - :pypi:`pytest-oar` PyTest plugin for the OAR testing framework May 02, 2023 N/A pytest>=6.0.1 - :pypi:`pytest-object-getter` Import any object from a 3rd party module while mocking its namespace on demand. Jul 31, 2022 5 - Production/Stable pytest - :pypi:`pytest-ochrus` pytest results data-base and HTML reporter Feb 21, 2018 4 - Beta N/A - :pypi:`pytest-odoo` py.test plugin to run Odoo tests Nov 17, 2022 4 - Beta pytest (>=7.2.0) - :pypi:`pytest-odoo-fixtures` Project description Jun 25, 2019 N/A N/A - :pypi:`pytest-oerp` pytest plugin to test OpenERP modules Feb 28, 2012 3 - Alpha N/A - :pypi:`pytest-offline` Mar 09, 2023 1 - Planning pytest (>=7.0.0,<8.0.0) - :pypi:`pytest-ogsm-plugin` 针对特定项目定制化插件,优化了pytest报告展示方式,并添加了项目所需特定参数 May 16, 2023 N/A N/A - :pypi:`pytest-ok` The ultimate pytest output plugin Apr 01, 2019 4 - Beta N/A - :pypi:`pytest-only` Use @pytest.mark.only to run a single test Jun 14, 2022 5 - Production/Stable pytest (<7.1); python_version <= "3.6" - :pypi:`pytest-oot` Run object-oriented tests in a simple format Sep 18, 2016 4 - Beta N/A - :pypi:`pytest-openfiles` Pytest plugin for detecting inadvertent open file handles Apr 16, 2020 3 - Alpha pytest (>=4.6) - :pypi:`pytest-opentelemetry` A pytest plugin for instrumenting test runs via OpenTelemetry Mar 15, 2023 N/A pytest - :pypi:`pytest-opentmi` pytest plugin for publish results to opentmi Jun 02, 2022 5 - Production/Stable pytest (>=5.0) - :pypi:`pytest-operator` Fixtures for Operators Sep 28, 2022 N/A pytest - :pypi:`pytest-optional` include/exclude values of fixtures in pytest Oct 07, 2015 N/A N/A - :pypi:`pytest-optional-tests` Easy declaration of optional tests (i.e., that are not run by default) Jul 09, 2019 4 - Beta pytest (>=4.5.0) - :pypi:`pytest-orchestration` A pytest plugin for orchestrating tests Jul 18, 2019 N/A N/A - :pypi:`pytest-order` pytest plugin to run your tests in a specific order Mar 10, 2023 4 - Beta pytest (>=5.0) ; python_version < "3.10" - :pypi:`pytest-ordering` pytest plugin to run your tests in a specific order Nov 14, 2018 4 - Beta pytest - :pypi:`pytest-order-modify` 新增run_marker 来自定义用例的执行顺序 Nov 04, 2022 N/A N/A - :pypi:`pytest-osxnotify` OS X notifications for py.test results. May 15, 2015 N/A N/A - :pypi:`pytest-otel` pytest-otel report OpenTelemetry traces about test executed Jan 18, 2023 N/A N/A - :pypi:`pytest-override-env-var` Pytest mark to override a value of an environment variable. Feb 25, 2023 N/A N/A - :pypi:`pytest-owner` Add owner mark for tests Apr 25, 2022 N/A N/A - :pypi:`pytest-pact` A simple plugin to use with pytest Jan 07, 2019 4 - Beta N/A - :pypi:`pytest-pahrametahrize` Parametrize your tests with a Boston accent. Nov 24, 2021 4 - Beta pytest (>=6.0,<7.0) - :pypi:`pytest-parallel` a pytest plugin for parallel and concurrent testing Oct 10, 2021 3 - Alpha pytest (>=3.0.0) - :pypi:`pytest-parallel-39` a pytest plugin for parallel and concurrent testing Jul 12, 2021 3 - Alpha pytest (>=3.0.0) - :pypi:`pytest-parallelize-tests` pytest plugin that parallelizes test execution across multiple hosts Jan 27, 2023 4 - Beta N/A - :pypi:`pytest-param` pytest plugin to test all, first, last or random params Sep 11, 2016 4 - Beta pytest (>=2.6.0) - :pypi:`pytest-paramark` Configure pytest fixtures using a combination of"parametrize" and markers Jan 10, 2020 4 - Beta pytest (>=4.5.0) - :pypi:`pytest-parametrization` Simpler PyTest parametrization May 22, 2022 5 - Production/Stable N/A - :pypi:`pytest-parametrize-cases` A more user-friendly way to write parametrized tests. Mar 13, 2022 N/A pytest (>=6.1.2) - :pypi:`pytest-parametrized` Pytest decorator for parametrizing tests with default iterables. Sep 13, 2022 5 - Production/Stable pytest - :pypi:`pytest-parametrize-suite` A simple pytest extension for creating a named test suite. Jan 19, 2023 5 - Production/Stable pytest - :pypi:`pytest-parawtf` Finally spell paramete?ri[sz]e correctly Dec 03, 2018 4 - Beta pytest (>=3.6.0) - :pypi:`pytest-pass` Check out https://github.com/elilutsky/pytest-pass Dec 04, 2019 N/A N/A - :pypi:`pytest-passrunner` Pytest plugin providing the 'run_on_pass' marker Feb 10, 2021 5 - Production/Stable pytest (>=4.6.0) - :pypi:`pytest-paste-config` Allow setting the path to a paste config file Sep 18, 2013 3 - Alpha N/A - :pypi:`pytest-patch` An automagic \`patch\` fixture that can patch objects directly or by name. Apr 29, 2023 3 - Alpha pytest (>=7.0.0) - :pypi:`pytest-patches` A contextmanager pytest fixture for handling multiple mock patches Aug 30, 2021 4 - Beta pytest (>=3.5.0) - :pypi:`pytest-pdb` pytest plugin which adds pdb helper commands related to pytest. Jul 31, 2018 N/A N/A - :pypi:`pytest-peach` pytest plugin for fuzzing with Peach API Security Apr 12, 2019 4 - Beta pytest (>=2.8.7) - :pypi:`pytest-pep257` py.test plugin for pep257 Jul 09, 2016 N/A N/A - :pypi:`pytest-pep8` pytest plugin to check PEP8 requirements Apr 27, 2014 N/A N/A - :pypi:`pytest-percent` Change the exit code of pytest test sessions when a required percent of tests pass. May 21, 2020 N/A pytest (>=5.2.0) - :pypi:`pytest-perf` Run performance tests against the mainline code. Jun 02, 2023 5 - Production/Stable pytest (>=6) ; extra == 'testing' - :pypi:`pytest-performance` A simple plugin to ensure the execution of critical sections of code has not been impacted Sep 11, 2020 5 - Production/Stable pytest (>=3.7.0) - :pypi:`pytest-persistence` Pytest tool for persistent objects Jun 14, 2023 N/A N/A - :pypi:`pytest-pg` A tiny plugin for pytest which runs PostgreSQL in Docker May 04, 2023 5 - Production/Stable pytest (>=6.0.0) - :pypi:`pytest-pgsql` Pytest plugins and helpers for tests using a Postgres database. May 13, 2020 5 - Production/Stable pytest (>=3.0.0) - :pypi:`pytest-phmdoctest` pytest plugin to test Python examples in Markdown using phmdoctest. Apr 15, 2022 4 - Beta pytest (>=5.4.3) - :pypi:`pytest-picked` Run the tests related to the changed files Dec 23, 2020 N/A pytest (>=3.5.0) - :pypi:`pytest-pigeonhole` Jun 25, 2018 5 - Production/Stable pytest (>=3.4) - :pypi:`pytest-pikachu` Show surprise when tests are passing Aug 05, 2021 5 - Production/Stable pytest - :pypi:`pytest-pilot` Slice in your test base thanks to powerful markers. Oct 09, 2020 5 - Production/Stable N/A - :pypi:`pytest-pingguo-pytest-plugin` pingguo test Oct 26, 2022 4 - Beta N/A - :pypi:`pytest-pings` 🦊 The pytest plugin for Firefox Telemetry 📊 Jun 29, 2019 3 - Alpha pytest (>=5.0.0) - :pypi:`pytest-pinned` A simple pytest plugin for pinning tests Sep 17, 2021 4 - Beta pytest (>=3.5.0) - :pypi:`pytest-pinpoint` A pytest plugin which runs SBFL algorithms to detect faults. Sep 25, 2020 N/A pytest (>=4.4.0) - :pypi:`pytest-pipeline` Pytest plugin for functional testing of data analysispipelines Jan 24, 2017 3 - Alpha N/A - :pypi:`pytest-platform-markers` Markers for pytest to skip tests on specific platforms Sep 09, 2019 4 - Beta pytest (>=3.6.0) - :pypi:`pytest-play` pytest plugin that let you automate actions and assertions with test metrics reporting executing plain YAML files Jun 12, 2019 5 - Production/Stable N/A - :pypi:`pytest-playbook` Pytest plugin for reading playbooks. Jan 21, 2021 3 - Alpha pytest (>=6.1.2,<7.0.0) - :pypi:`pytest-playwright` A pytest wrapper with fixtures for Playwright to automate web browsers Apr 24, 2023 N/A pytest (<8.0.0,>=6.2.4) - :pypi:`pytest-playwrights` A pytest wrapper with fixtures for Playwright to automate web browsers Dec 02, 2021 N/A N/A - :pypi:`pytest-playwright-snapshot` A pytest wrapper for snapshot testing with playwright Aug 19, 2021 N/A N/A - :pypi:`pytest-playwright-visual` A pytest fixture for visual testing with Playwright Apr 28, 2022 N/A N/A - :pypi:`pytest-plone` Pytest plugin to test Plone addons Jan 05, 2023 3 - Alpha pytest - :pypi:`pytest-plt` Fixtures for quickly making Matplotlib plots in tests Aug 17, 2020 5 - Production/Stable pytest - :pypi:`pytest-plugin-helpers` A plugin to help developing and testing other plugins Nov 23, 2019 4 - Beta pytest (>=3.5.0) - :pypi:`pytest-plus` PyTest Plus Plugin :: extends pytest functionality Dec 24, 2022 5 - Production/Stable pytest (>=6.0.1) - :pypi:`pytest-pmisc` Mar 21, 2019 5 - Production/Stable N/A - :pypi:`pytest-pointers` Pytest plugin to define functions you test with special marks for better navigation and reports Dec 26, 2022 N/A N/A - :pypi:`pytest-pokie` Pokie plugin for pytest May 22, 2023 5 - Production/Stable N/A - :pypi:`pytest-polarion-cfme` pytest plugin for collecting test cases and recording test results Nov 13, 2017 3 - Alpha N/A - :pypi:`pytest-polarion-collect` pytest plugin for collecting polarion test cases data Jun 18, 2020 3 - Alpha pytest - :pypi:`pytest-polecat` Provides Polecat pytest fixtures Aug 12, 2019 4 - Beta N/A - :pypi:`pytest-ponyorm` PonyORM in Pytest Oct 31, 2018 N/A pytest (>=3.1.1) - :pypi:`pytest-poo` Visualize your crappy tests Mar 25, 2021 5 - Production/Stable pytest (>=2.3.4) - :pypi:`pytest-poo-fail` Visualize your failed tests with poo Feb 12, 2015 5 - Production/Stable N/A - :pypi:`pytest-pop` A pytest plugin to help with testing pop projects May 09, 2023 5 - Production/Stable pytest - :pypi:`pytest-portion` Select a portion of the collected tests Jan 28, 2021 4 - Beta pytest (>=3.5.0) - :pypi:`pytest-postgres` Run PostgreSQL in Docker container in Pytest. Mar 22, 2020 N/A pytest - :pypi:`pytest-postgresql` Postgresql fixtures and fixture factories for Pytest. May 20, 2023 5 - Production/Stable pytest (>=6.2) - :pypi:`pytest-pot` A package for enhancing pytest Nov 20, 2022 N/A N/A - :pypi:`pytest-power` pytest plugin with powerful fixtures Dec 31, 2020 N/A pytest (>=5.4) - :pypi:`pytest-prefer-nested-dup-tests` A Pytest plugin to drop duplicated tests during collection, but will prefer keeping nested packages. Apr 27, 2022 4 - Beta pytest (>=7.1.1,<8.0.0) - :pypi:`pytest-pretty` pytest plugin for printing summary data as I want it Apr 05, 2023 5 - Production/Stable pytest>=7 - :pypi:`pytest-pretty-terminal` pytest plugin for generating prettier terminal output Jan 31, 2022 N/A pytest (>=3.4.1) - :pypi:`pytest-pride` Minitest-style test colors Apr 02, 2016 3 - Alpha N/A - :pypi:`pytest-print` pytest-print adds the printer fixture you can use to print messages to the user (directly to the pytest runner, not stdout) Jun 16, 2023 5 - Production/Stable pytest>=7.3.2 - :pypi:`pytest-profiles` pytest plugin for configuration profiles Dec 09, 2021 4 - Beta pytest (>=3.7.0) - :pypi:`pytest-profiling` Profiling plugin for py.test May 28, 2019 5 - Production/Stable pytest - :pypi:`pytest-progress` pytest plugin for instant test progress status Jan 31, 2022 5 - Production/Stable N/A - :pypi:`pytest-prometheus` Report test pass / failures to a Prometheus PushGateway Oct 03, 2017 N/A N/A - :pypi:`pytest-prometheus-pushgateway` Pytest report plugin for Zulip Sep 27, 2022 5 - Production/Stable pytest - :pypi:`pytest-prosper` Test helpers for Prosper projects Sep 24, 2018 N/A N/A - :pypi:`pytest-pspec` A rspec format reporter for Python ptest Jun 02, 2020 4 - Beta pytest (>=3.0.0) - :pypi:`pytest-psqlgraph` pytest plugin for testing applications that use psqlgraph Oct 19, 2021 4 - Beta pytest (>=6.0) - :pypi:`pytest-ptera` Use ptera probes in tests Mar 01, 2022 N/A pytest (>=6.2.4,<7.0.0) - :pypi:`pytest-pudb` Pytest PuDB debugger integration Oct 25, 2018 3 - Alpha pytest (>=2.0) - :pypi:`pytest-pumpkin-spice` A pytest plugin that makes your test reporting pumpkin-spiced Sep 18, 2022 4 - Beta N/A - :pypi:`pytest-purkinje` py.test plugin for purkinje test runner Oct 28, 2017 2 - Pre-Alpha N/A - :pypi:`pytest-pusher` pytest plugin for push report to minio Jan 06, 2023 5 - Production/Stable pytest (>=3.6) - :pypi:`pytest-py125` Dec 03, 2022 N/A N/A - :pypi:`pytest-pycharm` Plugin for py.test to enter PyCharm debugger on uncaught exceptions Aug 13, 2020 5 - Production/Stable pytest (>=2.3) - :pypi:`pytest-pycodestyle` pytest plugin to run pycodestyle Oct 28, 2022 3 - Alpha N/A - :pypi:`pytest-pydev` py.test plugin to connect to a remote debug server with PyDev or PyCharm. Nov 15, 2017 3 - Alpha N/A - :pypi:`pytest-pydocstyle` pytest plugin to run pydocstyle Jan 05, 2023 3 - Alpha N/A - :pypi:`pytest-pylint` pytest plugin to check source code with pylint Sep 10, 2022 5 - Production/Stable pytest (>=5.4) - :pypi:`pytest-pymysql-autorecord` Record PyMySQL queries and mock with the stored data. Sep 02, 2022 N/A N/A - :pypi:`pytest-pyodide` "Pytest plugin for testing applications that use Pyodide" Jun 11, 2023 N/A pytest - :pypi:`pytest-pypi` Easily test your HTTP library against a local copy of pypi Mar 04, 2018 3 - Alpha N/A - :pypi:`pytest-pypom-navigation` Core engine for cookiecutter-qa and pytest-play packages Feb 18, 2019 4 - Beta pytest (>=3.0.7) - :pypi:`pytest-pyppeteer` A plugin to run pyppeteer in pytest Apr 28, 2022 N/A pytest (>=6.2.5,<7.0.0) - :pypi:`pytest-pyq` Pytest fixture "q" for pyq Mar 10, 2020 5 - Production/Stable N/A - :pypi:`pytest-pyramid` pytest_pyramid - provides fixtures for testing pyramid applications with pytest test suite Dec 13, 2022 5 - Production/Stable pytest - :pypi:`pytest-pyramid-server` Pyramid server fixture for py.test May 28, 2019 5 - Production/Stable pytest - :pypi:`pytest-pyreport` PyReport is a lightweight reporting plugin for Pytest that provides concise HTML report May 08, 2023 N/A pytest (>=7.3.1) - :pypi:`pytest-pyright` Pytest plugin for type checking code with Pyright Nov 20, 2022 4 - Beta pytest (>=7.0.0) - :pypi:`pytest-pyspec` A plugin that transforms the pytest output into a result similar to the RSpec. It enables the use of docstrings to display results and also enables the use of the prefixes "describe", "with" and "it". Mar 12, 2023 5 - Production/Stable pytest (>=7.2.1,<8.0.0) - :pypi:`pytest-pystack` Plugin to run pystack after a timeout for a test suite. May 07, 2023 N/A pytest (>=3.5.0) - :pypi:`pytest-pytestrail` Pytest plugin for interaction with TestRail Aug 27, 2020 4 - Beta pytest (>=3.8.0) - :pypi:`pytest-pythonpath` pytest plugin for adding to the PYTHONPATH from command line or configs. Feb 10, 2022 5 - Production/Stable pytest (<7,>=2.5.2) - :pypi:`pytest-pytorch` pytest plugin for a better developer experience when working with the PyTorch test suite May 25, 2021 4 - Beta pytest - :pypi:`pytest-pyvista` Pytest-pyvista package Mar 19, 2023 4 - Beta pytest>=3.5.0 - :pypi:`pytest-qaseio` Pytest plugin for Qase.io integration May 11, 2023 4 - Beta pytest (>=7.2.2,<8.0.0) - :pypi:`pytest-qasync` Pytest support for qasync. Jul 12, 2021 4 - Beta pytest (>=5.4.0) - :pypi:`pytest-qatouch` Pytest plugin for uploading test results to your QA Touch Testrun. Feb 14, 2023 4 - Beta pytest (>=6.2.0) - :pypi:`pytest-qgis` A pytest plugin for testing QGIS python plugins Jun 09, 2023 5 - Production/Stable pytest (>=6.2.5) - :pypi:`pytest-qml` Run QML Tests with pytest Dec 02, 2020 4 - Beta pytest (>=6.0.0) - :pypi:`pytest-qr` pytest plugin to generate test result QR codes Nov 25, 2021 4 - Beta N/A - :pypi:`pytest-qt` pytest support for PyQt and PySide applications Oct 25, 2022 5 - Production/Stable pytest (>=3.0.0) - :pypi:`pytest-qt-app` QT app fixture for py.test Dec 23, 2015 5 - Production/Stable N/A - :pypi:`pytest-quarantine` A plugin for pytest to manage expected test failures Nov 24, 2019 5 - Production/Stable pytest (>=4.6) - :pypi:`pytest-quickcheck` pytest plugin to generate random data inspired by QuickCheck Nov 05, 2022 4 - Beta pytest (>=4.0) - :pypi:`pytest-rabbitmq` RabbitMQ process and client fixtures for pytest Jun 16, 2023 5 - Production/Stable pytest (>=6.2) - :pypi:`pytest-race` Race conditions tester for pytest Jun 07, 2022 4 - Beta N/A - :pypi:`pytest-rage` pytest plugin to implement PEP712 Oct 21, 2011 3 - Alpha N/A - :pypi:`pytest-rail` pytest plugin for creating TestRail runs and adding results May 02, 2022 N/A pytest (>=3.6) - :pypi:`pytest-railflow-testrail-reporter` Generate json reports along with specified metadata defined in test markers. Jun 29, 2022 5 - Production/Stable pytest - :pypi:`pytest-raises` An implementation of pytest.raises as a pytest.mark fixture Apr 23, 2020 N/A pytest (>=3.2.2) - :pypi:`pytest-raisesregexp` Simple pytest plugin to look for regex in Exceptions Dec 18, 2015 N/A N/A - :pypi:`pytest-raisin` Plugin enabling the use of exception instances with pytest.raises Feb 06, 2022 N/A pytest - :pypi:`pytest-random` py.test plugin to randomize tests Apr 28, 2013 3 - Alpha N/A - :pypi:`pytest-randomly` Pytest plugin to randomly order tests and control random.seed. May 11, 2022 5 - Production/Stable pytest - :pypi:`pytest-randomness` Pytest plugin about random seed management May 30, 2019 3 - Alpha N/A - :pypi:`pytest-random-num` Randomise the order in which pytest tests are run with some control over the randomness Oct 19, 2020 5 - Production/Stable N/A - :pypi:`pytest-random-order` Randomise the order in which pytest tests are run with some control over the randomness Dec 03, 2022 5 - Production/Stable pytest (>=3.0.0) - :pypi:`pytest-readme` Test your README.md file Sep 02, 2022 5 - Production/Stable N/A - :pypi:`pytest-reana` Pytest fixtures for REANA. Dec 13, 2022 3 - Alpha N/A - :pypi:`pytest-recorder` Pytest plugin, meant to facilitate unit tests writing for tools consumming Web APIs. Mar 30, 2023 N/A N/A - :pypi:`pytest-recording` A pytest plugin that allows you recording of network interactions via VCR.py Feb 16, 2023 4 - Beta pytest (>=3.5.0) - :pypi:`pytest-recordings` Provides pytest plugins for reporting request/response traffic, screenshots, and more to ReportPortal Aug 13, 2020 N/A N/A - :pypi:`pytest-redis` Redis fixtures and fixture factories for Pytest. Apr 19, 2023 5 - Production/Stable pytest (>=6.2) - :pypi:`pytest-redislite` Pytest plugin for testing code using Redis Apr 05, 2022 4 - Beta pytest - :pypi:`pytest-redmine` Pytest plugin for redmine Mar 19, 2018 1 - Planning N/A - :pypi:`pytest-ref` A plugin to store reference files to ease regression testing Nov 23, 2019 4 - Beta pytest (>=3.5.0) - :pypi:`pytest-reference-formatter` Conveniently run pytest with a dot-formatted test reference. Oct 01, 2019 4 - Beta N/A - :pypi:`pytest-regex` Select pytest tests with regular expressions May 29, 2023 4 - Beta pytest (>=3.5.0) - :pypi:`pytest-regex-dependency` Management of Pytest dependencies via regex patterns Jun 12, 2022 N/A pytest - :pypi:`pytest-regressions` Easy to use fixtures to write regression tests. Jan 13, 2023 5 - Production/Stable pytest (>=6.2.0) - :pypi:`pytest-regtest` pytest plugin for regression tests Jul 08, 2022 N/A N/A - :pypi:`pytest-relative-order` a pytest plugin that sorts tests using "before" and "after" markers May 17, 2021 4 - Beta N/A - :pypi:`pytest-relaxed` Relaxed test discovery/organization for pytest May 23, 2023 5 - Production/Stable pytest (>=7) - :pypi:`pytest-remfiles` Pytest plugin to create a temporary directory with remote files Jul 01, 2019 5 - Production/Stable N/A - :pypi:`pytest-remotedata` Pytest plugin for controlling remote data access. Dec 12, 2022 3 - Alpha pytest (>=4.6) - :pypi:`pytest-remote-response` Pytest plugin for capturing and mocking connection requests. Apr 26, 2023 5 - Production/Stable pytest (>=4.6) - :pypi:`pytest-remove-stale-bytecode` py.test plugin to remove stale byte code files. Mar 04, 2020 4 - Beta pytest - :pypi:`pytest-reorder` Reorder tests depending on their paths and names. May 31, 2018 4 - Beta pytest - :pypi:`pytest-repeat` pytest plugin for repeating tests Oct 31, 2020 5 - Production/Stable pytest (>=3.6) - :pypi:`pytest-replay` Saves previous test runs and allow re-execute previous pytest runs to reproduce crashes or flaky tests Jun 09, 2021 4 - Beta pytest (>=3.0.0) - :pypi:`pytest-repo-health` A pytest plugin to report on repository standards conformance Apr 17, 2023 3 - Alpha pytest - :pypi:`pytest-report` Creates json report that is compatible with atom.io's linter message format May 11, 2016 4 - Beta N/A - :pypi:`pytest-reporter` Generate Pytest reports with templates Jul 22, 2021 4 - Beta pytest - :pypi:`pytest-reporter-html1` A basic HTML report template for Pytest Jun 05, 2023 4 - Beta N/A - :pypi:`pytest-reporter-html-dots` A basic HTML report for pytest using Jinja2 template engine. Jan 22, 2023 N/A N/A - :pypi:`pytest-reportinfra` Pytest plugin for reportinfra Aug 11, 2019 3 - Alpha N/A - :pypi:`pytest-reporting` A plugin to report summarized results in a table format Oct 25, 2019 4 - Beta pytest (>=3.5.0) - :pypi:`pytest-reportlog` Replacement for the --resultlog option, focused in simplicity and extensibility May 22, 2023 3 - Alpha pytest - :pypi:`pytest-report-me` A pytest plugin to generate report. Dec 31, 2020 N/A pytest - :pypi:`pytest-report-parameters` pytest plugin for adding tests' parameters to junit report Jun 18, 2020 3 - Alpha pytest (>=2.4.2) - :pypi:`pytest-reportportal` Agent for Reporting results of tests to the Report Portal Jun 08, 2023 N/A pytest (>=3.8.0) - :pypi:`pytest-reports` An interesting python package Jun 07, 2023 N/A N/A - :pypi:`pytest-reqs` pytest plugin to check pinned requirements May 12, 2019 N/A pytest (>=2.4.2) - :pypi:`pytest-requests` A simple plugin to use with pytest Jun 24, 2019 4 - Beta pytest (>=3.5.0) - :pypi:`pytest-requestselapsed` collect and show http requests elapsed time Aug 14, 2022 N/A N/A - :pypi:`pytest-requests-futures` Pytest Plugin to Mock Requests Futures Jul 06, 2022 5 - Production/Stable pytest - :pypi:`pytest-requires` A pytest plugin to elegantly skip tests with optional requirements Dec 21, 2021 4 - Beta pytest (>=3.5.0) - :pypi:`pytest-reraise` Make multi-threaded pytest test cases fail when they should Sep 20, 2022 5 - Production/Stable pytest (>=4.6) - :pypi:`pytest-rerun` Re-run only changed files in specified branch Jul 08, 2019 N/A pytest (>=3.6) - :pypi:`pytest-rerunfailures` pytest plugin to re-run tests to eliminate flaky failures Mar 09, 2023 5 - Production/Stable pytest (>=5.3) - :pypi:`pytest-rerunfailures-all-logs` pytest plugin to re-run tests to eliminate flaky failures Mar 07, 2022 5 - Production/Stable N/A - :pypi:`pytest-reserial` Pytest fixture for recording and replaying serial port traffic. Apr 26, 2023 4 - Beta pytest - :pypi:`pytest-resilient-circuits` Resilient Circuits fixtures for PyTest Jun 01, 2023 N/A pytest (~=4.6) ; python_version == "2.7" - :pypi:`pytest-resource` Load resource fixture plugin to use with pytest Nov 14, 2018 4 - Beta N/A - :pypi:`pytest-resource-path` Provides path for uniform access to test resources in isolated directory May 01, 2021 5 - Production/Stable pytest (>=3.5.0) - :pypi:`pytest-resource-usage` Pytest plugin for reporting running time and peak memory usage Nov 06, 2022 5 - Production/Stable pytest>=7.0.0 - :pypi:`pytest-responsemock` Simplified requests calls mocking for pytest Mar 10, 2022 5 - Production/Stable N/A - :pypi:`pytest-responses` py.test integration for responses Oct 11, 2022 N/A pytest (>=2.5) - :pypi:`pytest-rest-api` Aug 08, 2022 N/A pytest (>=7.1.2,<8.0.0) - :pypi:`pytest-restrict` Pytest plugin to restrict the test types allowed Jun 16, 2023 5 - Production/Stable pytest - :pypi:`pytest-result-log` Write the execution result of the case to the log Apr 17, 2023 N/A pytest>=7.2.0 - :pypi:`pytest-result-sender` Apr 20, 2023 N/A pytest>=7.3.1 - :pypi:`pytest-resume` A Pytest plugin to resuming from the last run test Apr 22, 2023 4 - Beta pytest (>=7.0) - :pypi:`pytest-rethinkdb` A RethinkDB plugin for pytest. Jul 24, 2016 4 - Beta N/A - :pypi:`pytest-retry` Adds the ability to retry flaky tests in CI environments Aug 16, 2022 N/A pytest (>=7.0.0) - :pypi:`pytest-retry-class` A pytest plugin to rerun entire class on failure Mar 25, 2023 N/A pytest (>=5.3) - :pypi:`pytest-reusable-testcases` Apr 28, 2023 N/A N/A - :pypi:`pytest-reverse` Pytest plugin to reverse test order. Jun 16, 2023 5 - Production/Stable pytest - :pypi:`pytest-rich` Leverage rich for richer test session output Mar 03, 2022 4 - Beta pytest (>=7.0) - :pypi:`pytest-rich-reporter` A pytest plugin using Rich for beautiful test result formatting. Feb 17, 2022 1 - Planning pytest (>=5.0.0) - :pypi:`pytest-richtrace` Nov 05, 2022 N/A pytest (>=7.2.0,<8.0.0) - :pypi:`pytest-ringo` pytest plugin to test webapplications using the Ringo webframework Sep 27, 2017 3 - Alpha N/A - :pypi:`pytest-rmsis` Sycronise pytest results to Jira RMsis Aug 10, 2022 N/A pytest (>=5.3.5) - :pypi:`pytest-rng` Fixtures for seeding tests and making randomness reproducible Aug 08, 2019 5 - Production/Stable pytest - :pypi:`pytest-roast` pytest plugin for ROAST configuration override and fixtures Nov 09, 2022 5 - Production/Stable pytest - :pypi:`pytest-rocketchat` Pytest to Rocket.Chat reporting plugin Apr 18, 2021 5 - Production/Stable N/A - :pypi:`pytest-rotest` Pytest integration with rotest Sep 08, 2019 N/A pytest (>=3.5.0) - :pypi:`pytest-rpc` Extend py.test for RPC OpenStack testing. Feb 22, 2019 4 - Beta pytest (~=3.6) - :pypi:`pytest-rst` Test code from RST documents with pytest Jan 26, 2023 N/A N/A - :pypi:`pytest-rt` pytest data collector plugin for Testgr May 05, 2022 N/A N/A - :pypi:`pytest-rts` Coverage-based regression test selection (RTS) plugin for pytest May 17, 2021 N/A pytest - :pypi:`pytest-ruff` pytest plugin to check ruff requirements. Jun 08, 2023 4 - Beta N/A - :pypi:`pytest-run-changed` Pytest plugin that runs changed tests only Apr 02, 2021 3 - Alpha pytest - :pypi:`pytest-runfailed` implement a --failed option for pytest Mar 24, 2016 N/A N/A - :pypi:`pytest-runner` Invoke py.test as distutils command with dependency resolution Feb 25, 2022 5 - Production/Stable pytest (>=6) ; extra == 'testing' - :pypi:`pytest-run-subprocess` Pytest Plugin for running and testing subprocesses. Nov 12, 2022 5 - Production/Stable pytest - :pypi:`pytest-runtime-types` Checks type annotations on runtime while running tests. Feb 09, 2023 N/A pytest - :pypi:`pytest-runtime-xfail` Call runtime_xfail() to mark running test as xfail. Aug 26, 2021 N/A pytest>=5.0.0 - :pypi:`pytest-runtime-yoyo` run case mark timeout Jun 12, 2023 N/A pytest (>=7.2.0) - :pypi:`pytest-ry-demo1` 测试 Mar 26, 2023 N/A N/A - :pypi:`pytest-saccharin` pytest-saccharin is a updated fork of pytest-sugar, a plugin for pytest that changes the default look and feel of pytest (e.g. progressbar, show tests that fail instantly). Oct 31, 2022 3 - Alpha N/A - :pypi:`pytest-salt` Pytest Salt Plugin Jan 27, 2020 4 - Beta N/A - :pypi:`pytest-salt-containers` A Pytest plugin that builds and creates docker containers Nov 09, 2016 4 - Beta N/A - :pypi:`pytest-salt-factories` Pytest Salt Plugin Dec 15, 2022 4 - Beta pytest (>=6.0.0) - :pypi:`pytest-salt-from-filenames` Simple PyTest Plugin For Salt's Test Suite Specifically Jan 29, 2019 4 - Beta pytest (>=4.1) - :pypi:`pytest-salt-runtests-bridge` Simple PyTest Plugin For Salt's Test Suite Specifically Dec 05, 2019 4 - Beta pytest (>=4.1) - :pypi:`pytest-sanic` a pytest plugin for Sanic Oct 25, 2021 N/A pytest (>=5.2) - :pypi:`pytest-sanity` Dec 07, 2020 N/A N/A - :pypi:`pytest-sa-pg` May 14, 2019 N/A N/A - :pypi:`pytest-sbase` A complete web automation framework for end-to-end testing. Jun 13, 2023 5 - Production/Stable N/A - :pypi:`pytest-scenario` pytest plugin for test scenarios Feb 06, 2017 3 - Alpha N/A - :pypi:`pytest-schedule` The job of test scheduling for humans. Jan 07, 2023 5 - Production/Stable N/A - :pypi:`pytest-schema` 👍 Validate return values against a schema-like object in testing Mar 14, 2022 5 - Production/Stable pytest (>=3.5.0) - :pypi:`pytest-securestore` An encrypted password store for use within pytest cases Nov 08, 2021 4 - Beta N/A - :pypi:`pytest-select` A pytest plugin which allows to (de-)select tests from a file. Jan 18, 2019 3 - Alpha pytest (>=3.0) - :pypi:`pytest-selenium` pytest plugin for Selenium May 28, 2023 5 - Production/Stable pytest>=6.0.0 - :pypi:`pytest-seleniumbase` A complete web automation framework for end-to-end testing. Jun 13, 2023 5 - Production/Stable N/A - :pypi:`pytest-selenium-enhancer` pytest plugin for Selenium Apr 29, 2022 5 - Production/Stable N/A - :pypi:`pytest-selenium-pdiff` A pytest package implementing perceptualdiff for Selenium tests. Apr 06, 2017 2 - Pre-Alpha N/A - :pypi:`pytest-send-email` Send pytest execution result email Dec 04, 2019 N/A N/A - :pypi:`pytest-sentry` A pytest plugin to send testrun information to Sentry.io Jan 05, 2023 N/A N/A - :pypi:`pytest-sequence-markers` Pytest plugin for sequencing markers for execution of tests May 23, 2023 5 - Production/Stable N/A - :pypi:`pytest-server-fixtures` Extensible server fixures for py.test May 28, 2019 5 - Production/Stable pytest - :pypi:`pytest-serverless` Automatically mocks resources from serverless.yml in pytest using moto. May 09, 2022 4 - Beta N/A - :pypi:`pytest-servers` pytest servers Apr 15, 2023 3 - Alpha pytest (>=6.2) - :pypi:`pytest-services` Services plugin for pytest testing framework Oct 30, 2020 6 - Mature N/A - :pypi:`pytest-session2file` pytest-session2file (aka: pytest-session_to_file for v0.1.0 - v0.1.2) is a py.test plugin for capturing and saving to file the stdout of py.test. Jan 26, 2021 3 - Alpha pytest - :pypi:`pytest-session-fixture-globalize` py.test plugin to make session fixtures behave as if written in conftest, even if it is written in some modules May 15, 2018 4 - Beta N/A - :pypi:`pytest-session_to_file` pytest-session_to_file is a py.test plugin for capturing and saving to file the stdout of py.test. Oct 01, 2015 3 - Alpha N/A - :pypi:`pytest-setupinfo` Displaying setup info during pytest command run Jan 23, 2023 N/A N/A - :pypi:`pytest-sftpserver` py.test plugin to locally test sftp server connections. Sep 16, 2019 4 - Beta N/A - :pypi:`pytest-shard` Dec 11, 2020 4 - Beta pytest - :pypi:`pytest-share-hdf` Plugin to save test data in HDF files and retrieve them for comparison Sep 21, 2022 4 - Beta pytest (>=3.5.0) - :pypi:`pytest-sharkreport` this is pytest report plugin. Jul 11, 2022 N/A pytest (>=3.5) - :pypi:`pytest-shell` A pytest plugin to help with testing shell scripts / black box commands Mar 27, 2022 N/A N/A - :pypi:`pytest-shell-utilities` Pytest plugin to simplify running shell commands against the system Sep 23, 2022 4 - Beta pytest (>=6.0.0) - :pypi:`pytest-sheraf` Versatile ZODB abstraction layer - pytest fixtures Feb 11, 2020 N/A pytest - :pypi:`pytest-sherlock` pytest plugin help to find coupled tests Jan 16, 2023 5 - Production/Stable pytest (>=3.5.1) - :pypi:`pytest-shortcuts` Expand command-line shortcuts listed in pytest configuration Oct 29, 2020 4 - Beta pytest (>=3.5.0) - :pypi:`pytest-shutil` A goodie-bag of unix shell and environment tools for py.test May 28, 2019 5 - Production/Stable pytest - :pypi:`pytest-simplehttpserver` Simple pytest fixture to spin up an HTTP server Jun 24, 2021 4 - Beta N/A - :pypi:`pytest-simple-plugin` Simple pytest plugin Nov 27, 2019 N/A N/A - :pypi:`pytest-simple-settings` simple-settings plugin for pytest Nov 17, 2020 4 - Beta pytest - :pypi:`pytest-single-file-logging` Allow for multiple processes to log to a single file May 05, 2016 4 - Beta pytest (>=2.8.1) - :pypi:`pytest-skip-markers` Pytest Salt Plugin Dec 20, 2022 5 - Production/Stable pytest (>=7.1.0) - :pypi:`pytest-skipper` A plugin that selects only tests with changes in execution path Mar 26, 2017 3 - Alpha pytest (>=3.0.6) - :pypi:`pytest-skippy` Automatically skip tests that don't need to run! Jan 27, 2018 3 - Alpha pytest (>=2.3.4) - :pypi:`pytest-skip-slow` A pytest plugin to skip \`@pytest.mark.slow\` tests by default. Feb 09, 2023 N/A pytest>=6.2.0 - :pypi:`pytest-slack` Pytest to Slack reporting plugin Dec 15, 2020 5 - Production/Stable N/A - :pypi:`pytest-slow` A pytest plugin to skip \`@pytest.mark.slow\` tests by default. Sep 28, 2021 N/A N/A - :pypi:`pytest-slowest-first` Sort tests by their last duration, slowest first Dec 11, 2022 4 - Beta N/A - :pypi:`pytest-slow-last` Run tests in order of execution time (faster tests first) Dec 10, 2022 4 - Beta pytest (>=3.5.0) - :pypi:`pytest-smartcollect` A plugin for collecting tests that touch changed code Oct 04, 2018 N/A pytest (>=3.5.0) - :pypi:`pytest-smartcov` Smart coverage plugin for pytest. Sep 30, 2017 3 - Alpha N/A - :pypi:`pytest-smell` Automated bad smell detection tool for Pytest Jun 26, 2022 N/A N/A - :pypi:`pytest-smtp` Send email with pytest execution result Feb 20, 2021 N/A pytest - :pypi:`pytest-smtpd` An SMTP server for testing built on aiosmtpd May 15, 2023 N/A pytest - :pypi:`pytest-snail` Plugin for adding a marker to slow running tests. 🐌 Nov 04, 2019 3 - Alpha pytest (>=5.0.1) - :pypi:`pytest-snapci` py.test plugin for Snap-CI Nov 12, 2015 N/A N/A - :pypi:`pytest-snapshot` A plugin for snapshot testing with pytest. Apr 23, 2022 4 - Beta pytest (>=3.0.0) - :pypi:`pytest-snmpserver` May 12, 2021 N/A N/A - :pypi:`pytest-snowflake-bdd` Setup test data and run tests on snowflake in BDD style! Jan 05, 2022 4 - Beta pytest (>=6.2.0) - :pypi:`pytest-socket` Pytest Plugin to disable socket calls during tests Feb 03, 2023 4 - Beta pytest (>=3.6.3) - :pypi:`pytest-sofaepione` Test the installation of SOFA and the SofaEpione plugin. Aug 17, 2022 N/A N/A - :pypi:`pytest-soft-assertions` May 05, 2020 3 - Alpha pytest - :pypi:`pytest-solidity` A PyTest library plugin for Solidity language. Jan 15, 2022 1 - Planning pytest (<7,>=6.0.1) ; extra == 'tests' - :pypi:`pytest-solr` Solr process and client fixtures for py.test. May 11, 2020 3 - Alpha pytest (>=3.0.0) - :pypi:`pytest-sorter` A simple plugin to first execute tests that historically failed more Apr 20, 2021 4 - Beta pytest (>=3.1.1) - :pypi:`pytest-sosu` Unofficial PyTest plugin for Sauce Labs Feb 14, 2023 2 - Pre-Alpha pytest - :pypi:`pytest-sourceorder` Test-ordering plugin for pytest Sep 01, 2021 4 - Beta pytest - :pypi:`pytest-spark` pytest plugin to run the tests with support of pyspark. Feb 23, 2020 4 - Beta pytest - :pypi:`pytest-spawner` py.test plugin to spawn process and communicate with them. Jul 31, 2015 4 - Beta N/A - :pypi:`pytest-spec` Library pytest-spec is a pytest plugin to display test execution output like a SPECIFICATION. May 04, 2021 N/A N/A - :pypi:`pytest-spec2md` Library pytest-spec2md is a pytest plugin to create a markdown specification while running pytest. Jun 26, 2022 N/A pytest (>7.0) - :pypi:`pytest-speed` Modern benchmarking library for python with pytest integration. Jan 22, 2023 3 - Alpha pytest>=7 - :pypi:`pytest-sphinx` Doctest plugin for pytest with support for Sphinx-specific doctest-directives Sep 06, 2022 4 - Beta pytest (>=7.0.0) - :pypi:`pytest-spiratest` Exports unit tests as test runs in SpiraTest/Team/Plan Feb 08, 2022 N/A N/A - :pypi:`pytest-splinter` Splinter plugin for pytest testing framework Sep 09, 2022 6 - Mature pytest (>=3.0.0) - :pypi:`pytest-splinter4` Pytest plugin for the splinter automation library Jun 11, 2022 6 - Mature pytest (<8.0,>=7.1.2) - :pypi:`pytest-split` Pytest plugin which splits the test suite to equally sized sub suites based on test execution time. Apr 12, 2023 4 - Beta pytest (>=5,<8) - :pypi:`pytest-splitio` Split.io SDK integration for e2e tests Sep 22, 2020 N/A pytest (<7,>=5.0) - :pypi:`pytest-split-tests` A Pytest plugin for running a subset of your tests by splitting them in to equally sized groups. Forked from Mark Adams' original project pytest-test-groups. Jul 30, 2021 5 - Production/Stable pytest (>=2.5) - :pypi:`pytest-split-tests-tresorit` Feb 22, 2021 1 - Planning N/A - :pypi:`pytest-splunk-addon` A Dynamic test tool for Splunk Apps and Add-ons Feb 22, 2023 N/A pytest (>5.4.0,<7.3) - :pypi:`pytest-splunk-addon-ui-smartx` Library to support testing Splunk Add-on UX Mar 07, 2023 N/A N/A - :pypi:`pytest-splunk-env` pytest fixtures for interaction with Splunk Enterprise and Splunk Cloud Oct 22, 2020 N/A pytest (>=6.1.1,<7.0.0) - :pypi:`pytest-sqitch` sqitch for pytest Apr 06, 2020 4 - Beta N/A - :pypi:`pytest-sqlalchemy` pytest plugin with sqlalchemy related fixtures Mar 13, 2018 3 - Alpha N/A - :pypi:`pytest-sqlalchemy-mock` pytest sqlalchemy plugin for mock Mar 15, 2023 3 - Alpha pytest (>=2.0) - :pypi:`pytest-sqlalchemy-session` A pytest plugin for preserving test isolation that use SQLAlchemy. May 19, 2023 4 - Beta pytest (>=7.0) - :pypi:`pytest-sql-bigquery` Yet another SQL-testing framework for BigQuery provided by pytest plugin Dec 19, 2019 N/A pytest - :pypi:`pytest-sqlfluff` A pytest plugin to use sqlfluff to enable format checking of sql files. Dec 21, 2022 4 - Beta pytest (>=3.5.0) - :pypi:`pytest-squadcast` Pytest report plugin for Squadcast Feb 22, 2022 5 - Production/Stable pytest - :pypi:`pytest-srcpaths` Add paths to sys.path Oct 15, 2021 N/A pytest>=6.2.0 - :pypi:`pytest-ssh` pytest plugin for ssh command run May 27, 2019 N/A pytest - :pypi:`pytest-start-from` Start pytest run from a given point Apr 11, 2016 N/A N/A - :pypi:`pytest-star-track-issue` A package to prevent Dependency Confusion attacks against Yandex. Feb 10, 2023 N/A N/A - :pypi:`pytest-static` pytest-static May 07, 2023 1 - Planning N/A - :pypi:`pytest-statsd` pytest plugin for reporting to graphite Nov 30, 2018 5 - Production/Stable pytest (>=3.0.0) - :pypi:`pytest-stepfunctions` A small description May 08, 2021 4 - Beta pytest - :pypi:`pytest-steps` Create step-wise / incremental tests in pytest. Sep 23, 2021 5 - Production/Stable N/A - :pypi:`pytest-stepwise` Run a test suite one failing test at a time. Dec 01, 2015 4 - Beta N/A - :pypi:`pytest-stf` pytest plugin for openSTF Dec 04, 2022 N/A pytest (>=5.0) - :pypi:`pytest-stoq` A plugin to pytest stoq Feb 09, 2021 4 - Beta N/A - :pypi:`pytest-stress` A Pytest plugin that allows you to loop tests for a user defined amount of time. Dec 07, 2019 4 - Beta pytest (>=3.6.0) - :pypi:`pytest-structlog` Structured logging assertions Dec 18, 2022 N/A pytest - :pypi:`pytest-structmpd` provide structured temporary directory Oct 17, 2018 N/A N/A - :pypi:`pytest-stub` Stub packages, modules and attributes. Apr 28, 2020 5 - Production/Stable N/A - :pypi:`pytest-stubprocess` Provide stub implementations for subprocesses in Python tests Sep 17, 2018 3 - Alpha pytest (>=3.5.0) - :pypi:`pytest-study` A pytest plugin to organize long run tests (named studies) without interfering the regular tests Sep 26, 2017 3 - Alpha pytest (>=2.0) - :pypi:`pytest-subprocess` A plugin to fake subprocess for pytest Jan 28, 2023 5 - Production/Stable pytest (>=4.0.0) - :pypi:`pytest-subtesthack` A hack to explicitly set up and tear down fixtures. Jul 16, 2022 N/A N/A - :pypi:`pytest-subtests` unittest subTest() support and subtests fixture May 15, 2023 4 - Beta pytest (>=7.0) - :pypi:`pytest-subunit` pytest-subunit is a plugin for py.test which outputs testsresult in subunit format. Aug 29, 2017 N/A N/A - :pypi:`pytest-sugar` pytest-sugar is a plugin for pytest that changes the default look and feel of pytest (e.g. progressbar, show tests that fail instantly). Apr 10, 2023 4 - Beta pytest (>=6.2.0) - :pypi:`pytest-suitemanager` A simple plugin to use with pytest Apr 28, 2023 4 - Beta N/A - :pypi:`pytest-svn` SVN repository fixture for py.test May 28, 2019 5 - Production/Stable pytest - :pypi:`pytest-symbols` pytest-symbols is a pytest plugin that adds support for passing test environment symbols into pytest tests. Nov 20, 2017 3 - Alpha N/A - :pypi:`pytest-system-statistics` Pytest plugin to track and report system usage statistics Feb 16, 2022 5 - Production/Stable pytest (>=6.0.0) - :pypi:`pytest-system-test-plugin` Pyst - Pytest System-Test Plugin Feb 03, 2022 N/A N/A - :pypi:`pytest-tagging` a pytest plugin to tag tests Apr 01, 2023 N/A pytest (>=7.1.3,<8.0.0) - :pypi:`pytest-takeltest` Fixtures for ansible, testinfra and molecule Feb 15, 2023 N/A N/A - :pypi:`pytest-talisker` Nov 28, 2021 N/A N/A - :pypi:`pytest-tally` A Pytest plugin to generate realtime summary stats, and display them in-console using a text-based dashboard. May 22, 2023 4 - Beta pytest (>=6.2.5) - :pypi:`pytest-tap` Test Anything Protocol (TAP) reporting plugin for pytest Oct 27, 2021 5 - Production/Stable pytest (>=3.0) - :pypi:`pytest-tape` easy assertion with expected results saved to yaml files Mar 17, 2021 4 - Beta N/A - :pypi:`pytest-target` Pytest plugin for remote target orchestration. Jan 21, 2021 3 - Alpha pytest (>=6.1.2,<7.0.0) - :pypi:`pytest-tblineinfo` tblineinfo is a py.test plugin that insert the node id in the final py.test report when --tb=line option is used Dec 01, 2015 3 - Alpha pytest (>=2.0) - :pypi:`pytest-tcpclient` A pytest plugin for testing TCP clients Nov 16, 2022 N/A pytest (<8,>=7.1.3) - :pypi:`pytest-teamcity-logblock` py.test plugin to introduce block structure in teamcity build log, if output is not captured May 15, 2018 4 - Beta N/A - :pypi:`pytest-telegram` Pytest to Telegram reporting plugin Dec 10, 2020 5 - Production/Stable N/A - :pypi:`pytest-telegram-notifier` Telegram notification plugin for Pytest Mar 17, 2023 5 - Production/Stable N/A - :pypi:`pytest-tempdir` Predictable and repeatable tempdir support. Oct 11, 2019 4 - Beta pytest (>=2.8.1) - :pypi:`pytest-terra-fixt` Terraform and Terragrunt fixtures for pytest Sep 15, 2022 N/A pytest (==6.2.5) - :pypi:`pytest-terraform` A pytest plugin for using terraform fixtures Sep 01, 2022 N/A pytest (>=6.0) - :pypi:`pytest-terraform-fixture` generate terraform resources to use with pytest Nov 14, 2018 4 - Beta N/A - :pypi:`pytest-testbook` A plugin to run tests written in Jupyter notebook Dec 11, 2016 3 - Alpha N/A - :pypi:`pytest-testconfig` Test configuration plugin for pytest. Jan 11, 2020 4 - Beta pytest (>=3.5.0) - :pypi:`pytest-testdirectory` A py.test plugin providing temporary directories in unit tests. May 02, 2023 5 - Production/Stable pytest - :pypi:`pytest-testdox` A testdox format reporter for pytest Apr 19, 2022 5 - Production/Stable pytest (>=4.6.0) - :pypi:`pytest-test-grouping` A Pytest plugin for running a subset of your tests by splitting them in to equally sized groups. Feb 01, 2023 5 - Production/Stable pytest (>=2.5) - :pypi:`pytest-test-groups` A Pytest plugin for running a subset of your tests by splitting them in to equally sized groups. Oct 25, 2016 5 - Production/Stable N/A - :pypi:`pytest-testinfra` Test infrastructures May 21, 2023 5 - Production/Stable pytest (!=3.0.2) - :pypi:`pytest-testlink-adaptor` pytest reporting plugin for testlink Dec 20, 2018 4 - Beta pytest (>=2.6) - :pypi:`pytest-testmon` selects tests affected by changed files and methods Jun 16, 2023 4 - Beta N/A - :pypi:`pytest-testmon-dev` selects tests affected by changed files and methods Mar 30, 2023 4 - Beta pytest (<8,>=5) - :pypi:`pytest-testmon-oc` nOly selects tests affected by changed files and methods Jun 01, 2022 4 - Beta pytest (<8,>=5) - :pypi:`pytest-testmon-skip-libraries` selects tests affected by changed files and methods Mar 03, 2023 4 - Beta pytest (<8,>=5) - :pypi:`pytest-testobject` Plugin to use TestObject Suites with Pytest Sep 24, 2019 4 - Beta pytest (>=3.1.1) - :pypi:`pytest-testpluggy` set your encoding Jan 07, 2022 N/A pytest - :pypi:`pytest-testrail` pytest plugin for creating TestRail runs and adding results Aug 27, 2020 N/A pytest (>=3.6) - :pypi:`pytest-testrail2` A pytest plugin to upload results to TestRail. Feb 10, 2023 N/A pytest (<8.0,>=7.2.0) - :pypi:`pytest-testrail-api-client` TestRail Api Python Client Dec 14, 2021 N/A pytest - :pypi:`pytest-testrail-appetize` pytest plugin for creating TestRail runs and adding results Sep 29, 2021 N/A N/A - :pypi:`pytest-testrail-client` pytest plugin for Testrail Sep 29, 2020 5 - Production/Stable N/A - :pypi:`pytest-testrail-e2e` pytest plugin for creating TestRail runs and adding results Oct 11, 2021 N/A pytest (>=3.6) - :pypi:`pytest-testrail-integrator` Pytest plugin for sending report to testrail system. Aug 01, 2022 N/A pytest (>=6.2.5) - :pypi:`pytest-testrail-ns` pytest plugin for creating TestRail runs and adding results Aug 12, 2022 N/A N/A - :pypi:`pytest-testrail-plugin` PyTest plugin for TestRail Apr 21, 2020 3 - Alpha pytest - :pypi:`pytest-testrail-reporter` Sep 10, 2018 N/A N/A - :pypi:`pytest-testreport` Dec 01, 2022 4 - Beta pytest (>=3.5.0) - :pypi:`pytest-testreport-new` Aug 15, 2022 4 - Beta pytest (>=3.5.0) - :pypi:`pytest-testslide` TestSlide fixture for pytest Jan 07, 2021 5 - Production/Stable pytest (~=6.2) - :pypi:`pytest-test-this` Plugin for py.test to run relevant tests, based on naively checking if a test contains a reference to the symbol you supply Sep 15, 2019 2 - Pre-Alpha pytest (>=2.3) - :pypi:`pytest-test-utils` Jul 14, 2022 N/A pytest (>=5) - :pypi:`pytest-tesults` Tesults plugin for pytest Dec 23, 2022 5 - Production/Stable pytest (>=3.5.0) - :pypi:`pytest-tezos` pytest-ligo Jan 16, 2020 4 - Beta N/A - :pypi:`pytest-th2-bdd` pytest_th2_bdd May 13, 2022 N/A N/A - :pypi:`pytest-thawgun` Pytest plugin for time travel May 26, 2020 3 - Alpha N/A - :pypi:`pytest-threadleak` Detects thread leaks Jul 03, 2022 4 - Beta pytest (>=3.1.1) - :pypi:`pytest-tick` Ticking on tests Aug 31, 2021 5 - Production/Stable pytest (>=6.2.5,<7.0.0) - :pypi:`pytest-time` Jun 16, 2023 3 - Alpha pytest - :pypi:`pytest-timeit` A pytest plugin to time test function runs Oct 13, 2016 4 - Beta N/A - :pypi:`pytest-timeout` pytest plugin to abort hanging tests Jan 18, 2022 5 - Production/Stable pytest (>=5.0.0) - :pypi:`pytest-timeouts` Linux-only Pytest plugin to control durations of various test case execution phases Sep 21, 2019 5 - Production/Stable N/A - :pypi:`pytest-timer` A timer plugin for pytest Jun 02, 2021 N/A N/A - :pypi:`pytest-timestamper` Pytest plugin to add a timestamp prefix to the pytest output Jun 06, 2021 N/A N/A - :pypi:`pytest-timestamps` A simple plugin to view timestamps for each test Apr 01, 2023 N/A pytest (>=5.2) - :pypi:`pytest-tipsi-django` Nov 17, 2021 4 - Beta pytest (>=6.0.0) - :pypi:`pytest-tipsi-testing` Better fixtures management. Various helpers Nov 04, 2020 4 - Beta pytest (>=3.3.0) - :pypi:`pytest-tldr` A pytest plugin that limits the output to just the things you need. Oct 26, 2022 4 - Beta pytest (>=3.5.0) - :pypi:`pytest-tm4j-reporter` Cloud Jira Test Management (TM4J) PyTest reporter plugin Sep 01, 2020 N/A pytest - :pypi:`pytest-tmnet` A small example package Mar 01, 2022 N/A N/A - :pypi:`pytest-tmp-files` Utilities to create temporary file hierarchies in pytest. Apr 03, 2022 N/A pytest - :pypi:`pytest-tmpfs` A pytest plugin that helps you on using a temporary filesystem for testing. Aug 29, 2022 N/A pytest - :pypi:`pytest-tmreport` this is a vue-element ui report for pytest Aug 12, 2022 N/A N/A - :pypi:`pytest-tmux` A pytest plugin that enables tmux driven tests Apr 22, 2023 4 - Beta N/A - :pypi:`pytest-todo` A small plugin for the pytest testing framework, marking TODO comments as failure May 23, 2019 4 - Beta pytest - :pypi:`pytest-tomato` Mar 01, 2019 5 - Production/Stable N/A - :pypi:`pytest-toolbelt` This is just a collection of utilities for pytest, but don't really belong in pytest proper. Aug 12, 2019 3 - Alpha N/A - :pypi:`pytest-toolbox` Numerous useful plugins for pytest. Apr 07, 2018 N/A pytest (>=3.5.0) - :pypi:`pytest-tools` Pytest tools Oct 21, 2022 4 - Beta N/A - :pypi:`pytest-tornado` A py.test plugin providing fixtures and markers to simplify testing of asynchronous tornado applications. Jun 17, 2020 5 - Production/Stable pytest (>=3.6) - :pypi:`pytest-tornado5` A py.test plugin providing fixtures and markers to simplify testing of asynchronous tornado applications. Nov 16, 2018 5 - Production/Stable pytest (>=3.6) - :pypi:`pytest-tornado-yen3` A py.test plugin providing fixtures and markers to simplify testing of asynchronous tornado applications. Oct 15, 2018 5 - Production/Stable N/A - :pypi:`pytest-tornasync` py.test plugin for testing Python 3.5+ Tornado code Jul 15, 2019 3 - Alpha pytest (>=3.0) - :pypi:`pytest-trace` Save OpenTelemetry spans generated during testing Jun 19, 2022 N/A pytest (>=4.6) - :pypi:`pytest-track` Feb 26, 2021 3 - Alpha pytest (>=3.0) - :pypi:`pytest-translations` Test your translation files. Nov 05, 2021 5 - Production/Stable N/A - :pypi:`pytest-travis-fold` Folds captured output sections in Travis CI build log Nov 29, 2017 4 - Beta pytest (>=2.6.0) - :pypi:`pytest-trello` Plugin for py.test that integrates trello using markers Nov 20, 2015 5 - Production/Stable N/A - :pypi:`pytest-trepan` Pytest plugin for trepan debugger. Jul 28, 2018 5 - Production/Stable N/A - :pypi:`pytest-trialtemp` py.test plugin for using the same _trial_temp working directory as trial Jun 08, 2015 N/A N/A - :pypi:`pytest-trio` Pytest plugin for trio Nov 01, 2022 N/A pytest (>=7.2.0) - :pypi:`pytest-trytond` Pytest plugin for the Tryton server framework Nov 04, 2022 4 - Beta pytest (>=5) - :pypi:`pytest-tspwplib` A simple plugin to use with tspwplib Jan 08, 2021 4 - Beta pytest (>=3.5.0) - :pypi:`pytest-tst` Customize pytest options, output and exit code to make it compatible with tst Apr 27, 2022 N/A pytest (>=5.0.0) - :pypi:`pytest-tstcls` Test Class Base Mar 23, 2020 5 - Production/Stable N/A - :pypi:`pytest-tui` Text User Interface (TUI) and HTML report for Pytest test runs Jun 12, 2023 4 - Beta N/A - :pypi:`pytest-tutorials` Mar 11, 2023 N/A N/A - :pypi:`pytest-twilio-conversations-client-mock` Aug 02, 2022 N/A N/A - :pypi:`pytest-twisted` A twisted plugin for pytest. Oct 16, 2022 5 - Production/Stable pytest (>=2.3) - :pypi:`pytest-typechecker` Run type checkers on specified test files Feb 04, 2022 N/A pytest (>=6.2.5,<7.0.0) - :pypi:`pytest-typhoon-config` A Typhoon HIL plugin that facilitates test parameter configuration at runtime Apr 07, 2022 5 - Production/Stable N/A - :pypi:`pytest-typhoon-xray` Typhoon HIL plugin for pytest Jun 10, 2023 4 - Beta N/A - :pypi:`pytest-tytest` Typhoon HIL plugin for pytest May 25, 2020 4 - Beta pytest (>=5.4.2) - :pypi:`pytest-ubersmith` Easily mock calls to ubersmith at the \`requests\` level. Apr 13, 2015 N/A N/A - :pypi:`pytest-ui` Text User Interface for running python tests Jul 05, 2021 4 - Beta pytest - :pypi:`pytest-ui-failed-screenshot` UI自动测试失败时自动截图,并将截图加入到测试报告中 Dec 06, 2022 N/A N/A - :pypi:`pytest-ui-failed-screenshot-allure` UI自动测试失败时自动截图,并将截图加入到Allure测试报告中 Dec 06, 2022 N/A N/A - :pypi:`pytest-unflakable` Unflakable plugin for PyTest Mar 24, 2023 4 - Beta pytest (>=6.2.0) - :pypi:`pytest-unhandled-exception-exit-code` Plugin for py.test set a different exit code on uncaught exceptions Jun 22, 2020 5 - Production/Stable pytest (>=2.3) - :pypi:`pytest-unittest-filter` A pytest plugin for filtering unittest-based test classes Jan 12, 2019 4 - Beta pytest (>=3.1.0) - :pypi:`pytest-unmarked` Run only unmarked tests Aug 27, 2019 5 - Production/Stable N/A - :pypi:`pytest-unordered` Test equality of unordered collections in pytest Nov 28, 2022 4 - Beta pytest (>=6.0.0) - :pypi:`pytest-unstable` Set a test as unstable to return 0 even if it failed Sep 27, 2022 4 - Beta N/A - :pypi:`pytest-unused-fixtures` A pytest plugin to list unused fixtures after a test run. Jun 15, 2023 4 - Beta pytest (>=7.3.2,<8.0.0) - :pypi:`pytest-upload-report` pytest-upload-report is a plugin for pytest that upload your test report for test results. Jun 18, 2021 5 - Production/Stable N/A - :pypi:`pytest-utils` Some helpers for pytest. Feb 02, 2023 4 - Beta pytest (>=7.0.0,<8.0.0) - :pypi:`pytest-vagrant` A py.test plugin providing access to vagrant. Sep 07, 2021 5 - Production/Stable pytest - :pypi:`pytest-valgrind` May 19, 2021 N/A N/A - :pypi:`pytest-variables` pytest plugin for providing variables to tests/fixtures May 27, 2023 5 - Production/Stable pytest>=7.0.0 - :pypi:`pytest-variant` Variant support for Pytest Jun 06, 2022 N/A N/A - :pypi:`pytest-vcr` Plugin for managing VCR.py cassettes Apr 26, 2019 5 - Production/Stable pytest (>=3.6.0) - :pypi:`pytest-vcr-delete-on-fail` A pytest plugin that automates vcrpy cassettes deletion on test failure. Jun 20, 2022 5 - Production/Stable pytest (>=6.2.2) - :pypi:`pytest-vcrpandas` Test from HTTP interactions to dataframe processed. Jan 12, 2019 4 - Beta pytest - :pypi:`pytest-vcs` Sep 22, 2022 4 - Beta N/A - :pypi:`pytest-venv` py.test fixture for creating a virtual environment Aug 04, 2020 4 - Beta pytest - :pypi:`pytest-ver` Pytest module with Verification Protocol, Verification Report and Trace Matrix Mar 22, 2023 4 - Beta N/A - :pypi:`pytest-verbose-parametrize` More descriptive output for parametrized py.test tests May 28, 2019 5 - Production/Stable pytest - :pypi:`pytest-vimqf` A simple pytest plugin that will shrink pytest output when specified, to fit vim quickfix window. Feb 08, 2021 4 - Beta pytest (>=6.2.2,<7.0.0) - :pypi:`pytest-virtualenv` Virtualenv fixture for py.test May 28, 2019 5 - Production/Stable pytest - :pypi:`pytest-vnc` VNC client for Pytest Feb 25, 2023 N/A pytest - :pypi:`pytest-voluptuous` Pytest plugin for asserting data against voluptuous schema. Jun 09, 2020 N/A pytest - :pypi:`pytest-vscodedebug` A pytest plugin to easily enable debugging tests within Visual Studio Code Dec 04, 2020 4 - Beta N/A - :pypi:`pytest-vscode-pycharm-cls` A PyTest helper to enable start remote debugger on test start or failure or when pytest.set_trace is used. Feb 01, 2023 N/A pytest - :pypi:`pytest-vts` pytest plugin for automatic recording of http stubbed tests Jun 05, 2019 N/A pytest (>=2.3) - :pypi:`pytest-vulture` A pytest plugin to checks dead code with vulture Jun 01, 2023 N/A pytest (>=7.0.0) - :pypi:`pytest-vw` pytest-vw makes your failing test cases succeed under CI tools scrutiny Oct 07, 2015 4 - Beta N/A - :pypi:`pytest-vyper` Plugin for the vyper smart contract language. May 28, 2020 2 - Pre-Alpha N/A - :pypi:`pytest-wa-e2e-plugin` Pytest plugin for testing whatsapp bots with end to end tests Feb 18, 2020 4 - Beta pytest (>=3.5.0) - :pypi:`pytest-wake` May 11, 2023 N/A pytest - :pypi:`pytest-watch` Local continuous test runner with pytest and watchdog. May 20, 2018 N/A N/A - :pypi:`pytest-watcher` Automatically rerun your tests on file modifications Jun 11, 2023 4 - Beta N/A - :pypi:`pytest-wdl` Pytest plugin for testing WDL workflows. Nov 17, 2020 5 - Production/Stable N/A - :pypi:`pytest-web3-data` Sep 15, 2022 4 - Beta pytest - :pypi:`pytest-webdriver` Selenium webdriver fixture for py.test May 28, 2019 5 - Production/Stable pytest - :pypi:`pytest-wetest` Welian API Automation test framework pytest plugin Nov 10, 2018 4 - Beta N/A - :pypi:`pytest-when` Utility which makes mocking more readable and controllable Jun 05, 2023 N/A pytest>=7.3.1 - :pypi:`pytest-whirlwind` Testing Tornado. Jun 12, 2020 N/A N/A - :pypi:`pytest-wholenodeid` pytest addon for displaying the whole node id for failures Aug 26, 2015 4 - Beta pytest (>=2.0) - :pypi:`pytest-win32consoletitle` Pytest progress in console title (Win32 only) Aug 08, 2021 N/A N/A - :pypi:`pytest-winnotify` Windows tray notifications for py.test results. Apr 22, 2016 N/A N/A - :pypi:`pytest-wiremock` A pytest plugin for programmatically using wiremock in integration tests Mar 27, 2022 N/A pytest (>=7.1.1,<8.0.0) - :pypi:`pytest-with-docker` pytest with docker helpers. Nov 09, 2021 N/A pytest - :pypi:`pytest-workflow` A pytest plugin for configuring workflow/pipeline tests using YAML files Jan 13, 2023 5 - Production/Stable pytest (>=7.0.0) - :pypi:`pytest-xdist` pytest xdist plugin for distributed testing, most importantly across multiple CPUs May 19, 2023 5 - Production/Stable pytest (>=6.2.0) - :pypi:`pytest-xdist-debug-for-graingert` pytest xdist plugin for distributed testing and loop-on-failing modes Jul 24, 2019 5 - Production/Stable pytest (>=4.4.0) - :pypi:`pytest-xdist-forked` forked from pytest-xdist Feb 10, 2020 5 - Production/Stable pytest (>=4.4.0) - :pypi:`pytest-xdist-tracker` pytest plugin helps to reproduce failures for particular xdist node Nov 18, 2021 3 - Alpha pytest (>=3.5.1) - :pypi:`pytest-xdist-worker-stats` A pytest plugin to list worker statistics after a xdist run. Jun 15, 2023 4 - Beta pytest (>=7.3.2,<8.0.0) - :pypi:`pytest-xfaillist` Maintain a xfaillist in an additional file to avoid merge-conflicts. Sep 17, 2021 N/A pytest (>=6.2.2,<7.0.0) - :pypi:`pytest-xfiles` Pytest fixtures providing data read from function, module or package related (x)files. Feb 27, 2018 N/A N/A - :pypi:`pytest-xlog` Extended logging for test and decorators May 31, 2020 4 - Beta N/A - :pypi:`pytest-xlsx` pytest plugin for generating test cases by xlsx(excel) Mar 01, 2023 N/A pytest>=7.2.0 - :pypi:`pytest-xpara` An extended parametrizing plugin of pytest. Oct 30, 2017 3 - Alpha pytest - :pypi:`pytest-xprocess` A pytest plugin for managing processes across test runs. Jan 05, 2023 4 - Beta pytest (>=2.8) - :pypi:`pytest-xray` May 30, 2019 3 - Alpha N/A - :pypi:`pytest-xrayjira` Mar 17, 2020 3 - Alpha pytest (==4.3.1) - :pypi:`pytest-xray-server` May 03, 2022 3 - Alpha pytest (>=5.3.1) - :pypi:`pytest-xskynet` A package to prevent Dependency Confusion attacks against Yandex. Feb 10, 2023 N/A N/A - :pypi:`pytest-xvfb` A pytest plugin to run Xvfb (or Xephyr/Xvnc) for tests. May 29, 2023 4 - Beta pytest (>=2.8.1) - :pypi:`pytest-yaml` This plugin is used to load yaml output to your test using pytest framework. Oct 05, 2018 N/A pytest - :pypi:`pytest-yaml-sanmu` pytest plugin for generating test cases by yaml May 28, 2023 N/A pytest>=7.2.0 - :pypi:`pytest-yamltree` Create or check file/directory trees described by YAML Mar 02, 2020 4 - Beta pytest (>=3.1.1) - :pypi:`pytest-yamlwsgi` Run tests against wsgi apps defined in yaml May 11, 2010 N/A N/A - :pypi:`pytest-yaml-yoyo` http/https API run by yaml Jun 08, 2023 N/A pytest (>=7.2.0) - :pypi:`pytest-yapf` Run yapf Jul 06, 2017 4 - Beta pytest (>=3.1.1) - :pypi:`pytest-yapf3` Validate your Python file format with yapf Mar 29, 2023 5 - Production/Stable pytest (>=7) - :pypi:`pytest-yield` PyTest plugin to run tests concurrently, each \`yield\` switch context to other one Jan 23, 2019 N/A N/A - :pypi:`pytest-yls` Pytest plugin to test the YLS as a whole. Mar 29, 2023 N/A pytest (>=7.2.2,<8.0.0) - :pypi:`pytest-yuk` Display tests you are uneasy with, using 🤢/🤮 for pass/fail of tests marked with yuk. Mar 26, 2021 N/A pytest>=5.0.0 - :pypi:`pytest-zafira` A Zafira plugin for pytest Sep 18, 2019 5 - Production/Stable pytest (==4.1.1) - :pypi:`pytest-zap` OWASP ZAP plugin for py.test. May 12, 2014 4 - Beta N/A - :pypi:`pytest-zebrunner` Pytest connector for Zebrunner reporting Dec 12, 2022 5 - Production/Stable pytest (>=4.5.0) - :pypi:`pytest-zest` Zesty additions to pytest. Nov 17, 2022 N/A N/A - :pypi:`pytest-zigzag` Extend py.test for RPC OpenStack testing. Feb 27, 2019 4 - Beta pytest (~=3.6) - :pypi:`pytest-zulip` Pytest report plugin for Zulip May 07, 2022 5 - Production/Stable pytest - =============================================== ======================================================================================================================================================================================================== ============== ===================== ================================================ + =============================================== ====================================================================================================================================================================================================================================================================================================================================================================================== ============== ===================== ================================================ + name summary last_release status requires + =============================================== ====================================================================================================================================================================================================================================================================================================================================================================================== ============== ===================== ================================================ + :pypi:`databricks-labs-pytester` Python Testing for Databricks May 13, 2025 4 - Beta pytest>=8.3 + :pypi:`logassert` Simple but powerful assertion and verification of logged lines May 15, 2025 5 - Production/Stable pytest; extra == "dev" + :pypi:`logot` Test whether your code is logging correctly 🪵 May 05, 2025 5 - Production/Stable pytest; extra == "pytest" + :pypi:`nuts` Network Unit Testing System May 10, 2025 N/A pytest<8,>=7 + :pypi:`pytest-abq` Pytest integration for the ABQ universal test runner. Apr 07, 2023 N/A N/A + :pypi:`pytest-abstracts` A contextmanager pytest fixture for handling multiple mock abstracts May 25, 2022 N/A N/A + :pypi:`pytest-accept` A pytest-plugin for updating doctest outputs Dec 08, 2024 N/A pytest>=7 + :pypi:`pytest-adaptavist` pytest plugin for generating test execution results within Jira Test Management (tm4j) Oct 13, 2022 N/A pytest (>=5.4.0) + :pypi:`pytest-adaptavist-fixed` pytest plugin for generating test execution results within Jira Test Management (tm4j) Jan 17, 2025 N/A pytest>=5.4.0 + :pypi:`pytest-addons-test` 用于测试pytest的插件 Aug 02, 2021 N/A pytest (>=6.2.4,<7.0.0) + :pypi:`pytest-adf` Pytest plugin for writing Azure Data Factory integration tests May 10, 2021 4 - Beta pytest (>=3.5.0) + :pypi:`pytest-adf-azure-identity` Pytest plugin for writing Azure Data Factory integration tests Mar 06, 2021 4 - Beta pytest (>=3.5.0) + :pypi:`pytest-ads-testplan` Azure DevOps Test Case reporting for pytest tests Sep 15, 2022 N/A N/A + :pypi:`pytest-affected` Nov 06, 2023 N/A N/A + :pypi:`pytest-agent` Service that exposes a REST API that can be used to interract remotely with Pytest. It is shipped with a dashboard that enables running tests in a more convenient way. Nov 25, 2021 N/A N/A + :pypi:`pytest-aggreport` pytest plugin for pytest-repeat that generate aggregate report of the same test cases with additional statistics details. Mar 07, 2021 4 - Beta pytest (>=6.2.2) + :pypi:`pytest-ai` A Python package to generate regular, edge-case, and security HTTP tests. Jan 22, 2025 N/A N/A + :pypi:`pytest-ai1899` pytest plugin for connecting to ai1899 smart system stack Mar 13, 2024 5 - Production/Stable N/A + :pypi:`pytest-aio` Pytest plugin for testing async python code Jul 31, 2024 5 - Production/Stable pytest + :pypi:`pytest-aioboto3` Aioboto3 Pytest with Moto Jan 17, 2025 N/A N/A + :pypi:`pytest-aiofiles` pytest fixtures for writing aiofiles tests with pyfakefs May 14, 2017 5 - Production/Stable N/A + :pypi:`pytest-aiogram` May 06, 2023 N/A N/A + :pypi:`pytest-aiohttp` Pytest plugin for aiohttp support Jan 23, 2025 4 - Beta pytest>=6.1.0 + :pypi:`pytest-aiohttp-client` Pytest \`client\` fixture for the Aiohttp Jan 10, 2023 N/A pytest (>=7.2.0,<8.0.0) + :pypi:`pytest-aiomoto` pytest-aiomoto Jun 24, 2023 N/A pytest (>=7.0,<8.0) + :pypi:`pytest-aioresponses` py.test integration for aioresponses Jan 02, 2025 4 - Beta pytest>=3.5.0 + :pypi:`pytest-aioworkers` A plugin to test aioworkers project with pytest Dec 26, 2024 5 - Production/Stable pytest>=8.3.4 + :pypi:`pytest-airflow` pytest support for airflow. Apr 03, 2019 3 - Alpha pytest (>=4.4.0) + :pypi:`pytest-airflow-utils` Nov 15, 2021 N/A N/A + :pypi:`pytest-alembic` A pytest plugin for verifying alembic migrations. May 27, 2025 N/A pytest>=7.0 + :pypi:`pytest-alerts` A pytest plugin for sending test results to Slack and Telegram Feb 21, 2025 4 - Beta pytest>=7.4.0 + :pypi:`pytest-allclose` Pytest fixture extending Numpy's allclose function Jul 30, 2019 5 - Production/Stable pytest + :pypi:`pytest-allure-adaptor` Plugin for py.test to generate allure xml reports Jan 10, 2018 N/A pytest (>=2.7.3) + :pypi:`pytest-allure-adaptor2` Plugin for py.test to generate allure xml reports Oct 14, 2020 N/A pytest (>=2.7.3) + :pypi:`pytest-allure-collection` pytest plugin to collect allure markers without running any tests Apr 13, 2023 N/A pytest + :pypi:`pytest-allure-dsl` pytest plugin to test case doc string dls instructions Oct 25, 2020 4 - Beta pytest + :pypi:`pytest-allure-id2history` Overwrite allure history id with testcase full name and testcase id if testcase has id, exclude parameters. May 14, 2024 4 - Beta pytest>=6.2.0 + :pypi:`pytest-allure-intersection` Oct 27, 2022 N/A pytest (<5) + :pypi:`pytest-allure-spec-coverage` The pytest plugin aimed to display test coverage of the specs(requirements) in Allure Oct 26, 2021 N/A pytest + :pypi:`pytest-alphamoon` Static code checks used at Alphamoon Dec 30, 2021 5 - Production/Stable pytest (>=3.5.0) + :pypi:`pytest-amaranth-sim` Fixture to automate running Amaranth simulations Sep 21, 2024 4 - Beta pytest>=6.2.0 + :pypi:`pytest-analyzer` this plugin allows to analyze tests in pytest project, collect test metadata and sync it with testomat.io TCM system Feb 21, 2024 N/A pytest <8.0.0,>=7.3.1 + :pypi:`pytest-android` This fixture provides a configured "driver" for Android Automated Testing, using uiautomator2. Feb 21, 2019 3 - Alpha pytest + :pypi:`pytest-anki` A pytest plugin for testing Anki add-ons Jul 31, 2022 4 - Beta pytest (>=3.5.0) + :pypi:`pytest-annotate` pytest-annotate: Generate PyAnnotate annotations from your pytest tests. Jun 07, 2022 3 - Alpha pytest (<8.0.0,>=3.2.0) + :pypi:`pytest-annotated` Pytest plugin to allow use of Annotated in tests to resolve fixtures Sep 30, 2024 N/A pytest>=8.3.3 + :pypi:`pytest-ansible` Plugin for pytest to simplify calling ansible modules from tests or fixtures May 26, 2025 5 - Production/Stable pytest>=6 + :pypi:`pytest-ansible-playbook` Pytest fixture which runs given ansible playbook file. Mar 08, 2019 4 - Beta N/A + :pypi:`pytest-ansible-playbook-runner` Pytest fixture which runs given ansible playbook file. Dec 02, 2020 4 - Beta pytest (>=3.1.0) + :pypi:`pytest-ansible-units` A pytest plugin for running unit tests within an ansible collection Apr 14, 2022 N/A N/A + :pypi:`pytest-antilru` Bust functools.lru_cache when running pytest to avoid test pollution Jul 28, 2024 5 - Production/Stable pytest>=7; python_version >= "3.10" + :pypi:`pytest-anyio` The pytest anyio plugin is built into anyio. You don't need this package. Jun 29, 2021 N/A pytest + :pypi:`pytest-anything` Pytest fixtures to assert anything and something Jan 18, 2024 N/A pytest + :pypi:`pytest-aoc` Downloads puzzle inputs for Advent of Code and synthesizes PyTest fixtures Dec 02, 2023 5 - Production/Stable pytest ; extra == 'test' + :pypi:`pytest-aoreporter` pytest report Jun 27, 2022 N/A N/A + :pypi:`pytest-api` An ASGI middleware to populate OpenAPI Specification examples from pytest functions May 12, 2022 N/A pytest (>=7.1.1,<8.0.0) + :pypi:`pytest-api-soup` Validate multiple endpoints with unit testing using a single source of truth. Aug 27, 2022 N/A N/A + :pypi:`pytest-apistellar` apistellar plugin for pytest. Jun 18, 2019 N/A N/A + :pypi:`pytest-apiver` Jun 21, 2024 N/A pytest + :pypi:`pytest-appengine` AppEngine integration that works well with pytest-django Feb 27, 2017 N/A N/A + :pypi:`pytest-appium` Pytest plugin for appium Dec 05, 2019 N/A N/A + :pypi:`pytest-approvaltests` A plugin to use approvaltests with pytest May 08, 2022 4 - Beta pytest (>=7.0.1) + :pypi:`pytest-approvaltests-geo` Extension for ApprovalTests.Python specific to geo data verification Feb 05, 2024 5 - Production/Stable pytest + :pypi:`pytest-archon` Rule your architecture like a real developer Dec 18, 2023 5 - Production/Stable pytest >=7.2 + :pypi:`pytest-argus` pyest results colection plugin Jun 24, 2021 5 - Production/Stable pytest (>=6.2.4) + :pypi:`pytest-argus-server` A plugin that provides a running Argus API server for tests Mar 24, 2025 4 - Beta pytest>=6.2.0 + :pypi:`pytest-arraydiff` pytest plugin to help with comparing array output from tests Nov 27, 2023 4 - Beta pytest >=4.6 + :pypi:`pytest-asgi-server` Convenient ASGI client/server fixtures for Pytest Dec 12, 2020 N/A pytest (>=5.4.1) + :pypi:`pytest-aspec` A rspec format reporter for pytest Dec 20, 2023 4 - Beta N/A + :pypi:`pytest-asptest` test Answer Set Programming programs Apr 28, 2018 4 - Beta N/A + :pypi:`pytest-assertcount` Plugin to count actual number of asserts in pytest Oct 23, 2022 N/A pytest (>=5.0.0) + :pypi:`pytest-assertions` Pytest Assertions Apr 27, 2022 N/A N/A + :pypi:`pytest-assertutil` pytest-assertutil May 10, 2019 N/A N/A + :pypi:`pytest-assert-utils` Useful assertion utilities for use with pytest Apr 14, 2022 3 - Alpha N/A + :pypi:`pytest-assist` load testing library Mar 17, 2025 N/A pytest + :pypi:`pytest-assume` A pytest plugin that allows multiple failures per test Jun 24, 2021 N/A pytest (>=2.7) + :pypi:`pytest-assurka` A pytest plugin for Assurka Studio Aug 04, 2022 N/A N/A + :pypi:`pytest-ast-back-to-python` A plugin for pytest devs to view how assertion rewriting recodes the AST Sep 29, 2019 4 - Beta N/A + :pypi:`pytest-asteroid` PyTest plugin for docker-based testing on database images Aug 15, 2022 N/A pytest (>=6.2.5,<8.0.0) + :pypi:`pytest-astropy` Meta-package containing dependencies for testing Sep 26, 2023 5 - Production/Stable pytest >=4.6 + :pypi:`pytest-astropy-header` pytest plugin to add diagnostic information to the header of the test output Sep 06, 2022 3 - Alpha pytest (>=4.6) + :pypi:`pytest-ast-transformer` May 04, 2019 3 - Alpha pytest + :pypi:`pytest_async` pytest-async - Run your coroutine in event loop without decorator Feb 26, 2020 N/A N/A + :pypi:`pytest-async-benchmark` pytest-async-benchmark: Modern pytest benchmarking for async code. 🚀 May 28, 2025 N/A pytest>=8.3.5 + :pypi:`pytest-async-generators` Pytest fixtures for async generators Jul 05, 2023 N/A N/A + :pypi:`pytest-asyncio` Pytest support for asyncio May 26, 2025 4 - Beta pytest<9,>=8.2 + :pypi:`pytest-asyncio-concurrent` Pytest plugin to execute python async tests concurrently. May 17, 2025 4 - Beta pytest>=6.2.0 + :pypi:`pytest-asyncio-cooperative` Run all your asynchronous tests cooperatively. Apr 26, 2025 N/A N/A + :pypi:`pytest-asyncio-network-simulator` pytest-asyncio-network-simulator: Plugin for pytest for simulator the network in tests Jul 31, 2018 3 - Alpha pytest (<3.7.0,>=3.3.2) + :pypi:`pytest-async-mongodb` pytest plugin for async MongoDB Oct 18, 2017 5 - Production/Stable pytest (>=2.5.2) + :pypi:`pytest-async-sqlalchemy` Database testing fixtures using the SQLAlchemy asyncio API Oct 07, 2021 4 - Beta pytest (>=6.0.0) + :pypi:`pytest-atf-allure` 基于allure-pytest进行自定义 Nov 29, 2023 N/A pytest (>=7.4.2,<8.0.0) + :pypi:`pytest-atomic` Skip rest of tests if previous test failed. Nov 24, 2018 4 - Beta N/A + :pypi:`pytest-atstack` A simple plugin to use with pytest Jan 02, 2025 4 - Beta pytest>=6.2.0 + :pypi:`pytest-attrib` pytest plugin to select tests based on attributes similar to the nose-attrib plugin May 24, 2016 4 - Beta N/A + :pypi:`pytest-attributes` A plugin that allows users to add attributes to their tests. These attributes can then be referenced by fixtures or the test itself. Jun 24, 2024 4 - Beta pytest>=6.2.0 + :pypi:`pytest-austin` Austin plugin for pytest Oct 11, 2020 4 - Beta N/A + :pypi:`pytest-autocap` automatically capture test & fixture stdout/stderr to files May 15, 2022 N/A pytest (<7.2,>=7.1.2) + :pypi:`pytest-autochecklog` automatically check condition and log all the checks Apr 25, 2015 4 - Beta N/A + :pypi:`pytest-autofixture` simplify pytest fixtures Aug 01, 2024 N/A pytest>=8 + :pypi:`pytest-automation` pytest plugin for building a test suite, using YAML files to extend pytest parameterize functionality. Apr 24, 2024 N/A pytest>=7.0.0 + :pypi:`pytest-automock` Pytest plugin for automatical mocks creation May 16, 2023 N/A pytest ; extra == 'dev' + :pypi:`pytest-auto-parametrize` pytest plugin: avoid repeating arguments in parametrize Oct 02, 2016 3 - Alpha N/A + :pypi:`pytest-autotest` This fixture provides a configured "driver" for Android Automated Testing, using uiautomator2. Aug 25, 2021 N/A pytest + :pypi:`pytest-aviator` Aviator's Flakybot pytest plugin that automatically reruns flaky tests. Nov 04, 2022 4 - Beta pytest + :pypi:`pytest-avoidance` Makes pytest skip tests that don not need rerunning May 23, 2019 4 - Beta pytest (>=3.5.0) + :pypi:`pytest-aws` pytest plugin for testing AWS resource configurations Oct 04, 2017 4 - Beta N/A + :pypi:`pytest-aws-apigateway` pytest plugin for AWS ApiGateway May 24, 2024 4 - Beta pytest + :pypi:`pytest-aws-config` Protect your AWS credentials in unit tests May 28, 2021 N/A N/A + :pypi:`pytest-aws-fixtures` A series of fixtures to use in integration tests involving actual AWS services. Apr 06, 2025 N/A pytest<9.0.0,>=8.0.0 + :pypi:`pytest-axe` pytest plugin for axe-selenium-python Nov 12, 2018 N/A pytest (>=3.0.0) + :pypi:`pytest-axe-playwright-snapshot` A pytest plugin that runs Axe-core on Playwright pages and takes snapshots of the results. Jul 25, 2023 N/A pytest + :pypi:`pytest-azure` Pytest utilities and mocks for Azure Jan 18, 2023 3 - Alpha pytest + :pypi:`pytest-azure-devops` Simplifies using azure devops parallel strategy (https://docs.microsoft.com/en-us/azure/devops/pipelines/test/parallel-testing-any-test-runner) with pytest. Jun 20, 2022 4 - Beta pytest (>=3.5.0) + :pypi:`pytest-azurepipelines` Formatting PyTest output for Azure Pipelines UI Oct 06, 2023 5 - Production/Stable pytest (>=5.0.0) + :pypi:`pytest-bandit` A bandit plugin for pytest Feb 23, 2021 4 - Beta pytest (>=3.5.0) + :pypi:`pytest-bandit-xayon` A bandit plugin for pytest Oct 17, 2022 4 - Beta pytest (>=3.5.0) + :pypi:`pytest-base-url` pytest plugin for URL based testing Jan 31, 2024 5 - Production/Stable pytest>=7.0.0 + :pypi:`pytest-batch-regression` A pytest plugin to repeat the entire test suite in batches. May 08, 2024 N/A pytest>=6.0.0 + :pypi:`pytest-bazel` A pytest runner with bazel support May 11, 2025 4 - Beta pytest + :pypi:`pytest-bdd` BDD for pytest Dec 05, 2024 6 - Mature pytest>=7.0.0 + :pypi:`pytest-bdd-html` pytest plugin to display BDD info in HTML test report Nov 22, 2022 3 - Alpha pytest (!=6.0.0,>=5.0) + :pypi:`pytest-bdd-ng` BDD for pytest Nov 26, 2024 4 - Beta pytest>=5.2 + :pypi:`pytest-bdd-report` A pytest-bdd plugin for generating useful and informative BDD test reports Nov 27, 2024 N/A pytest>=7.1.3 + :pypi:`pytest-bdd-splinter` Common steps for pytest bdd and splinter integration Aug 12, 2019 5 - Production/Stable pytest (>=4.0.0) + :pypi:`pytest-bdd-web` A simple plugin to use with pytest Jan 02, 2020 4 - Beta pytest (>=3.5.0) + :pypi:`pytest-bdd-wrappers` Feb 11, 2020 2 - Pre-Alpha N/A + :pypi:`pytest-beakerlib` A pytest plugin that reports test results to the BeakerLib framework Mar 17, 2017 5 - Production/Stable pytest + :pypi:`pytest-beartype` Pytest plugin to run your tests with beartype checking enabled. Oct 31, 2024 N/A pytest + :pypi:`pytest-bec-e2e` BEC pytest plugin for end-to-end tests May 30, 2025 3 - Alpha pytest + :pypi:`pytest-beds` Fixtures for testing Google Appengine (GAE) apps Jun 07, 2016 4 - Beta N/A + :pypi:`pytest-beeprint` use icdiff for better error messages in pytest assertions Jul 04, 2023 4 - Beta N/A + :pypi:`pytest-bench` Benchmark utility that plugs into pytest. Jul 21, 2014 3 - Alpha N/A + :pypi:`pytest-benchmark` A \`\`pytest\`\` fixture for benchmarking code. It will group the tests into rounds that are calibrated to the chosen timer. Oct 30, 2024 5 - Production/Stable pytest>=8.1 + :pypi:`pytest-better-datadir` A small example package Mar 13, 2023 N/A N/A + :pypi:`pytest-better-parametrize` Better description of parametrized test cases Mar 05, 2024 4 - Beta pytest >=6.2.0 + :pypi:`pytest-bg-process` Pytest plugin to initialize background process Jan 24, 2022 4 - Beta pytest (>=3.5.0) + :pypi:`pytest-bigchaindb` A BigchainDB plugin for pytest. Jan 24, 2022 4 - Beta N/A + :pypi:`pytest-bigquery-mock` Provides a mock fixture for python bigquery client Dec 28, 2022 N/A pytest (>=5.0) + :pypi:`pytest-bisect-tests` Find tests leaking state and affecting other Jun 09, 2024 N/A N/A + :pypi:`pytest-black` A pytest plugin to enable format checking with black Dec 15, 2024 4 - Beta pytest>=7.0.0 + :pypi:`pytest-black-multipy` Allow '--black' on older Pythons Jan 14, 2021 5 - Production/Stable pytest (!=3.7.3,>=3.5) ; extra == 'testing' + :pypi:`pytest-black-ng` A pytest plugin to enable format checking with black Oct 20, 2022 4 - Beta pytest (>=7.0.0) + :pypi:`pytest-blame` A pytest plugin helps developers to debug by providing useful commits history. May 04, 2019 N/A pytest (>=4.4.0) + :pypi:`pytest-blender` Blender Pytest plugin. Aug 02, 2024 N/A pytest + :pypi:`pytest-blink1` Pytest plugin to emit notifications via the Blink(1) RGB LED Jan 07, 2018 4 - Beta N/A + :pypi:`pytest-blockage` Disable network requests during a test run. Dec 21, 2021 N/A pytest + :pypi:`pytest-blocker` pytest plugin to mark a test as blocker and skip all other tests Sep 07, 2015 4 - Beta N/A + :pypi:`pytest-blue` A pytest plugin that adds a \`blue\` fixture for printing stuff in blue. Sep 05, 2022 N/A N/A + :pypi:`pytest-board` Local continuous test runner with pytest and watchdog. Jan 20, 2019 N/A N/A + :pypi:`pytest-boilerplate` The pytest plugin for your Django Boilerplate. Sep 12, 2024 5 - Production/Stable pytest>=4.0.0 + :pypi:`pytest-bonsai` Apr 08, 2025 N/A pytest>=6 + :pypi:`pytest-boost-xml` Plugin for pytest to generate boost xml reports Nov 30, 2022 4 - Beta N/A + :pypi:`pytest-bootstrap` Mar 04, 2022 N/A N/A + :pypi:`pytest-boto-mock` Thin-wrapper around the mock package for easier use with pytest Jul 16, 2024 5 - Production/Stable pytest>=8.2.0 + :pypi:`pytest-bpdb` A py.test plug-in to enable drop to bpdb debugger on test failure. Jan 19, 2015 2 - Pre-Alpha N/A + :pypi:`pytest-bq` BigQuery fixtures and fixture factories for Pytest. May 08, 2024 5 - Production/Stable pytest>=6.2 + :pypi:`pytest-bravado` Pytest-bravado automatically generates from OpenAPI specification client fixtures. Feb 15, 2022 N/A N/A + :pypi:`pytest-breakword` Use breakword with pytest Aug 04, 2021 N/A pytest (>=6.2.4,<7.0.0) + :pypi:`pytest-breed-adapter` A simple plugin to connect with breed-server Nov 07, 2018 4 - Beta pytest (>=3.5.0) + :pypi:`pytest-briefcase` A pytest plugin for running tests on a Briefcase project. Jun 14, 2020 4 - Beta pytest (>=3.5.0) + :pypi:`pytest-broadcaster` Pytest plugin to broadcast pytest output to various destinations Mar 02, 2025 3 - Alpha pytest + :pypi:`pytest-browser` A pytest plugin for console based browser test selection just after the collection phase Dec 10, 2016 3 - Alpha N/A + :pypi:`pytest-browsermob-proxy` BrowserMob proxy plugin for py.test. Jun 11, 2013 4 - Beta N/A + :pypi:`pytest_browserstack` Py.test plugin for BrowserStack Jan 27, 2016 4 - Beta N/A + :pypi:`pytest-browserstack-local` \`\`py.test\`\` plugin to run \`\`BrowserStackLocal\`\` in background. Feb 09, 2018 N/A N/A + :pypi:`pytest-budosystems` Budo Systems is a martial arts school management system. This module is the Budo Systems Pytest Plugin. May 07, 2023 3 - Alpha pytest + :pypi:`pytest-bug` Pytest plugin for marking tests as a bug Jun 05, 2024 5 - Production/Stable pytest>=8.0.0 + :pypi:`pytest-bugtong-tag` pytest-bugtong-tag is a plugin for pytest Jan 16, 2022 N/A N/A + :pypi:`pytest-bugzilla` py.test bugzilla integration plugin May 05, 2010 4 - Beta N/A + :pypi:`pytest-bugzilla-notifier` A plugin that allows you to execute create, update, and read information from BugZilla bugs Jun 15, 2018 4 - Beta pytest (>=2.9.2) + :pypi:`pytest-buildkite` Plugin for pytest that automatically publishes coverage and pytest report annotations to Buildkite. Jul 13, 2019 4 - Beta pytest (>=3.5.0) + :pypi:`pytest-builtin-types` Nov 17, 2021 N/A pytest + :pypi:`pytest-bwrap` Run your tests in Bubblewrap sandboxes Feb 25, 2024 3 - Alpha N/A + :pypi:`pytest-cache` pytest plugin with mechanisms for caching across test runs Jun 04, 2013 3 - Alpha N/A + :pypi:`pytest-cache-assert` Cache assertion data to simplify regression testing of complex serializable data Aug 14, 2023 5 - Production/Stable pytest (>=6.0.0) + :pypi:`pytest-cagoule` Pytest plugin to only run tests affected by changes Jan 01, 2020 3 - Alpha N/A + :pypi:`pytest-cairo` Pytest support for cairo-lang and starknet Apr 17, 2022 N/A pytest + :pypi:`pytest-call-checker` Small pytest utility to easily create test doubles Oct 16, 2022 4 - Beta pytest (>=7.1.3,<8.0.0) + :pypi:`pytest-camel-collect` Enable CamelCase-aware pytest class collection Aug 02, 2020 N/A pytest (>=2.9) + :pypi:`pytest-canonical-data` A plugin which allows to compare results with canonical results, based on previous runs May 08, 2020 2 - Pre-Alpha pytest (>=3.5.0) + :pypi:`pytest-caprng` A plugin that replays pRNG state on failure. May 02, 2018 4 - Beta N/A + :pypi:`pytest-capsqlalchemy` Pytest plugin to allow capturing SQLAlchemy queries. Mar 19, 2025 4 - Beta N/A + :pypi:`pytest-capture-deprecatedwarnings` pytest plugin to capture all deprecatedwarnings and put them in one file Apr 30, 2019 N/A N/A + :pypi:`pytest-capture-sysout` An academic experiment package May 21, 2025 2 - Pre-Alpha N/A + :pypi:`pytest-capture-warnings` pytest plugin to capture all warnings and put them in one file of your choice May 03, 2022 N/A pytest + :pypi:`pytest-case` A clean, modern, wrapper for pytest.mark.parametrize Nov 25, 2024 N/A pytest<9.0.0,>=8.3.3 + :pypi:`pytest-cases` Separate test code from test cases in pytest. Sep 26, 2024 5 - Production/Stable N/A + :pypi:`pytest-cassandra` Cassandra CCM Test Fixtures for pytest Nov 04, 2017 1 - Planning N/A + :pypi:`pytest-catchlog` py.test plugin to catch log messages. This is a fork of pytest-capturelog. Jan 24, 2016 4 - Beta pytest (>=2.6) + :pypi:`pytest-catch-server` Pytest plugin with server for catching HTTP requests. Dec 12, 2019 5 - Production/Stable N/A + :pypi:`pytest-cdist` A pytest plugin to split your test suite into multiple parts Jan 30, 2025 N/A pytest>=7 + :pypi:`pytest-celery` Pytest plugin for Celery Feb 21, 2025 5 - Production/Stable N/A + :pypi:`pytest-celery-py37` Pytest plugin for Celery (compatible with python 3.7) May 23, 2025 5 - Production/Stable N/A + :pypi:`pytest-cfg-fetcher` Pass config options to your unit tests. Feb 26, 2024 N/A N/A + :pypi:`pytest-chainmaker` pytest plugin for chainmaker Oct 15, 2021 N/A N/A + :pypi:`pytest-chalice` A set of py.test fixtures for AWS Chalice Jul 01, 2020 4 - Beta N/A + :pypi:`pytest-change-assert` 修改报错中文为英文 Oct 19, 2022 N/A N/A + :pypi:`pytest-change-demo` turn . into √,turn F into x Mar 02, 2022 N/A pytest + :pypi:`pytest-change-report` turn . into √,turn F into x Sep 14, 2020 N/A pytest + :pypi:`pytest-change-xds` turn . into √,turn F into x Apr 16, 2022 N/A pytest + :pypi:`pytest-chdir` A pytest fixture for changing current working directory Jan 28, 2020 N/A pytest (>=5.0.0,<6.0.0) + :pypi:`pytest-check` A pytest plugin that allows multiple failures per test. Apr 04, 2025 5 - Production/Stable pytest>=7.0.0 + :pypi:`pytest-checkdocs` check the README when running tests Apr 30, 2024 5 - Production/Stable pytest!=8.1.*,>=6; extra == "testing" + :pypi:`pytest-checkipdb` plugin to check if there are ipdb debugs left Dec 04, 2023 5 - Production/Stable pytest >=2.9.2 + :pypi:`pytest-check-library` check your missing library Jul 17, 2022 N/A N/A + :pypi:`pytest-check-libs` check your missing library Jul 17, 2022 N/A N/A + :pypi:`pytest-check-links` Check links in files Jul 29, 2020 N/A pytest<9,>=7.0 + :pypi:`pytest-checklist` Pytest plugin to track and report unit/function coverage. May 23, 2025 N/A N/A + :pypi:`pytest-check-mk` pytest plugin to test Check_MK checks Nov 19, 2015 4 - Beta pytest + :pypi:`pytest-checkpoint` Restore a checkpoint in pytest Mar 30, 2025 N/A pytest>=8.0.0 + :pypi:`pytest-ch-framework` My pytest framework Apr 17, 2024 N/A pytest==8.0.1 + :pypi:`pytest-chic-report` Simple pytest plugin for generating and sending report to messengers. Nov 01, 2024 N/A pytest>=6.0 + :pypi:`pytest-chinesereport` Apr 16, 2025 4 - Beta pytest>=3.5.0 + :pypi:`pytest-choose` Provide the pytest with the ability to collect use cases based on rules in text files Feb 04, 2024 N/A pytest >=7.0.0 + :pypi:`pytest-chunks` Run only a chunk of your test suite Jul 05, 2022 N/A pytest (>=6.0.0) + :pypi:`pytest_cid` Compare data structures containing matching CIDs of different versions and encoding Sep 01, 2023 4 - Beta pytest >= 5.0, < 7.0 + :pypi:`pytest-circleci` py.test plugin for CircleCI May 03, 2019 N/A N/A + :pypi:`pytest-circleci-parallelized` Parallelize pytest across CircleCI workers. Oct 20, 2022 N/A N/A + :pypi:`pytest-circleci-parallelized-rjp` Parallelize pytest across CircleCI workers. Jun 21, 2022 N/A pytest + :pypi:`pytest-ckan` Backport of CKAN 2.9 pytest plugin and fixtures to CAKN 2.8 Apr 28, 2020 4 - Beta pytest + :pypi:`pytest-clarity` A plugin providing an alternative, colourful diff output for failing assertions. Jun 11, 2021 N/A N/A + :pypi:`pytest-class-fixtures` Class as PyTest fixtures (and BDD steps) Nov 15, 2024 N/A pytest<9.0.0,>=8.3.3 + :pypi:`pytest-cldf` Easy quality control for CLDF datasets using pytest Nov 07, 2022 N/A pytest (>=3.6) + :pypi:`pytest-clean-database` A pytest plugin that cleans your database up after every test. Mar 14, 2025 3 - Alpha pytest<9,>=7.0 + :pypi:`pytest-cleanslate` Collects and executes pytest tests separately Apr 10, 2025 N/A pytest + :pypi:`pytest_cleanup` Automated, comprehensive and well-organised pytest test cases. Jan 28, 2020 N/A N/A + :pypi:`pytest-cleanuptotal` A cleanup plugin for pytest Nov 08, 2024 5 - Production/Stable N/A + :pypi:`pytest-clerk` A set of pytest fixtures to help with integration testing with Clerk. Jan 30, 2025 N/A pytest<9.0.0,>=8.0.0 + :pypi:`pytest-cli2-ansible` Mar 05, 2025 N/A N/A + :pypi:`pytest-click` Pytest plugin for Click Feb 11, 2022 5 - Production/Stable pytest (>=5.0) + :pypi:`pytest-cli-fixtures` Automatically register fixtures for custom CLI arguments Jul 28, 2022 N/A pytest (~=7.0) + :pypi:`pytest-clld` Oct 23, 2024 N/A pytest>=3.9 + :pypi:`pytest-cloud` Distributed tests planner plugin for pytest testing framework. Oct 05, 2020 6 - Mature N/A + :pypi:`pytest-cloudflare-worker` pytest plugin for testing cloudflare workers Mar 30, 2021 4 - Beta pytest (>=6.0.0) + :pypi:`pytest-cloudist` Distribute tests to cloud machines without fuss Sep 02, 2022 4 - Beta pytest (>=7.1.2,<8.0.0) + :pypi:`pytest-cmake` Provide CMake module for Pytest Feb 17, 2025 N/A pytest<9,>=4 + :pypi:`pytest-cmake-presets` Execute CMake Presets via pytest Dec 26, 2022 N/A pytest (>=7.2.0,<8.0.0) + :pypi:`pytest-cmdline-add-args` Pytest plugin for custom argument handling and Allure reporting. This plugin allows you to add arguments before running a test. Sep 01, 2024 N/A N/A + :pypi:`pytest-cobra` PyTest plugin for testing Smart Contracts for Ethereum blockchain. Jun 29, 2019 3 - Alpha pytest (<4.0.0,>=3.7.1) + :pypi:`pytest-cocotb` Pytest plugin to integrate Cocotb Mar 15, 2025 5 - Production/Stable pytest; extra == "test" + :pypi:`pytest-codeblock` Pytest plugin to collect and test code blocks in reStructuredText and Markdown files. May 10, 2025 4 - Beta pytest + :pypi:`pytest_codeblocks` Test code blocks in your READMEs Sep 17, 2023 5 - Production/Stable pytest >= 7.0.0 + :pypi:`pytest-codecarbon` Pytest plugin for measuring carbon emissions Jun 15, 2022 N/A pytest + :pypi:`pytest-codecheckers` pytest plugin to add source code sanity checks (pep8 and friends) Feb 13, 2010 N/A N/A + :pypi:`pytest-codecov` Pytest plugin for uploading pytest-cov results to codecov.io Mar 25, 2025 4 - Beta pytest>=4.6.0 + :pypi:`pytest-codegen` Automatically create pytest test signatures Aug 23, 2020 2 - Pre-Alpha N/A + :pypi:`pytest-codeowners` Pytest plugin for selecting tests by GitHub CODEOWNERS. Mar 30, 2022 4 - Beta pytest (>=6.0.0) + :pypi:`pytest-codestyle` pytest plugin to run pycodestyle Mar 23, 2020 3 - Alpha N/A + :pypi:`pytest-codspeed` Pytest plugin to create CodSpeed benchmarks May 27, 2025 5 - Production/Stable pytest>=3.8 + :pypi:`pytest-collect-appoint-info` set your encoding Aug 03, 2023 N/A pytest + :pypi:`pytest-collect-formatter` Formatter for pytest collect output Mar 29, 2021 5 - Production/Stable N/A + :pypi:`pytest-collect-formatter2` Formatter for pytest collect output May 31, 2021 5 - Production/Stable N/A + :pypi:`pytest-collect-interface-info-plugin` Get executed interface information in pytest interface automation framework Sep 25, 2023 4 - Beta N/A + :pypi:`pytest-collector` Python package for collecting pytest. Aug 02, 2022 N/A pytest (>=7.0,<8.0) + :pypi:`pytest-collect-pytest-interinfo` A simple plugin to use with pytest Sep 26, 2023 4 - Beta N/A + :pypi:`pytest-colordots` Colorizes the progress indicators Oct 06, 2017 5 - Production/Stable N/A + :pypi:`pytest-commander` An interactive GUI test runner for PyTest Aug 17, 2021 N/A pytest (<7.0.0,>=6.2.4) + :pypi:`pytest-common-subject` pytest framework for testing different aspects of a common method Jun 12, 2024 N/A pytest<9,>=3.6 + :pypi:`pytest-compare` pytest plugin for comparing call arguments. Jun 22, 2023 5 - Production/Stable N/A + :pypi:`pytest-concurrent` Concurrently execute test cases with multithread, multiprocess and gevent Jan 12, 2019 4 - Beta pytest (>=3.1.1) + :pypi:`pytest-config` Base configurations and utilities for developing your Python project test suite with pytest. Nov 07, 2014 5 - Production/Stable N/A + :pypi:`pytest-confluence-report` Package stands for pytest plugin to upload results into Confluence page. Apr 17, 2022 N/A N/A + :pypi:`pytest-console-scripts` Pytest plugin for testing console scripts May 31, 2023 4 - Beta pytest (>=4.0.0) + :pypi:`pytest-consul` pytest plugin with fixtures for testing consul aware apps Nov 24, 2018 3 - Alpha pytest + :pypi:`pytest-container` Pytest fixtures for writing container based tests Dec 04, 2024 4 - Beta pytest>=3.10 + :pypi:`pytest-contextfixture` Define pytest fixtures as context managers. Mar 12, 2013 4 - Beta N/A + :pypi:`pytest-contexts` A plugin to run tests written with the Contexts framework using pytest May 19, 2021 4 - Beta N/A + :pypi:`pytest-continuous` A pytest plugin to run tests continuously until failure or interruption. Apr 23, 2024 N/A N/A + :pypi:`pytest-cookies` The pytest plugin for your Cookiecutter templates. 🍪 Mar 22, 2023 5 - Production/Stable pytest (>=3.9.0) + :pypi:`pytest-copie` The pytest plugin for your copier templates 📒 Apr 09, 2025 3 - Alpha pytest + :pypi:`pytest-copier` A pytest plugin to help testing Copier templates Dec 11, 2023 4 - Beta pytest>=7.3.2 + :pypi:`pytest-couchdbkit` py.test extension for per-test couchdb databases using couchdbkit Apr 17, 2012 N/A N/A + :pypi:`pytest-count` count erros and send email Jan 12, 2018 4 - Beta N/A + :pypi:`pytest-cov` Pytest plugin for measuring coverage. Apr 05, 2025 5 - Production/Stable pytest>=4.6 + :pypi:`pytest-cover` Pytest plugin for measuring coverage. Forked from \`pytest-cov\`. Aug 01, 2015 5 - Production/Stable N/A + :pypi:`pytest-coverage` Jun 17, 2015 N/A N/A + :pypi:`pytest-coverage-context` Coverage dynamic context support for PyTest, including sub-processes Jun 28, 2023 4 - Beta N/A + :pypi:`pytest-coveragemarkers` Using pytest markers to track functional coverage and filtering of tests May 15, 2025 N/A pytest<8.0.0,>=7.1.2 + :pypi:`pytest-cov-exclude` Pytest plugin for excluding tests based on coverage data Apr 29, 2016 4 - Beta pytest (>=2.8.0,<2.9.0); extra == 'dev' + :pypi:`pytest_covid` Too many faillure, less tests. Jun 24, 2020 N/A N/A + :pypi:`pytest-cpp` Use pytest's runner to discover and execute C++ tests Sep 18, 2024 5 - Production/Stable pytest + :pypi:`pytest-cqase` Custom qase pytest plugin Aug 22, 2022 N/A pytest (>=7.1.2,<8.0.0) + :pypi:`pytest-cram` Run cram tests with pytest. Aug 08, 2020 N/A N/A + :pypi:`pytest-crate` Manages CrateDB instances during your integration tests May 28, 2019 3 - Alpha pytest (>=4.0) + :pypi:`pytest-cratedb` Manage CrateDB instances for integration tests Oct 08, 2024 4 - Beta pytest<9 + :pypi:`pytest-cratedb-reporter` A pytest plugin for reporting test results to CrateDB Mar 11, 2025 N/A pytest>=6.0.0 + :pypi:`pytest-crayons` A pytest plugin for colorful print statements Oct 08, 2023 N/A pytest + :pypi:`pytest-create` pytest-create Feb 15, 2023 1 - Planning N/A + :pypi:`pytest-cricri` A Cricri plugin for pytest. Jan 27, 2018 N/A pytest + :pypi:`pytest-crontab` add crontab task in crontab Dec 09, 2019 N/A N/A + :pypi:`pytest-csv` CSV output for pytest. Apr 22, 2021 N/A pytest (>=6.0) + :pypi:`pytest-csv-params` Pytest plugin for Test Case Parametrization with CSV files May 29, 2025 5 - Production/Stable pytest<9,>=8.3 + :pypi:`pytest-culprit` Find the last Git commit where a pytest test started failing May 15, 2025 N/A N/A + :pypi:`pytest-curio` Pytest support for curio. Oct 06, 2024 N/A pytest + :pypi:`pytest-curl-report` pytest plugin to generate curl command line report Dec 11, 2016 4 - Beta N/A + :pypi:`pytest-custom-concurrency` Custom grouping concurrence for pytest Feb 08, 2021 N/A N/A + :pypi:`pytest-custom-exit-code` Exit pytest test session with custom exit code in different scenarios Aug 07, 2019 4 - Beta pytest (>=4.0.2) + :pypi:`pytest-custom-nodeid` Custom grouping for pytest-xdist, rename test cases name and test cases nodeid, support allure report Mar 07, 2021 N/A N/A + :pypi:`pytest-custom-outputs` A plugin that allows users to create and use custom outputs instead of the standard Pass and Fail. Also allows users to retrieve test results in fixtures. Jul 10, 2024 4 - Beta pytest>=6.2.0 + :pypi:`pytest-custom-report` Configure the symbols displayed for test outcomes Jan 30, 2019 N/A pytest + :pypi:`pytest-custom-scheduling` Custom grouping for pytest-xdist, rename test cases name and test cases nodeid, support allure report Mar 01, 2021 N/A N/A + :pypi:`pytest-custom-timeout` Use custom logic when a test times out. Based on pytest-timeout. Jan 08, 2025 4 - Beta pytest>=8.0.0 + :pypi:`pytest-cython` A plugin for testing Cython extension modules Apr 05, 2024 5 - Production/Stable pytest>=8 + :pypi:`pytest-cython-collect` Jun 17, 2022 N/A pytest + :pypi:`pytest-darker` A pytest plugin for checking of modified code using Darker Feb 25, 2024 N/A pytest <7,>=6.0.1 + :pypi:`pytest-dash` pytest fixtures to run dash applications. Mar 18, 2019 N/A N/A + :pypi:`pytest-dashboard` May 20, 2025 N/A pytest<8.0.0,>=7.4.3 + :pypi:`pytest-data` Useful functions for managing data for pytest fixtures Nov 01, 2016 5 - Production/Stable N/A + :pypi:`pytest-databases` Reusable database fixtures for any and all databases. May 25, 2025 4 - Beta pytest + :pypi:`pytest-databricks` Pytest plugin for remote Databricks notebooks testing Jul 29, 2020 N/A pytest + :pypi:`pytest-datadir` pytest plugin for test data directories and files May 30, 2025 5 - Production/Stable pytest>=7.0 + :pypi:`pytest-datadir-mgr` Manager for test data: downloads, artifact caching, and a tmpdir context. Apr 06, 2023 5 - Production/Stable pytest (>=7.1) + :pypi:`pytest-datadir-ng` Fixtures for pytest allowing test functions/methods to easily retrieve test resources from the local filesystem. Dec 25, 2019 5 - Production/Stable pytest + :pypi:`pytest-datadir-nng` Fixtures for pytest allowing test functions/methods to easily retrieve test resources from the local filesystem. Nov 09, 2022 5 - Production/Stable pytest (>=7.0.0,<8.0.0) + :pypi:`pytest-data-extractor` A pytest plugin to extract relevant metadata about tests into an external file (currently only json support) Jul 19, 2022 N/A pytest (>=7.0.1) + :pypi:`pytest-data-file` Fixture "data" and "case_data" for test from yaml file Dec 04, 2019 N/A N/A + :pypi:`pytest-datafiles` py.test plugin to create a 'tmp_path' containing predefined files/directories. Feb 24, 2023 5 - Production/Stable pytest (>=3.6) + :pypi:`pytest-datafixtures` Data fixtures for pytest made simple. May 15, 2025 5 - Production/Stable N/A + :pypi:`pytest-data-from-files` pytest plugin to provide data from files loaded automatically Oct 13, 2021 4 - Beta pytest + :pypi:`pytest-dataplugin` A pytest plugin for managing an archive of test data. Sep 16, 2017 1 - Planning N/A + :pypi:`pytest-datarecorder` A py.test plugin recording and comparing test output. Jul 31, 2024 5 - Production/Stable pytest + :pypi:`pytest-dataset` Plugin for loading different datasets for pytest by prefix from json or yaml files Sep 01, 2023 5 - Production/Stable N/A + :pypi:`pytest-data-suites` Class-based pytest parametrization Apr 06, 2024 N/A pytest<9.0,>=6.0 + :pypi:`pytest-datatest` A pytest plugin for test driven data-wrangling (this is the development version of datatest's pytest integration). Oct 15, 2020 4 - Beta pytest (>=3.3) + :pypi:`pytest-db` Session scope fixture "db" for mysql query or change Aug 22, 2024 N/A pytest + :pypi:`pytest-dbfixtures` Databases fixtures plugin for py.test. Dec 07, 2016 4 - Beta N/A + :pypi:`pytest-db-plugin` Nov 27, 2021 N/A pytest (>=5.0) + :pypi:`pytest-dbt` Unit test dbt models with standard python tooling Jun 08, 2023 2 - Pre-Alpha pytest (>=7.0.0,<8.0.0) + :pypi:`pytest-dbt-adapter` A pytest plugin for testing dbt adapter plugins Nov 24, 2021 N/A pytest (<7,>=6) + :pypi:`pytest-dbt-conventions` A pytest plugin for linting a dbt project's conventions Mar 02, 2022 N/A pytest (>=6.2.5,<7.0.0) + :pypi:`pytest-dbt-core` Pytest extension for dbt. Jun 04, 2024 N/A pytest>=6.2.5; extra == "test" + :pypi:`pytest-dbt-duckdb` Fearless testing for dbt models, powered by DuckDB. Feb 09, 2025 4 - Beta pytest>=8.3.4 + :pypi:`pytest-dbt-postgres` Pytest tooling to unittest DBT & Postgres models Sep 03, 2024 N/A pytest<9.0.0,>=8.3.2 + :pypi:`pytest-dbus-notification` D-BUS notifications for pytest results. Mar 05, 2014 5 - Production/Stable N/A + :pypi:`pytest-dbx` Pytest plugin to run unit tests for dbx (Databricks CLI extensions) related code Nov 29, 2022 N/A pytest (>=7.1.3,<8.0.0) + :pypi:`pytest-dc` Manages Docker containers during your integration tests Aug 16, 2023 5 - Production/Stable pytest >=3.3 + :pypi:`pytest-deadfixtures` A simple plugin to list unused fixtures in pytest Jul 23, 2020 5 - Production/Stable N/A + :pypi:`pytest-deduplicate` Identifies duplicate unit tests Aug 12, 2023 4 - Beta pytest + :pypi:`pytest-deepcov` deepcov Mar 30, 2021 N/A N/A + :pypi:`pytest_defer` A 'defer' fixture for pytest Nov 13, 2024 N/A pytest>=8.3 + :pypi:`pytest-demo-plugin` pytest示例插件 May 15, 2021 N/A N/A + :pypi:`pytest-dependency` Manage dependencies of tests Dec 31, 2023 4 - Beta N/A + :pypi:`pytest-depends` Tests that depend on other tests Apr 05, 2020 5 - Production/Stable pytest (>=3) + :pypi:`pytest-deprecate` Mark tests as testing a deprecated feature with a warning note. Jul 01, 2019 N/A N/A + :pypi:`pytest-deprecator` A simple plugin to use with pytest Dec 02, 2024 4 - Beta pytest>=6.2.0 + :pypi:`pytest-describe` Describe-style plugin for pytest Feb 10, 2024 5 - Production/Stable pytest <9,>=4.6 + :pypi:`pytest-describe-it` plugin for rich text descriptions Jul 19, 2019 4 - Beta pytest + :pypi:`pytest-deselect-if` A plugin to deselect pytests tests rather than using skipif Dec 26, 2024 4 - Beta pytest>=6.2.0 + :pypi:`pytest-devpi-server` DevPI server fixture for py.test Oct 17, 2024 5 - Production/Stable pytest + :pypi:`pytest-dfm` pytest-dfm provides a pytest integration for DV Flow Manager, a build system for silicon design May 10, 2025 N/A pytest + :pypi:`pytest-dhos` Common fixtures for pytest in DHOS services and libraries Sep 07, 2022 N/A N/A + :pypi:`pytest-diamond` pytest plugin for diamond Aug 31, 2015 4 - Beta N/A + :pypi:`pytest-dicom` pytest plugin to provide DICOM fixtures Dec 19, 2018 3 - Alpha pytest + :pypi:`pytest-dictsdiff` Jul 26, 2019 N/A N/A + :pypi:`pytest-diff` A simple plugin to use with pytest Mar 30, 2019 4 - Beta pytest (>=3.5.0) + :pypi:`pytest-diff-selector` Get tests affected by code changes (using git) Feb 24, 2022 4 - Beta pytest (>=6.2.2) ; extra == 'all' + :pypi:`pytest-difido` PyTest plugin for generating Difido reports Oct 23, 2022 4 - Beta pytest (>=4.0.0) + :pypi:`pytest-dir-equal` pytest-dir-equals is a pytest plugin providing helpers to assert directories equality allowing golden testing Dec 11, 2023 4 - Beta pytest>=7.3.2 + :pypi:`pytest-dirty` Static import analysis for thrifty testing. Jul 11, 2024 3 - Alpha pytest>=8.2; extra == "dev" + :pypi:`pytest-disable` pytest plugin to disable a test and skip it from testrun Sep 10, 2015 4 - Beta N/A + :pypi:`pytest-disable-plugin` Disable plugins per test Feb 28, 2019 4 - Beta pytest (>=3.5.0) + :pypi:`pytest-discord` A pytest plugin to notify test results to a Discord channel. May 11, 2024 4 - Beta pytest!=6.0.0,<9,>=3.3.2 + :pypi:`pytest-discover` Pytest plugin to record discovered tests in a file Mar 26, 2024 N/A pytest + :pypi:`pytest-ditto` Snapshot testing pytest plugin with minimal ceremony and flexible persistence formats. Jun 09, 2024 4 - Beta pytest>=3.5.0 + :pypi:`pytest-ditto-pandas` pytest-ditto plugin for pandas snapshots. May 29, 2024 4 - Beta pytest>=3.5.0 + :pypi:`pytest-ditto-pyarrow` pytest-ditto plugin for pyarrow tables. Jun 09, 2024 4 - Beta pytest>=3.5.0 + :pypi:`pytest-django` A Django plugin for pytest. Apr 03, 2025 5 - Production/Stable pytest>=7.0.0 + :pypi:`pytest-django-ahead` A Django plugin for pytest. Oct 27, 2016 5 - Production/Stable pytest (>=2.9) + :pypi:`pytest-djangoapp` Nice pytest plugin to help you with Django pluggable application testing. May 19, 2023 4 - Beta pytest + :pypi:`pytest-django-cache-xdist` A djangocachexdist plugin for pytest May 12, 2020 4 - Beta N/A + :pypi:`pytest-django-casperjs` Integrate CasperJS with your django tests as a pytest fixture. Mar 15, 2015 2 - Pre-Alpha N/A + :pypi:`pytest-django-class` A pytest plugin for running django in class-scoped fixtures Aug 08, 2023 4 - Beta N/A + :pypi:`pytest-django-docker-pg` Jun 13, 2024 5 - Production/Stable pytest<9.0.0,>=7.0.0 + :pypi:`pytest-django-dotenv` Pytest plugin used to setup environment variables with django-dotenv Nov 26, 2019 4 - Beta pytest (>=2.6.0) + :pypi:`pytest-django-factories` Factories for your Django models that can be used as Pytest fixtures. Nov 12, 2020 4 - Beta N/A + :pypi:`pytest-django-filefield` Replaces FileField.storage with something you can patch globally. May 09, 2022 5 - Production/Stable pytest >= 5.2 + :pypi:`pytest-django-gcir` A Django plugin for pytest. Mar 06, 2018 5 - Production/Stable N/A + :pypi:`pytest-django-haystack` Cleanup your Haystack indexes between tests Sep 03, 2017 5 - Production/Stable pytest (>=2.3.4) + :pypi:`pytest-django-ifactory` A model instance factory for pytest-django Apr 30, 2025 5 - Production/Stable N/A + :pypi:`pytest-django-lite` The bare minimum to integrate py.test with Django. Jan 30, 2014 N/A N/A + :pypi:`pytest-django-liveserver-ssl` Jan 09, 2025 3 - Alpha N/A + :pypi:`pytest-django-model` A Simple Way to Test your Django Models Feb 14, 2019 4 - Beta N/A + :pypi:`pytest-django-ordering` A pytest plugin for preserving the order in which Django runs tests. Jul 25, 2019 5 - Production/Stable pytest (>=2.3.0) + :pypi:`pytest-django-queries` Generate performance reports from your django database performance tests. Mar 01, 2021 N/A N/A + :pypi:`pytest-djangorestframework` A djangorestframework plugin for pytest Aug 11, 2019 4 - Beta N/A + :pypi:`pytest-django-rq` A pytest plugin to help writing unit test for django-rq Apr 13, 2020 4 - Beta N/A + :pypi:`pytest-django-sqlcounts` py.test plugin for reporting the number of SQLs executed per django testcase. Jun 16, 2015 4 - Beta N/A + :pypi:`pytest-django-testing-postgresql` Use a temporary PostgreSQL database with pytest-django Jan 31, 2022 4 - Beta N/A + :pypi:`pytest-doc` A documentation plugin for py.test. Jun 28, 2015 5 - Production/Stable N/A + :pypi:`pytest-docfiles` pytest plugin to test codeblocks in your documentation. Dec 22, 2021 4 - Beta pytest (>=3.7.0) + :pypi:`pytest-docgen` An RST Documentation Generator for pytest-based test suites Apr 17, 2020 N/A N/A + :pypi:`pytest-docker` Simple pytest fixtures for Docker and Docker Compose based tests May 26, 2025 N/A pytest<9.0,>=4.0 + :pypi:`pytest-docker-apache-fixtures` Pytest fixtures for testing with apache2 (httpd). Aug 12, 2024 4 - Beta pytest + :pypi:`pytest-docker-butla` Jun 16, 2019 3 - Alpha N/A + :pypi:`pytest-dockerc` Run, manage and stop Docker Compose project from Docker API Oct 09, 2020 5 - Production/Stable pytest (>=3.0) + :pypi:`pytest-docker-compose` Manages Docker containers during your integration tests Jan 26, 2021 5 - Production/Stable pytest (>=3.3) + :pypi:`pytest-docker-compose-v2` Manages Docker containers during your integration tests Dec 11, 2024 4 - Beta pytest>=7.2.2 + :pypi:`pytest-docker-db` A plugin to use docker databases for pytests Mar 20, 2021 5 - Production/Stable pytest (>=3.1.1) + :pypi:`pytest-docker-fixtures` pytest docker fixtures May 14, 2025 3 - Alpha pytest + :pypi:`pytest-docker-git-fixtures` Pytest fixtures for testing with git scm. Aug 12, 2024 4 - Beta pytest + :pypi:`pytest-docker-haproxy-fixtures` Pytest fixtures for testing with haproxy. Aug 12, 2024 4 - Beta pytest + :pypi:`pytest-docker-pexpect` pytest plugin for writing functional tests with pexpect and docker Jan 14, 2019 N/A pytest + :pypi:`pytest-docker-postgresql` A simple plugin to use with pytest Sep 24, 2019 4 - Beta pytest (>=3.5.0) + :pypi:`pytest-docker-py` Easy to use, simple to extend, pytest plugin that minimally leverages docker-py. Nov 27, 2018 N/A pytest (==4.0.0) + :pypi:`pytest-docker-registry-fixtures` Pytest fixtures for testing with docker registries. Aug 12, 2024 4 - Beta pytest + :pypi:`pytest-docker-service` pytest plugin to start docker container Jan 03, 2024 3 - Alpha pytest (>=7.1.3) + :pypi:`pytest-docker-squid-fixtures` Pytest fixtures for testing with squid. Aug 12, 2024 4 - Beta pytest + :pypi:`pytest-docker-tools` Docker integration tests for pytest Mar 16, 2025 4 - Beta pytest>=6.0.1 + :pypi:`pytest-docs` Documentation tool for pytest Nov 11, 2018 4 - Beta pytest (>=3.5.0) + :pypi:`pytest-docstyle` pytest plugin to run pydocstyle Mar 23, 2020 3 - Alpha N/A + :pypi:`pytest-doctest-custom` A py.test plugin for customizing string representations of doctest results. Jul 25, 2016 4 - Beta N/A + :pypi:`pytest-doctest-ellipsis-markers` Setup additional values for ELLIPSIS_MARKER for doctests Jan 12, 2018 4 - Beta N/A + :pypi:`pytest-doctest-import` A simple pytest plugin to import names and add them to the doctest namespace. Nov 13, 2018 4 - Beta pytest (>=3.3.0) + :pypi:`pytest-doctest-mkdocstrings` Run pytest --doctest-modules with markdown docstrings in code blocks (\`\`\`) Mar 02, 2024 N/A pytest + :pypi:`pytest-doctestplus` Pytest plugin with advanced doctest features. Jan 25, 2025 5 - Production/Stable pytest>=4.6 + :pypi:`pytest-documentary` A simple pytest plugin to generate test documentation Jul 11, 2024 N/A pytest + :pypi:`pytest-dogu-report` pytest plugin for dogu report Jul 07, 2023 N/A N/A + :pypi:`pytest-dogu-sdk` pytest plugin for the Dogu Dec 14, 2023 N/A N/A + :pypi:`pytest-dolphin` Some extra stuff that we use ininternally Nov 30, 2016 4 - Beta pytest (==3.0.4) + :pypi:`pytest-donde` record pytest session characteristics per test item (coverage and duration) into a persistent file and use them in your own plugin or script. Oct 01, 2023 4 - Beta pytest >=7.3.1 + :pypi:`pytest-doorstop` A pytest plugin for adding test results into doorstop items. Jun 09, 2020 4 - Beta pytest (>=3.5.0) + :pypi:`pytest-dotenv` A py.test plugin that parses environment files before running tests Jun 16, 2020 4 - Beta pytest (>=5.0.0) + :pypi:`pytest-dot-only-pkcopley` A Pytest marker for only running a single test Oct 27, 2023 N/A N/A + :pypi:`pytest-dparam` A more readable alternative to @pytest.mark.parametrize. Aug 27, 2024 6 - Mature pytest + :pypi:`pytest-dpg` pytest-dpg is a pytest plugin for testing Dear PyGui (DPG) applications Aug 13, 2024 N/A N/A + :pypi:`pytest-draw` Pytest plugin for randomly selecting a specific number of tests Mar 21, 2023 3 - Alpha pytest + :pypi:`pytest-drf` A Django REST framework plugin for pytest. Jul 12, 2022 5 - Production/Stable pytest (>=3.7) + :pypi:`pytest-drivings` Tool to allow webdriver automation to be ran locally or remotely Jan 13, 2021 N/A N/A + :pypi:`pytest-drop-dup-tests` A Pytest plugin to drop duplicated tests during collection Mar 04, 2024 5 - Production/Stable pytest >=7 + :pypi:`pytest-dryci` Test caching plugin for pytest Sep 27, 2024 4 - Beta N/A + :pypi:`pytest-dryrun` A Pytest plugin to ignore tests during collection without reporting them in the test summary. Jan 19, 2025 5 - Production/Stable pytest<9,>=7.40 + :pypi:`pytest-dsl` A DSL testing framework based on pytest May 29, 2025 N/A pytest>=7.0.0 + :pypi:`pytest-dummynet` A py.test plugin providing access to a dummynet. Dec 15, 2021 5 - Production/Stable pytest + :pypi:`pytest-dump2json` A pytest plugin for dumping test results to json. Jun 29, 2015 N/A N/A + :pypi:`pytest-duration-insights` Jul 15, 2024 N/A N/A + :pypi:`pytest-durations` Pytest plugin reporting fixtures and test functions execution time. Apr 29, 2025 5 - Production/Stable pytest>=4.6 + :pypi:`pytest-dynamicrerun` A pytest plugin to rerun tests dynamically based off of test outcome and output. Aug 15, 2020 4 - Beta N/A + :pypi:`pytest-dynamodb` DynamoDB fixtures for pytest Apr 04, 2025 5 - Production/Stable pytest + :pypi:`pytest-easy-addoption` pytest-easy-addoption: Easy way to work with pytest addoption Jan 22, 2020 N/A N/A + :pypi:`pytest-easyMPI` Package that supports mpi tests in pytest Oct 21, 2020 N/A N/A + :pypi:`pytest-easyread` pytest plugin that makes terminal printouts of the reports easier to read Nov 17, 2017 N/A N/A + :pypi:`pytest-easy-server` Pytest plugin for easy testing against servers May 01, 2021 4 - Beta pytest (<5.0.0,>=4.3.1) ; python_version < "3.5" + :pypi:`pytest-ebics-sandbox` A pytest plugin for testing against an EBICS sandbox server. Requires docker. Aug 15, 2022 N/A N/A + :pypi:`pytest-ec2` Pytest execution on EC2 instance Oct 22, 2019 3 - Alpha N/A + :pypi:`pytest-echo` pytest plugin that allows to dump environment variables, package version and generic attributes Apr 27, 2025 5 - Production/Stable pytest>=8.3.3 + :pypi:`pytest-edit` Edit the source code of a failed test with \`pytest --edit\`. Nov 17, 2024 N/A pytest + :pypi:`pytest-ekstazi` Pytest plugin to select test using Ekstazi algorithm Sep 10, 2022 N/A pytest + :pypi:`pytest-elasticsearch` Elasticsearch fixtures and fixture factories for Pytest. Dec 03, 2024 5 - Production/Stable pytest>=7.0 + :pypi:`pytest-elasticsearch-test` Elasticsearch fixtures and fixture factories for Pytest. Apr 20, 2025 5 - Production/Stable pytest>=7.0 + :pypi:`pytest-elbase` Elbase pytest plugin Apr 15, 2025 N/A N/A + :pypi:`pytest-elements` Tool to help automate user interfaces Jan 13, 2021 N/A pytest (>=5.4,<6.0) + :pypi:`pytest-eliot` An eliot plugin for pytest. Aug 31, 2022 1 - Planning pytest (>=5.4.0) + :pypi:`pytest-elk-reporter` A simple plugin to use with pytest Jul 25, 2024 4 - Beta pytest>=3.5.0 + :pypi:`pytest-email` Send execution result email Jul 08, 2020 N/A pytest + :pypi:`pytest-embedded` A pytest plugin that designed for embedded testing. Apr 22, 2025 5 - Production/Stable pytest>=7.0 + :pypi:`pytest-embedded-arduino` Make pytest-embedded plugin work with Arduino. Apr 22, 2025 5 - Production/Stable N/A + :pypi:`pytest-embedded-idf` Make pytest-embedded plugin work with ESP-IDF. Apr 22, 2025 5 - Production/Stable N/A + :pypi:`pytest-embedded-jtag` Make pytest-embedded plugin work with JTAG. Apr 22, 2025 5 - Production/Stable N/A + :pypi:`pytest-embedded-nuttx` Make pytest-embedded plugin work with NuttX. Apr 22, 2025 5 - Production/Stable N/A + :pypi:`pytest-embedded-qemu` Make pytest-embedded plugin work with QEMU. Apr 22, 2025 5 - Production/Stable N/A + :pypi:`pytest-embedded-serial` Make pytest-embedded plugin work with Serial. Apr 22, 2025 5 - Production/Stable N/A + :pypi:`pytest-embedded-serial-esp` Make pytest-embedded plugin work with Espressif target boards. Apr 22, 2025 5 - Production/Stable N/A + :pypi:`pytest-embedded-wokwi` Make pytest-embedded plugin work with the Wokwi CLI. Apr 22, 2025 5 - Production/Stable N/A + :pypi:`pytest-embrace` 💝 Dataclasses-as-tests. Describe the runtime once and multiply coverage with no boilerplate. Mar 25, 2023 N/A pytest (>=7.0,<8.0) + :pypi:`pytest-emoji` A pytest plugin that adds emojis to your test result report Feb 19, 2019 4 - Beta pytest (>=4.2.1) + :pypi:`pytest-emoji-output` Pytest plugin to represent test output with emoji support Apr 09, 2023 4 - Beta pytest (==7.0.1) + :pypi:`pytest-enabler` Enable installed pytest plugins May 16, 2025 5 - Production/Stable pytest!=8.1.*,>=6; extra == "test" + :pypi:`pytest-encode` set your encoding and logger Nov 06, 2021 N/A N/A + :pypi:`pytest-encode-kane` set your encoding and logger Nov 16, 2021 N/A pytest + :pypi:`pytest-encoding` set your encoding and logger Aug 11, 2023 N/A pytest + :pypi:`pytest_energy_reporter` An energy estimation reporter for pytest Mar 28, 2024 3 - Alpha pytest<9.0.0,>=8.1.1 + :pypi:`pytest-enhanced-reports` Enhanced test reports for pytest Dec 15, 2022 N/A N/A + :pypi:`pytest-enhancements` Improvements for pytest (rejected upstream) Oct 30, 2019 4 - Beta N/A + :pypi:`pytest-env` pytest plugin that allows you to add environment variables. Sep 17, 2024 5 - Production/Stable pytest>=8.3.3 + :pypi:`pytest-envfiles` A py.test plugin that parses environment files before running tests Oct 08, 2015 3 - Alpha N/A + :pypi:`pytest-env-info` Push information about the running pytest into envvars Nov 25, 2017 4 - Beta pytest (>=3.1.1) + :pypi:`pytest-environment` Pytest Environment Mar 17, 2024 1 - Planning N/A + :pypi:`pytest-envraw` py.test plugin that allows you to add environment variables. Aug 27, 2020 4 - Beta pytest (>=2.6.0) + :pypi:`pytest-envvars` Pytest plugin to validate use of envvars on your tests Jun 13, 2020 5 - Production/Stable pytest (>=3.0.0) + :pypi:`pytest-env-yaml` Apr 02, 2019 N/A N/A + :pypi:`pytest-eradicate` pytest plugin to check for commented out code Sep 08, 2020 N/A pytest (>=2.4.2) + :pypi:`pytest_erp` py.test plugin to send test info to report portal dynamically Jan 13, 2015 N/A N/A + :pypi:`pytest-error-for-skips` Pytest plugin to treat skipped tests a test failure Dec 19, 2019 4 - Beta pytest (>=4.6) + :pypi:`pytest-errxfail` pytest plugin to mark a test as xfailed if it fails with the specified error message in the captured output Jan 06, 2025 4 - Beta pytest>=6.2.0 + :pypi:`pytest-essentials` A Pytest plugin providing essential utilities like soft assertions. May 19, 2025 3 - Alpha pytest>=7.0 + :pypi:`pytest-eth` PyTest plugin for testing Smart Contracts for Ethereum Virtual Machine (EVM). Aug 14, 2020 1 - Planning N/A + :pypi:`pytest-ethereum` pytest-ethereum: Pytest library for ethereum projects. Jun 24, 2019 3 - Alpha pytest (==3.3.2); extra == 'dev' + :pypi:`pytest-eucalyptus` Pytest Plugin for BDD Jun 28, 2022 N/A pytest (>=4.2.0) + :pypi:`pytest-evals` A pytest plugin for running and analyzing LLM evaluation tests Feb 02, 2025 N/A pytest>=7.0.0 + :pypi:`pytest-eventlet` Applies eventlet monkey-patch as a pytest plugin. Oct 04, 2021 N/A pytest ; extra == 'dev' + :pypi:`pytest-everyfunc` A pytest plugin to detect completely untested functions using coverage Apr 30, 2025 4 - Beta pytest + :pypi:`pytest_evm` The testing package containing tools to test Web3-based projects Sep 23, 2024 4 - Beta pytest<9.0.0,>=8.1.1 + :pypi:`pytest_exact_fixtures` Parse queries in Lucene and Elasticsearch syntaxes Feb 04, 2019 N/A N/A + :pypi:`pytest-examples` Pytest plugin for testing examples in docstrings and markdown files. May 06, 2025 N/A pytest>=7 + :pypi:`pytest-exasol-backend` Feb 11, 2025 N/A pytest<9,>=7 + :pypi:`pytest-exasol-extension` Feb 11, 2025 N/A pytest<9,>=7 + :pypi:`pytest-exasol-itde` Nov 22, 2024 N/A pytest<9,>=7 + :pypi:`pytest-exasol-saas` Nov 22, 2024 N/A pytest<9,>=7 + :pypi:`pytest-exasol-slc` Feb 11, 2025 N/A pytest<9,>=7 + :pypi:`pytest-excel` pytest plugin for generating excel reports Jun 18, 2024 5 - Production/Stable pytest>3.6 + :pypi:`pytest-exceptional` Better exceptions Mar 16, 2017 4 - Beta N/A + :pypi:`pytest-exception-script` Walk your code through exception script to check it's resiliency to failures. Aug 04, 2020 3 - Alpha pytest + :pypi:`pytest-executable` pytest plugin for testing executables Oct 07, 2023 N/A pytest <8,>=5 + :pypi:`pytest-execution-timer` A timer for the phases of Pytest's execution. Dec 24, 2021 4 - Beta N/A + :pypi:`pytest-exit-code` A pytest plugin that overrides the built-in exit codes to retain more information about the test results. May 06, 2024 4 - Beta pytest>=6.2.0 + :pypi:`pytest-exit-status` Enhance. Jan 25, 2025 N/A pytest>=8.0.0 + :pypi:`pytest-expect` py.test plugin to store test expectations and mark tests based on them Apr 21, 2016 4 - Beta N/A + :pypi:`pytest-expectdir` A pytest plugin to provide initial/expected directories, and check a test transforms the initial directory to the expected one Mar 19, 2023 5 - Production/Stable pytest (>=5.0) + :pypi:`pytest-expected` Record and play back your expectations Feb 26, 2025 N/A pytest + :pypi:`pytest-expecter` Better testing with expecter and pytest. Sep 18, 2022 5 - Production/Stable N/A + :pypi:`pytest-expectr` This plugin is used to expect multiple assert using pytest framework. Oct 05, 2018 N/A pytest (>=2.4.2) + :pypi:`pytest-expect-test` A fixture to support expect tests in pytest Apr 10, 2023 4 - Beta pytest (>=3.5.0) + :pypi:`pytest-experiments` A pytest plugin to help developers of research-oriented software projects keep track of the results of their numerical experiments. Dec 13, 2021 4 - Beta pytest (>=6.2.5,<7.0.0) + :pypi:`pytest-explicit` A Pytest plugin to ignore certain marked tests by default Jun 15, 2021 5 - Production/Stable pytest + :pypi:`pytest-exploratory` Interactive console for pytest. Sep 18, 2024 N/A pytest>=6.2 + :pypi:`pytest-explorer` terminal ui for exploring and running tests Aug 01, 2023 N/A N/A + :pypi:`pytest-ext` pytest plugin for automation test Mar 31, 2024 N/A pytest>=5.3 + :pypi:`pytest-extended-mock` a pytest extension for easy mock setup Mar 12, 2025 N/A pytest<9.0.0,>=8.3.5 + :pypi:`pytest-extensions` A collection of helpers for pytest to ease testing Aug 17, 2022 4 - Beta pytest ; extra == 'testing' + :pypi:`pytest-external-blockers` a special outcome for tests that are blocked for external reasons Oct 05, 2021 N/A pytest + :pypi:`pytest_extra` Some helpers for writing tests with pytest. Aug 14, 2014 N/A N/A + :pypi:`pytest-extra-durations` A pytest plugin to get durations on a per-function basis and per module basis. Apr 21, 2020 4 - Beta pytest (>=3.5.0) + :pypi:`pytest-extra-markers` Additional pytest markers to dynamically enable/disable tests viia CLI flags Mar 05, 2023 4 - Beta pytest + :pypi:`pytest-f3ts` Pytest Plugin for communicating test results and information to a FixturFab Test Runner GUI May 08, 2025 N/A pytest<8.0.0,>=7.2.1 + :pypi:`pytest-fabric` Provides test utilities to run fabric task tests by using docker containers Sep 12, 2018 5 - Production/Stable N/A + :pypi:`pytest-factory` Use factories for test setup with py.test Sep 06, 2020 3 - Alpha pytest (>4.3) + :pypi:`pytest-factoryboy` Factory Boy support for pytest. Mar 05, 2024 6 - Mature pytest (>=6.2) + :pypi:`pytest-factoryboy-fixtures` Generates pytest fixtures that allow the use of type hinting Jun 25, 2020 N/A N/A + :pypi:`pytest-factoryboy-state` Simple factoryboy random state management Mar 22, 2022 5 - Production/Stable pytest (>=5.0) + :pypi:`pytest-failed-screen-record` Create a video of the screen when pytest fails Jan 05, 2023 4 - Beta pytest (>=7.1.2d,<8.0.0) + :pypi:`pytest-failed-screenshot` Test case fails,take a screenshot,save it,attach it to the allure Apr 21, 2021 N/A N/A + :pypi:`pytest-failed-to-verify` A pytest plugin that helps better distinguishing real test failures from setup flakiness. Aug 08, 2019 5 - Production/Stable pytest (>=4.1.0) + :pypi:`pytest-fail-slow` Fail tests that take too long to run Jun 01, 2024 N/A pytest>=7.0 + :pypi:`pytest-failure-tracker` A pytest plugin for tracking test failures over multiple runs Jul 17, 2024 N/A pytest>=6.0.0 + :pypi:`pytest-faker` Faker integration with the pytest framework. Dec 19, 2016 6 - Mature N/A + :pypi:`pytest-falcon` Pytest helpers for Falcon. Sep 07, 2016 4 - Beta N/A + :pypi:`pytest-fantasy` Pytest plugin for Flask Fantasy Framework Mar 14, 2019 N/A N/A + :pypi:`pytest-fastapi` Dec 27, 2020 N/A N/A + :pypi:`pytest-fastapi-deps` A fixture which allows easy replacement of fastapi dependencies for testing Jul 20, 2022 5 - Production/Stable pytest + :pypi:`pytest-fastest` Use SCM and coverage to run only needed tests Oct 04, 2023 4 - Beta pytest (>=4.4) + :pypi:`pytest-fast-first` Pytest plugin that runs fast tests first Jan 19, 2023 3 - Alpha pytest + :pypi:`pytest-faulthandler` py.test plugin that activates the fault handler module for tests (dummy package) Jul 04, 2019 6 - Mature pytest (>=5.0) + :pypi:`pytest-fauna` A collection of helpful test fixtures for Fauna DB. Jan 03, 2025 N/A N/A + :pypi:`pytest-fauxfactory` Integration of fauxfactory into pytest. Dec 06, 2017 5 - Production/Stable pytest (>=3.2) + :pypi:`pytest-figleaf` py.test figleaf coverage plugin Jan 18, 2010 5 - Production/Stable N/A + :pypi:`pytest-file` Pytest File Mar 18, 2024 1 - Planning N/A + :pypi:`pytest-filecov` A pytest plugin to detect unused files Jun 27, 2021 4 - Beta pytest + :pypi:`pytest-filedata` easily load test data from files Apr 29, 2024 5 - Production/Stable N/A + :pypi:`pytest-filemarker` A pytest plugin that runs marked tests when files change. Dec 01, 2020 N/A pytest + :pypi:`pytest-file-watcher` Pytest-File-Watcher is a CLI tool that watches for changes in your code and runs pytest on the changed files. Mar 23, 2023 N/A pytest + :pypi:`pytest-filter-case` run test cases filter by mark Nov 05, 2020 N/A N/A + :pypi:`pytest-filter-subpackage` Pytest plugin for filtering based on sub-packages Mar 04, 2024 5 - Production/Stable pytest >=4.6 + :pypi:`pytest-find-dependencies` A pytest plugin to find dependencies between tests Mar 16, 2024 4 - Beta pytest >=4.3.0 + :pypi:`pytest-finer-verdicts` A pytest plugin to treat non-assertion failures as test errors. Jun 18, 2020 N/A pytest (>=5.4.3) + :pypi:`pytest-firefox` Feb 28, 2025 N/A N/A + :pypi:`pytest-fixturecheck` A pytest plugin to check fixture validity before test execution May 17, 2025 3 - Alpha pytest>=6.0.0 + :pypi:`pytest-fixture-classes` Fixtures as classes that work well with dependency injection, autocompletetion, type checkers, and language servers Sep 02, 2023 5 - Production/Stable pytest + :pypi:`pytest-fixturecollection` A pytest plugin to collect tests based on fixtures being used by tests Feb 22, 2024 4 - Beta pytest >=3.5.0 + :pypi:`pytest-fixture-config` Fixture configuration utils for py.test Oct 17, 2024 5 - Production/Stable pytest + :pypi:`pytest-fixture-forms` A pytest plugin for creating fixtures that holds different forms between tests. Dec 06, 2024 N/A pytest<9.0.0,>=7.0.0 + :pypi:`pytest-fixture-maker` Pytest plugin to load fixtures from YAML files Sep 21, 2021 N/A N/A + :pypi:`pytest-fixture-marker` A pytest plugin to add markers based on fixtures used. Oct 11, 2020 5 - Production/Stable N/A + :pypi:`pytest-fixture-order` pytest plugin to control fixture evaluation order May 16, 2022 5 - Production/Stable pytest (>=3.0) + :pypi:`pytest-fixture-ref` Lets users reference fixtures without name matching magic. Nov 17, 2022 4 - Beta N/A + :pypi:`pytest-fixture-remover` A LibCST codemod to remove pytest fixtures applied via the usefixtures decorator, as well as its parametrizations. Feb 14, 2024 5 - Production/Stable N/A + :pypi:`pytest-fixture-rtttg` Warn or fail on fixture name clash Feb 23, 2022 N/A pytest (>=7.0.1,<8.0.0) + :pypi:`pytest-fixtures` Common fixtures for pytest May 01, 2019 5 - Production/Stable N/A + :pypi:`pytest-fixture-tools` Plugin for pytest which provides tools for fixtures Apr 30, 2025 6 - Mature pytest + :pypi:`pytest-fixture-typecheck` A pytest plugin to assert type annotations at runtime. Aug 24, 2021 N/A pytest + :pypi:`pytest-flake8` pytest plugin to check FLAKE8 requirements Nov 09, 2024 5 - Production/Stable pytest>=7.0 + :pypi:`pytest-flake8-path` A pytest fixture for testing flake8 plugins. Oct 25, 2024 5 - Production/Stable pytest + :pypi:`pytest-flake8-v2` pytest plugin to check FLAKE8 requirements Mar 01, 2022 5 - Production/Stable pytest (>=7.0) + :pypi:`pytest-flake-detection` Continuously runs your tests to detect flaky tests Nov 29, 2024 4 - Beta pytest>=6.2.0 + :pypi:`pytest-flakefinder` Runs tests multiple times to expose flakiness. Oct 26, 2022 4 - Beta pytest (>=2.7.1) + :pypi:`pytest-flakes` pytest plugin to check source code with pyflakes Dec 02, 2021 5 - Production/Stable pytest (>=5) + :pypi:`pytest-flaptastic` Flaptastic py.test plugin Mar 17, 2019 N/A N/A + :pypi:`pytest-flask` A set of py.test fixtures to test Flask applications. Oct 23, 2023 5 - Production/Stable pytest >=5.2 + :pypi:`pytest-flask-ligand` Pytest fixtures and helper functions to use for testing flask-ligand microservices. Apr 25, 2023 4 - Beta pytest (~=7.3) + :pypi:`pytest-flask-sqlalchemy` A pytest plugin for preserving test isolation in Flask-SQlAlchemy using database transactions. Apr 30, 2022 4 - Beta pytest (>=3.2.1) + :pypi:`pytest-flask-sqlalchemy-transactions` Run tests in transactions using pytest, Flask, and SQLalchemy. Aug 02, 2018 4 - Beta pytest (>=3.2.1) + :pypi:`pytest-flexreport` Apr 15, 2023 4 - Beta pytest + :pypi:`pytest-fluent` A pytest plugin in order to provide logs via fluentd Aug 14, 2024 4 - Beta pytest>=7.0.0 + :pypi:`pytest-fluentbit` A pytest plugin in order to provide logs via fluentbit Jun 16, 2023 4 - Beta pytest (>=7.0.0) + :pypi:`pytest-fly` pytest runner and observer May 19, 2025 3 - Alpha pytest + :pypi:`pytest-flyte` Pytest fixtures for simplifying Flyte integration testing May 03, 2021 N/A pytest + :pypi:`pytest-focus` A pytest plugin that alerts user of failed test cases with screen notifications May 04, 2019 4 - Beta pytest + :pypi:`pytest-forbid` Mar 07, 2023 N/A pytest (>=7.2.2,<8.0.0) + :pypi:`pytest-forcefail` py.test plugin to make the test failing regardless of pytest.mark.xfail May 15, 2018 4 - Beta N/A + :pypi:`pytest-forward-compatability` A name to avoid typosquating pytest-foward-compatibility Sep 06, 2020 N/A N/A + :pypi:`pytest-forward-compatibility` A pytest plugin to shim pytest commandline options for fowards compatibility Sep 29, 2020 N/A N/A + :pypi:`pytest-frappe` Pytest Frappe Plugin - A set of pytest fixtures to test Frappe applications Jul 30, 2024 4 - Beta pytest>=7.0.0 + :pypi:`pytest-freethreaded` pytest plugin for running parallel tests Oct 03, 2024 5 - Production/Stable pytest + :pypi:`pytest-freezeblaster` Wrap tests with fixtures in freeze_time Feb 11, 2025 N/A pytest>=6.2.5 + :pypi:`pytest-freezegun` Wrap tests with fixtures in freeze_time Jul 19, 2020 4 - Beta pytest (>=3.0.0) + :pypi:`pytest-freezer` Pytest plugin providing a fixture interface for spulec/freezegun Dec 12, 2024 N/A pytest>=3.6 + :pypi:`pytest-freeze-reqs` Check if requirement files are frozen Apr 29, 2021 N/A N/A + :pypi:`pytest-frozen-uuids` Deterministically frozen UUID's for your tests Apr 17, 2022 N/A pytest (>=3.0) + :pypi:`pytest-func-cov` Pytest plugin for measuring function coverage Apr 15, 2021 3 - Alpha pytest (>=5) + :pypi:`pytest-funcnodes` Testing plugin for funcnodes Mar 19, 2025 4 - Beta pytest>=6.2.0 + :pypi:`pytest-funparam` An alternative way to parametrize test cases. Dec 02, 2021 4 - Beta pytest >=4.6.0 + :pypi:`pytest-fv` pytest extensions to support running functional-verification jobs Feb 27, 2025 N/A pytest + :pypi:`pytest-fxa` pytest plugin for Firefox Accounts Aug 28, 2018 5 - Production/Stable N/A + :pypi:`pytest-fxa-mte` pytest plugin for Firefox Accounts Oct 02, 2024 5 - Production/Stable N/A + :pypi:`pytest-fxtest` Oct 27, 2020 N/A N/A + :pypi:`pytest-fzf` fzf-based test selector for pytest Jan 06, 2025 4 - Beta pytest>=6.0.0 + :pypi:`pytest_gae` pytest plugin for apps written with Google's AppEngine Aug 03, 2016 3 - Alpha N/A + :pypi:`pytest-gak` A Pytest plugin and command line tool for interactive testing with Pytest Apr 10, 2025 N/A N/A + :pypi:`pytest-gather-fixtures` set up asynchronous pytest fixtures concurrently Aug 18, 2024 N/A pytest>=7.0.0 + :pypi:`pytest-gc` The garbage collector plugin for py.test Feb 01, 2018 N/A N/A + :pypi:`pytest-gcov` Uses gcov to measure test coverage of a C library Feb 01, 2018 3 - Alpha N/A + :pypi:`pytest-gcs` GCS fixtures and fixture factories for Pytest. Jan 24, 2025 5 - Production/Stable pytest>=6.2 + :pypi:`pytest-gee` The Python plugin for your GEE based packages. May 11, 2025 3 - Alpha pytest + :pypi:`pytest-gevent` Ensure that gevent is properly patched when invoking pytest Feb 25, 2020 N/A pytest + :pypi:`pytest-gherkin` A flexible framework for executing BDD gherkin tests Jul 27, 2019 3 - Alpha pytest (>=5.0.0) + :pypi:`pytest-gh-log-group` pytest plugin for gh actions Jan 11, 2022 3 - Alpha pytest + :pypi:`pytest-ghostinspector` For finding/executing Ghost Inspector tests May 17, 2016 3 - Alpha N/A + :pypi:`pytest-girder` A set of pytest fixtures for testing Girder applications. May 29, 2025 N/A pytest>=3.6 + :pypi:`pytest-git` Git repository fixture for py.test Oct 17, 2024 5 - Production/Stable pytest + :pypi:`pytest-gitconfig` Provide a Git config sandbox for testing Aug 11, 2024 4 - Beta pytest>=7.1.2 + :pypi:`pytest-gitcov` Pytest plugin for reporting on coverage of the last git commit. Jan 11, 2020 2 - Pre-Alpha N/A + :pypi:`pytest-git-diff` Pytest plugin that allows the user to select the tests affected by a range of git commits Apr 02, 2024 N/A N/A + :pypi:`pytest-git-fixtures` Pytest fixtures for testing with git. Mar 11, 2021 4 - Beta pytest + :pypi:`pytest-github` Plugin for py.test that associates tests with github issues using a marker. Mar 07, 2019 5 - Production/Stable N/A + :pypi:`pytest-github-actions-annotate-failures` pytest plugin to annotate failed tests with a workflow command for GitHub Actions Jan 17, 2025 5 - Production/Stable pytest>=6.0.0 + :pypi:`pytest-github-report` Generate a GitHub report using pytest in GitHub Workflows Jun 03, 2022 4 - Beta N/A + :pypi:`pytest-gitignore` py.test plugin to ignore the same files as git Jul 17, 2015 4 - Beta N/A + :pypi:`pytest-gitlab` Pytest Plugin for Gitlab Oct 16, 2024 N/A N/A + :pypi:`pytest-gitlabci-parallelized` Parallelize pytest across GitLab CI workers. Mar 08, 2023 N/A N/A + :pypi:`pytest-gitlab-code-quality` Collects warnings while testing and generates a GitLab Code Quality Report. Sep 09, 2024 N/A pytest>=8.1.1 + :pypi:`pytest-gitlab-fold` Folds output sections in GitLab CI build log Dec 31, 2023 4 - Beta pytest >=2.6.0 + :pypi:`pytest-git-selector` Utility to select tests that have had its dependencies modified (as identified by git diff) Nov 17, 2022 N/A N/A + :pypi:`pytest-glamor-allure` Extends allure-pytest functionality Apr 30, 2024 4 - Beta pytest<=8.2.0 + :pypi:`pytest-gnupg-fixtures` Pytest fixtures for testing with gnupg. Mar 04, 2021 4 - Beta pytest + :pypi:`pytest-golden` Plugin for pytest that offloads expected outputs to data files Jul 18, 2022 N/A pytest (>=6.1.2) + :pypi:`pytest-goldie` A plugin to support golden tests with pytest. May 23, 2023 4 - Beta pytest (>=3.5.0) + :pypi:`pytest-google-chat` Notify google chat channel for test results Mar 27, 2022 4 - Beta pytest + :pypi:`pytest-google-cloud-storage` Pytest custom features, e.g. fixtures and various tests. Aimed to emulate Google Cloud Storage service May 22, 2025 N/A pytest==8.3.5 + :pypi:`pytest-gradescope` A pytest plugin for Gradescope integration Apr 29, 2025 N/A N/A + :pypi:`pytest-graphql-schema` Get graphql schema as fixture for pytest Oct 18, 2019 N/A N/A + :pypi:`pytest-greendots` Green progress dots Feb 08, 2014 3 - Alpha N/A + :pypi:`pytest-group-by-class` A Pytest plugin for running a subset of your tests by splitting them in to groups of classes. Jun 27, 2023 5 - Production/Stable pytest (>=2.5) + :pypi:`pytest-growl` Growl notifications for pytest results. Jan 13, 2014 5 - Production/Stable N/A + :pypi:`pytest-grpc` pytest plugin for grpc May 01, 2020 N/A pytest (>=3.6.0) + :pypi:`pytest-grunnur` Py.Test plugin for Grunnur-based packages. Jul 26, 2024 N/A pytest>=6 + :pypi:`pytest_gui_status` Show pytest status in gui Jan 23, 2016 N/A pytest + :pypi:`pytest-hammertime` Display "🔨 " instead of "." for passed pytest tests. Jul 28, 2018 N/A pytest + :pypi:`pytest-hardware-test-report` A simple plugin to use with pytest Apr 01, 2024 4 - Beta pytest<9.0.0,>=8.0.0 + :pypi:`pytest-harmony` Chain tests and data with pytest Jan 17, 2023 N/A pytest (>=7.2.1,<8.0.0) + :pypi:`pytest-harvest` Store data created during your pytest tests execution, and retrieve it at the end of the session, e.g. for applicative benchmarking purposes. Mar 16, 2024 5 - Production/Stable N/A + :pypi:`pytest-helm-charts` A plugin to provide different types and configs of Kubernetes clusters that can be used for testing. Oct 31, 2024 4 - Beta pytest<9.0.0,>=8.0.0 + :pypi:`pytest-helm-templates` Pytest fixtures for unit testing the output of helm templates Aug 07, 2024 N/A pytest~=7.4.0; extra == "dev" + :pypi:`pytest-helper` Functions to help in using the pytest testing framework May 31, 2019 5 - Production/Stable N/A + :pypi:`pytest-helpers` pytest helpers May 17, 2020 N/A pytest + :pypi:`pytest-helpers-namespace` Pytest Helpers Namespace Plugin Dec 29, 2021 5 - Production/Stable pytest (>=6.0.0) + :pypi:`pytest-henry` Aug 29, 2023 N/A N/A + :pypi:`pytest-hidecaptured` Hide captured output May 04, 2018 4 - Beta pytest (>=2.8.5) + :pypi:`pytest-himark` This plugin aims to create markers automatically based on a json configuration. Jun 05, 2024 4 - Beta pytest>=6.2.0 + :pypi:`pytest-historic` Custom report to display pytest historical execution records Apr 08, 2020 N/A pytest + :pypi:`pytest-historic-hook` Custom listener to store execution results into MYSQL DB, which is used for pytest-historic report Apr 08, 2020 N/A pytest + :pypi:`pytest-history` Pytest plugin to keep a history of your pytest runs Jan 14, 2024 N/A pytest (>=7.4.3,<8.0.0) + :pypi:`pytest-home` Home directory fixtures Jul 28, 2024 5 - Production/Stable pytest + :pypi:`pytest-homeassistant` A pytest plugin for use with homeassistant custom components. Aug 12, 2020 4 - Beta N/A + :pypi:`pytest-homeassistant-custom-component` Experimental package to automatically extract test plugins for Home Assistant custom components May 30, 2025 3 - Alpha pytest==8.3.5 + :pypi:`pytest-honey` A simple plugin to use with pytest Jan 07, 2022 4 - Beta pytest (>=3.5.0) + :pypi:`pytest-honors` Report on tests that honor constraints, and guard against regressions Mar 06, 2020 4 - Beta N/A + :pypi:`pytest-hot-reloading` Sep 23, 2024 N/A N/A + :pypi:`pytest-hot-test` A plugin that tracks test changes Dec 10, 2022 4 - Beta pytest (>=3.5.0) + :pypi:`pytest-houdini` pytest plugin for testing code in Houdini. Jul 15, 2024 N/A pytest + :pypi:`pytest-hoverfly` Simplify working with Hoverfly from pytest Jan 30, 2023 N/A pytest (>=5.0) + :pypi:`pytest-hoverfly-wrapper` Integrates the Hoverfly HTTP proxy into Pytest Feb 27, 2023 5 - Production/Stable pytest (>=3.7.0) + :pypi:`pytest-hpfeeds` Helpers for testing hpfeeds in your python project Feb 28, 2023 4 - Beta pytest (>=6.2.4,<7.0.0) + :pypi:`pytest-html` pytest plugin for generating HTML reports Nov 07, 2023 5 - Production/Stable pytest>=7.0.0 + :pypi:`pytest-html-cn` pytest plugin for generating HTML reports Aug 19, 2024 5 - Production/Stable pytest!=6.0.0,>=5.0 + :pypi:`pytest-html-lee` optimized pytest plugin for generating HTML reports Jun 30, 2020 5 - Production/Stable pytest (>=5.0) + :pypi:`pytest-html-merger` Pytest HTML reports merging utility Jul 12, 2024 N/A N/A + :pypi:`pytest-html-object-storage` Pytest report plugin for send HTML report on object-storage Jan 17, 2024 5 - Production/Stable N/A + :pypi:`pytest-html-profiling` Pytest plugin for generating HTML reports with per-test profiling and optionally call graph visualizations. Based on pytest-html by Dave Hunt. Feb 11, 2020 5 - Production/Stable pytest (>=3.0) + :pypi:`pytest-html-reporter` Generates a static html report based on pytest framework Feb 13, 2022 N/A N/A + :pypi:`pytest-html-report-merger` May 22, 2024 N/A N/A + :pypi:`pytest-html-thread` pytest plugin for generating HTML reports Dec 29, 2020 5 - Production/Stable N/A + :pypi:`pytest-http` Fixture "http" for http requests Aug 22, 2024 N/A pytest + :pypi:`pytest-httpbin` Easily test your HTTP library against a local copy of httpbin Sep 18, 2024 5 - Production/Stable pytest; extra == "test" + :pypi:`pytest-httpdbg` A pytest plugin to record HTTP(S) requests with stack trace. May 08, 2025 4 - Beta pytest>=7.0.0 + :pypi:`pytest-http-mocker` Pytest plugin for http mocking (via https://github.com/vilus/mocker) Oct 20, 2019 N/A N/A + :pypi:`pytest-httpretty` A thin wrapper of HTTPretty for pytest Feb 16, 2014 3 - Alpha N/A + :pypi:`pytest_httpserver` pytest-httpserver is a httpserver for pytest Apr 10, 2025 3 - Alpha N/A + :pypi:`pytest-httptesting` http_testing framework on top of pytest Dec 19, 2024 N/A pytest>=8.2.0 + :pypi:`pytest-httpx` Send responses to httpx. Nov 28, 2024 5 - Production/Stable pytest==8.* + :pypi:`pytest-httpx-blockage` Disable httpx requests during a test run Feb 16, 2023 N/A pytest (>=7.2.1) + :pypi:`pytest-httpx-recorder` Recorder feature based on pytest_httpx, like recorder feature in responses. Jan 04, 2024 5 - Production/Stable pytest + :pypi:`pytest-hue` Visualise PyTest status via your Phillips Hue lights May 09, 2019 N/A N/A + :pypi:`pytest-hylang` Pytest plugin to allow running tests written in hylang Mar 28, 2021 N/A pytest + :pypi:`pytest-hypo-25` help hypo module for pytest Jan 12, 2020 3 - Alpha N/A + :pypi:`pytest-iam` A fully functional OAUTH2 / OpenID Connect (OIDC) / SCIM server to be used in your testsuite Apr 24, 2025 4 - Beta pytest>=7.0.0 + :pypi:`pytest-ibutsu` A plugin to sent pytest results to an Ibutsu server Feb 06, 2025 4 - Beta pytest>=7.1 + :pypi:`pytest-icdiff` use icdiff for better error messages in pytest assertions Dec 05, 2023 4 - Beta pytest + :pypi:`pytest-idapro` A pytest plugin for idapython. Allows a pytest setup to run tests outside and inside IDA in an automated manner by runnig pytest inside IDA and by mocking idapython api Nov 03, 2018 N/A N/A + :pypi:`pytest-idem` A pytest plugin to help with testing idem projects Dec 13, 2023 5 - Production/Stable N/A + :pypi:`pytest-idempotent` Pytest plugin for testing function idempotence. Jul 25, 2022 N/A N/A + :pypi:`pytest-ignore-flaky` ignore failures from flaky tests (pytest plugin) Apr 20, 2024 5 - Production/Stable pytest>=6.0 + :pypi:`pytest-ignore-test-results` A pytest plugin to ignore test results. Feb 03, 2025 5 - Production/Stable pytest>=7.0 + :pypi:`pytest-image-diff` Dec 31, 2024 3 - Alpha pytest + :pypi:`pytest-image-snapshot` A pytest plugin for image snapshot management and comparison. Jul 01, 2024 4 - Beta pytest>=3.5.0 + :pypi:`pytest-impacted` A pytest plugin that selectively runs tests impacted by codechanges via git introspection, ASL parsing, and dependency graph analysis. May 31, 2025 4 - Beta pytest>=8.0.0 + :pypi:`pytest-import-check` pytest plugin to check whether Python modules can be imported Jul 19, 2024 3 - Alpha pytest>=8.1 + :pypi:`pytest-incremental` an incremental test runner (pytest plugin) Apr 24, 2021 5 - Production/Stable N/A + :pypi:`pytest-infinity` Jun 09, 2024 N/A pytest<9.0.0,>=8.0.0 + :pypi:`pytest-influx` Pytest plugin for managing your influx instance between test runs Oct 16, 2024 N/A pytest<9.0.0,>=8.3.3 + :pypi:`pytest-influxdb` Plugin for influxdb and pytest integration. Apr 20, 2021 N/A N/A + :pypi:`pytest-info-collector` pytest plugin to collect information from tests May 26, 2019 3 - Alpha N/A + :pypi:`pytest-info-plugin` Get executed interface information in pytest interface automation framework Sep 14, 2023 N/A N/A + :pypi:`pytest-informative-node` display more node ininformation. Apr 25, 2019 4 - Beta N/A + :pypi:`pytest-infrahouse` A set of fixtures to use with pytest Mar 18, 2025 4 - Beta pytest~=8.3 + :pypi:`pytest-infrastructure` pytest stack validation prior to testing executing Apr 12, 2020 4 - Beta N/A + :pypi:`pytest-ini` Reuse pytest.ini to store env variables Apr 26, 2022 N/A N/A + :pypi:`pytest-initry` Plugin for sending automation test data from Pytest to the initry Apr 30, 2024 N/A pytest<9.0.0,>=8.1.1 + :pypi:`pytest-inline` A pytest plugin for writing inline tests Oct 24, 2024 4 - Beta pytest<9.0,>=7.0 + :pypi:`pytest-inmanta` A py.test plugin providing fixtures to simplify inmanta modules testing. Apr 09, 2025 5 - Production/Stable pytest + :pypi:`pytest-inmanta-extensions` Inmanta tests package May 27, 2025 5 - Production/Stable N/A + :pypi:`pytest-inmanta-lsm` Common fixtures for inmanta LSM related modules Apr 09, 2025 5 - Production/Stable N/A + :pypi:`pytest-inmanta-srlinux` Pytest library to facilitate end to end testing of inmanta projects Apr 22, 2025 3 - Alpha N/A + :pypi:`pytest-inmanta-yang` Common fixtures used in inmanta yang related modules Feb 22, 2024 4 - Beta pytest + :pypi:`pytest-Inomaly` A simple image diff plugin for pytest Feb 13, 2018 4 - Beta N/A + :pypi:`pytest-in-robotframework` The extension enables easy execution of pytest tests within the Robot Framework environment. Nov 23, 2024 N/A pytest + :pypi:`pytest-insper` Pytest plugin for courses at Insper Mar 21, 2024 N/A pytest + :pypi:`pytest-insta` A practical snapshot testing plugin for pytest Feb 19, 2024 N/A pytest (>=7.2.0,<9.0.0) + :pypi:`pytest-instafail` pytest plugin to show failures instantly Mar 31, 2023 4 - Beta pytest (>=5) + :pypi:`pytest-instrument` pytest plugin to instrument tests Apr 05, 2020 5 - Production/Stable pytest (>=5.1.0) + :pypi:`pytest-integration` Organizing pytests by integration or not Nov 17, 2022 N/A N/A + :pypi:`pytest-integration-mark` Automatic integration test marking and excluding plugin for pytest May 22, 2023 N/A pytest (>=5.2) + :pypi:`pytest-interactive` A pytest plugin for console based interactive test selection just after the collection phase Nov 30, 2017 3 - Alpha N/A + :pypi:`pytest-intercept-remote` Pytest plugin for intercepting outgoing connection requests during pytest run. May 24, 2021 4 - Beta pytest (>=4.6) + :pypi:`pytest-interface-tester` Pytest plugin for checking charm relation interface protocol compliance. Feb 13, 2025 4 - Beta pytest + :pypi:`pytest-invenio` Pytest fixtures for Invenio. May 08, 2025 5 - Production/Stable pytest<9.0.0,>=6 + :pypi:`pytest-involve` Run tests covering a specific file or changeset Feb 02, 2020 4 - Beta pytest (>=3.5.0) + :pypi:`pytest-iovis` A Pytest plugin to enable Jupyter Notebook testing with Papermill Nov 06, 2024 4 - Beta pytest>=7.1.0 + :pypi:`pytest-ipdb` A py.test plug-in to enable drop to ipdb debugger on test failure. Mar 20, 2013 2 - Pre-Alpha N/A + :pypi:`pytest-ipynb` THIS PROJECT IS ABANDONED Jan 29, 2019 3 - Alpha N/A + :pypi:`pytest-ipynb2` Pytest plugin to run tests in Jupyter Notebooks Mar 09, 2025 N/A pytest + :pypi:`pytest-ipywidgets` May 30, 2025 N/A pytest + :pypi:`pytest-iso` Plugin for pytest to produce test documentation for code audits. May 15, 2025 4 - Beta pytest<9.0.0,>=7.4.0 + :pypi:`pytest-isolate` Run pytest tests in isolated subprocesses May 22, 2025 4 - Beta pytest + :pypi:`pytest-isolate-mpi` pytest-isolate-mpi allows for MPI-parallel tests being executed in a segfault and MPI_Abort safe manner Feb 24, 2025 4 - Beta pytest>=5 + :pypi:`pytest-isort` py.test plugin to check import ordering using isort Mar 05, 2024 5 - Production/Stable pytest (>=5.0) + :pypi:`pytest-it` Pytest plugin to display test reports as a plaintext spec, inspired by Rspec: https://github.com/mattduck/pytest-it. Jan 29, 2024 4 - Beta N/A + :pypi:`pytest-item-dict` Get a hierarchical dict of session.items Nov 14, 2024 4 - Beta pytest>=8.3.0 + :pypi:`pytest-iterassert` Nicer list and iterable assertion messages for pytest May 11, 2020 3 - Alpha N/A + :pypi:`pytest-iteration` Add iteration mark for tests Aug 22, 2024 N/A pytest + :pypi:`pytest-iters` A contextmanager pytest fixture for handling multiple mock iters May 24, 2022 N/A N/A + :pypi:`pytest_jar_yuan` A allure and pytest used package Dec 12, 2022 N/A N/A + :pypi:`pytest-jasmine` Run jasmine tests from your pytest test suite Nov 04, 2017 1 - Planning N/A + :pypi:`pytest-jelastic` Pytest plugin defining the necessary command-line options to pass to pytests testing a Jelastic environment. Nov 16, 2022 N/A pytest (>=7.2.0,<8.0.0) + :pypi:`pytest-jest` A custom jest-pytest oriented Pytest reporter May 22, 2018 4 - Beta pytest (>=3.3.2) + :pypi:`pytest-jinja` A plugin to generate customizable jinja-based HTML reports in pytest Oct 04, 2022 3 - Alpha pytest (>=6.2.5,<7.0.0) + :pypi:`pytest-jira` py.test JIRA integration plugin, using markers Apr 15, 2025 3 - Alpha N/A + :pypi:`pytest-jira-xfail` Plugin skips (xfail) tests if unresolved Jira issue(s) linked Jul 09, 2024 N/A pytest>=7.2.0 + :pypi:`pytest-jira-xray` pytest plugin to integrate tests with JIRA XRAY May 24, 2025 4 - Beta pytest>=6.2.4 + :pypi:`pytest-job-selection` A pytest plugin for load balancing test suites Jan 30, 2023 4 - Beta pytest (>=3.5.0) + :pypi:`pytest-jobserver` Limit parallel tests with posix jobserver. May 15, 2019 5 - Production/Stable pytest + :pypi:`pytest-joke` Test failures are better served with humor. Oct 08, 2019 4 - Beta pytest (>=4.2.1) + :pypi:`pytest-json` Generate JSON test reports Jan 18, 2016 4 - Beta N/A + :pypi:`pytest-json-ctrf` Pytest plugin to generate json report in CTRF (Common Test Report Format) Oct 10, 2024 N/A pytest>6.0.0 + :pypi:`pytest-json-fixtures` JSON output for the --fixtures flag Mar 14, 2023 4 - Beta N/A + :pypi:`pytest-jsonlint` UNKNOWN Aug 04, 2016 N/A N/A + :pypi:`pytest-json-report` A pytest plugin to report test results as JSON files Mar 15, 2022 4 - Beta pytest (>=3.8.0) + :pypi:`pytest-json-report-wip` A pytest plugin to report test results as JSON files Oct 28, 2023 4 - Beta pytest >=3.8.0 + :pypi:`pytest-jsonschema` A pytest plugin to perform JSONSchema validations Apr 20, 2025 4 - Beta pytest>=6.2.0 + :pypi:`pytest-jtr` pytest plugin supporting json test report output Jul 21, 2024 N/A pytest<8.0.0,>=7.1.2 + :pypi:`pytest-jubilant` Add your description here May 14, 2025 N/A pytest>=8.3.5 + :pypi:`pytest-junit-xray-xml` Export test results in an augmented JUnit format for usage with Xray () Jan 01, 2025 4 - Beta pytest + :pypi:`pytest-jupyter` A pytest plugin for testing Jupyter libraries and extensions. Apr 04, 2024 4 - Beta pytest>=7.0 + :pypi:`pytest-jupyterhub` A reusable JupyterHub pytest plugin Apr 25, 2023 5 - Production/Stable pytest + :pypi:`pytest-kafka` Zookeeper, Kafka server, and Kafka consumer fixtures for Pytest Aug 14, 2024 N/A pytest + :pypi:`pytest-kafkavents` A plugin to send pytest events to Kafka Sep 08, 2021 4 - Beta pytest + :pypi:`pytest-kairos` Pytest plugin with random number generation, reproducibility, and test repetition Aug 08, 2024 5 - Production/Stable pytest>=5.0.0 + :pypi:`pytest-kasima` Display horizontal lines above and below the captured standard output for easy viewing. Jan 26, 2023 5 - Production/Stable pytest (>=7.2.1,<8.0.0) + :pypi:`pytest-keep-together` Pytest plugin to customize test ordering by running all 'related' tests together Dec 07, 2022 5 - Production/Stable pytest + :pypi:`pytest-kexi` Apr 29, 2022 N/A pytest (>=7.1.2,<8.0.0) + :pypi:`pytest-keyring` A Pytest plugin to access the system's keyring to provide credentials for tests Dec 08, 2024 N/A pytest>=8.0.2 + :pypi:`pytest-kind` Kubernetes test support with KIND for pytest Nov 30, 2022 5 - Production/Stable N/A + :pypi:`pytest-kivy` Kivy GUI tests fixtures using pytest Jul 06, 2021 4 - Beta pytest (>=3.6) + :pypi:`pytest-knows` A pytest plugin that can automaticly skip test case based on dependence info calculated by trace Aug 22, 2014 N/A N/A + :pypi:`pytest-konira` Run Konira DSL tests with py.test Oct 09, 2011 N/A N/A + :pypi:`pytest-kookit` Your simple but kooky integration testing with pytest Sep 10, 2024 N/A N/A + :pypi:`pytest-koopmans` A plugin for testing the koopmans package Nov 21, 2022 4 - Beta pytest (>=3.5.0) + :pypi:`pytest-krtech-common` pytest krtech common library Nov 28, 2016 4 - Beta N/A + :pypi:`pytest-kubernetes` Feb 04, 2025 N/A pytest<9.0.0,>=8.3.0 + :pypi:`pytest-kuunda` pytest plugin to help with test data setup for PySpark tests Feb 25, 2024 4 - Beta pytest >=6.2.0 + :pypi:`pytest-kwparametrize` Alternate syntax for @pytest.mark.parametrize with test cases as dictionaries and default value fallbacks Jan 22, 2021 N/A pytest (>=6) + :pypi:`pytest-lambda` Define pytest fixtures with lambda functions. May 27, 2024 5 - Production/Stable pytest<9,>=3.6 + :pypi:`pytest-lamp` Jan 06, 2017 3 - Alpha N/A + :pypi:`pytest-langchain` Pytest-style test runner for langchain agents Feb 26, 2023 N/A pytest + :pypi:`pytest-lark` Create fancy and clear HTML test reports. Nov 05, 2023 N/A N/A + :pypi:`pytest-latin-hypercube` Implementation of Latin Hypercube Sampling for pytest. Feb 27, 2025 N/A pytest + :pypi:`pytest-launchable` Launchable Pytest Plugin Apr 05, 2023 N/A pytest (>=4.2.0) + :pypi:`pytest-layab` Pytest fixtures for layab. Oct 05, 2020 5 - Production/Stable N/A + :pypi:`pytest-lazy-fixture` It helps to use fixtures in pytest.mark.parametrize Feb 01, 2020 4 - Beta pytest (>=3.2.5) + :pypi:`pytest-lazy-fixtures` Allows you to use fixtures in @pytest.mark.parametrize. May 27, 2025 N/A pytest>=7 + :pypi:`pytest-ldap` python-ldap fixtures for pytest Aug 18, 2020 N/A pytest + :pypi:`pytest-leak-finder` Find the test that's leaking before the one that fails Feb 15, 2023 4 - Beta pytest (>=3.5.0) + :pypi:`pytest-leaks` A pytest plugin to trace resource leaks. Nov 27, 2019 1 - Planning N/A + :pypi:`pytest-leaping` A simple plugin to use with pytest Mar 27, 2024 4 - Beta pytest>=6.2.0 + :pypi:`pytest-leo-interface` Pytest extension tool for leo projects. Mar 19, 2025 N/A N/A + :pypi:`pytest-level` Select tests of a given level or lower Oct 21, 2019 N/A pytest + :pypi:`pytest-lf-skip` A pytest plugin which makes \`--last-failed\` skip instead of deselect tests. May 26, 2025 4 - Beta pytest>=8.3.5 + :pypi:`pytest-libfaketime` A python-libfaketime plugin for pytest Apr 12, 2024 4 - Beta pytest>=3.0.0 + :pypi:`pytest-libiio` A pytest plugin to manage interfacing with libiio contexts Oct 01, 2024 4 - Beta N/A + :pypi:`pytest-libnotify` Pytest plugin that shows notifications about the test run Apr 02, 2021 3 - Alpha pytest + :pypi:`pytest-ligo` Jan 16, 2020 4 - Beta N/A + :pypi:`pytest-lineno` A pytest plugin to show the line numbers of test functions Dec 04, 2020 N/A pytest + :pypi:`pytest-line-profiler` Profile code executed by pytest Aug 10, 2023 4 - Beta pytest >=3.5.0 + :pypi:`pytest-line-profiler-apn` Profile code executed by pytest Dec 05, 2022 N/A pytest (>=3.5.0) + :pypi:`pytest-lisa` Pytest plugin for organizing tests. Jan 21, 2021 3 - Alpha pytest (>=6.1.2,<7.0.0) + :pypi:`pytest-listener` A simple network listener Nov 29, 2024 5 - Production/Stable pytest + :pypi:`pytest-litf` A pytest plugin that stream output in LITF format Jan 18, 2021 4 - Beta pytest (>=3.1.1) + :pypi:`pytest-litter` Pytest plugin which verifies that tests do not modify file trees. Nov 23, 2023 4 - Beta pytest >=6.1 + :pypi:`pytest-live` Live results for pytest Mar 08, 2020 N/A pytest + :pypi:`pytest-llmeval` A pytest plugin to evaluate/benchmark LLM prompts Mar 19, 2025 4 - Beta pytest>=6.2.0 + :pypi:`pytest-local-badge` Generate local badges (shields) reporting your test suite status. Jan 15, 2023 N/A pytest (>=6.1.0) + :pypi:`pytest-localftpserver` A PyTest plugin which provides an FTP fixture for your tests May 19, 2024 5 - Production/Stable pytest + :pypi:`pytest-localserver` pytest plugin to test server connections locally. Oct 06, 2024 4 - Beta N/A + :pypi:`pytest-localstack` Pytest plugin for AWS integration tests Jun 07, 2023 4 - Beta pytest (>=6.0.0,<7.0.0) + :pypi:`pytest-lock` pytest-lock is a pytest plugin that allows you to "lock" the results of unit tests, storing them in a local cache. This is particularly useful for tests that are resource-intensive or don't need to be run every time. When the tests are run subsequently, pytest-lock will compare the current results with the locked results and issue a warning if there are any discrepancies. Feb 03, 2024 N/A pytest (>=7.4.3,<8.0.0) + :pypi:`pytest-lockable` lockable resource plugin for pytest Jan 24, 2024 5 - Production/Stable pytest + :pypi:`pytest-locker` Used to lock object during testing. Essentially changing assertions from being hard coded to asserting that nothing changed Dec 20, 2024 N/A pytest>=5.4 + :pypi:`pytest-log` print log Aug 15, 2021 N/A pytest (>=3.8) + :pypi:`pytest-logbook` py.test plugin to capture logbook log messages Nov 23, 2015 5 - Production/Stable pytest (>=2.8) + :pypi:`pytest-logdog` Pytest plugin to test logging Jun 15, 2021 1 - Planning pytest (>=6.2.0) + :pypi:`pytest-logfest` Pytest plugin providing three logger fixtures with basic or full writing to log files Jul 21, 2019 4 - Beta pytest (>=3.5.0) + :pypi:`pytest-logger` Plugin configuring handlers for loggers from Python logging module. Mar 10, 2024 5 - Production/Stable pytest (>=3.2) + :pypi:`pytest-logging` Configures logging and allows tweaking the log level with a py.test flag Nov 04, 2015 4 - Beta N/A + :pypi:`pytest-logging-end-to-end-test-tool` Sep 23, 2022 N/A pytest (>=7.1.2,<8.0.0) + :pypi:`pytest-logging-strict` pytest fixture logging configured from packaged YAML May 20, 2025 3 - Alpha pytest + :pypi:`pytest-logikal` Common testing environment Apr 30, 2025 5 - Production/Stable pytest==8.3.5 + :pypi:`pytest-log-report` Package for creating a pytest test run reprot Dec 26, 2019 N/A N/A + :pypi:`pytest-logscanner` Pytest plugin for logscanner (A logger for python logging outputting to easily viewable (and filterable) html files. Good for people not grep savey, and color higlighting and quickly changing filters might even bye useful for commandline wizards.) Sep 30, 2024 4 - Beta pytest>=8.2.2 + :pypi:`pytest-loguru` Pytest Loguru Mar 20, 2024 5 - Production/Stable pytest; extra == "test" + :pypi:`pytest-loop` pytest plugin for looping tests Oct 17, 2024 5 - Production/Stable pytest + :pypi:`pytest-lsp` A pytest plugin for end-to-end testing of language servers Nov 23, 2024 3 - Alpha pytest + :pypi:`pytest-lw-realtime-result` Pytest plugin to generate realtime test results to a file Mar 13, 2025 N/A pytest>=3.5.0 + :pypi:`pytest-manifest` PyTest plugin for recording and asserting against a manifest file Apr 07, 2025 N/A pytest + :pypi:`pytest-manual-marker` pytest marker for marking manual tests Aug 04, 2022 3 - Alpha pytest>=7 + :pypi:`pytest-mark-count` Get a count of the number of tests marked, unmarked, and unique tests if tests have multiple markers Nov 13, 2024 4 - Beta pytest>=8.0.0 + :pypi:`pytest-markdoctest` A pytest plugin to doctest your markdown files Jul 22, 2022 4 - Beta pytest (>=6) + :pypi:`pytest-markdown` Test your markdown docs with pytest Jan 15, 2021 4 - Beta pytest (>=6.0.1,<7.0.0) + :pypi:`pytest-markdown-docs` Run markdown code fences through pytest Apr 09, 2025 N/A pytest>=7.0.0 + :pypi:`pytest-marker-bugzilla` py.test bugzilla integration plugin, using markers Apr 02, 2025 5 - Production/Stable pytest>=2.2.4 + :pypi:`pytest-markers-presence` A simple plugin to detect missed pytest tags and markers" Oct 30, 2024 4 - Beta pytest>=6.0 + :pypi:`pytest-mark-filter` Filter pytest marks by name using match kw May 11, 2025 N/A pytest>=8.3.0 + :pypi:`pytest-markfiltration` UNKNOWN Nov 08, 2011 3 - Alpha N/A + :pypi:`pytest-mark-manage` 用例标签化管理 Aug 15, 2024 N/A pytest + :pypi:`pytest-mark-no-py3` pytest plugin and bowler codemod to help migrate tests to Python 3 May 17, 2019 N/A pytest + :pypi:`pytest-marks` UNKNOWN Nov 23, 2012 3 - Alpha N/A + :pypi:`pytest-mask-secrets` Pytest plugin to hide sensitive data in test reports Jan 28, 2025 N/A N/A + :pypi:`pytest-matcher` Easy way to match captured \`pytest\` output against expectations stored in files Aug 01, 2024 5 - Production/Stable pytest + :pypi:`pytest-matchers` Matchers for pytest Feb 11, 2025 N/A pytest<9.0,>=7.0 + :pypi:`pytest-match-skip` Skip matching marks. Matches partial marks using wildcards. May 15, 2019 4 - Beta pytest (>=4.4.1) + :pypi:`pytest-mat-report` this is report Jan 20, 2021 N/A N/A + :pypi:`pytest-matrix` Provide tools for generating tests from combinations of fixtures. Jun 24, 2020 5 - Production/Stable pytest (>=5.4.3,<6.0.0) + :pypi:`pytest-maxcov` Compute the maximum coverage available through pytest with the minimum execution time cost Sep 24, 2023 N/A pytest (>=7.4.0,<8.0.0) + :pypi:`pytest-max-warnings` A Pytest plugin to exit non-zero exit code when the configured maximum warnings has been exceeded. Oct 23, 2024 4 - Beta pytest>=8.3.3 + :pypi:`pytest-maybe-context` Simplify tests with warning and exception cases. Apr 16, 2023 N/A pytest (>=7,<8) + :pypi:`pytest-maybe-raises` Pytest fixture for optional exception testing. May 27, 2022 N/A pytest ; extra == 'dev' + :pypi:`pytest-mccabe` pytest plugin to run the mccabe code complexity checker. Jul 22, 2020 3 - Alpha pytest (>=5.4.0) + :pypi:`pytest-md` Plugin for generating Markdown reports for pytest results Jul 11, 2019 3 - Alpha pytest (>=4.2.1) + :pypi:`pytest-md-report` A pytest plugin to generate test outcomes reports with markdown table format. May 02, 2025 4 - Beta pytest!=6.0.0,<9,>=3.3.2 + :pypi:`pytest-meilisearch` Pytest helpers for testing projects using Meilisearch Oct 08, 2024 N/A pytest>=7.4.3 + :pypi:`pytest-memlog` Log memory usage during tests May 03, 2023 N/A pytest (>=7.3.0,<8.0.0) + :pypi:`pytest-memprof` Estimates memory consumption of test functions Mar 29, 2019 4 - Beta N/A + :pypi:`pytest-memray` A simple plugin to use with pytest Jul 25, 2024 N/A pytest>=7.2 + :pypi:`pytest-menu` A pytest plugin for console based interactive test selection just after the collection phase Oct 04, 2017 3 - Alpha pytest (>=2.4.2) + :pypi:`pytest-mercurial` pytest plugin to write integration tests for projects using Mercurial Python internals Nov 21, 2020 1 - Planning N/A + :pypi:`pytest-mergify` Pytest plugin for Mergify May 13, 2025 N/A pytest>=6.0.0 + :pypi:`pytest-mesh` pytest_mesh插件 Aug 05, 2022 N/A pytest (==7.1.2) + :pypi:`pytest-message` Pytest plugin for sending report message of marked tests execution Aug 04, 2022 N/A pytest (>=6.2.5) + :pypi:`pytest-messenger` Pytest to Slack reporting plugin Nov 24, 2022 5 - Production/Stable N/A + :pypi:`pytest-metadata` pytest plugin for test session metadata Feb 12, 2024 5 - Production/Stable pytest>=7.0.0 + :pypi:`pytest-metrics` Custom metrics report for pytest Apr 04, 2020 N/A pytest + :pypi:`pytest-mh` Pytest multihost plugin May 15, 2025 N/A pytest + :pypi:`pytest-mimesis` Mimesis integration with the pytest test runner Mar 21, 2020 5 - Production/Stable pytest (>=4.2) + :pypi:`pytest-mimic` Easily record function calls while testing Apr 24, 2025 4 - Beta pytest>=6.2.0 + :pypi:`pytest-minecraft` A pytest plugin for running tests against Minecraft releases Apr 06, 2022 N/A pytest (>=6.0.1) + :pypi:`pytest-mini` A plugin to test mp Feb 06, 2023 N/A pytest (>=7.2.0,<8.0.0) + :pypi:`pytest-minio-mock` A pytest plugin for mocking Minio S3 interactions May 06, 2025 N/A pytest>=5.0.0 + :pypi:`pytest-missing-fixtures` Pytest plugin that creates missing fixtures Oct 14, 2020 4 - Beta pytest (>=3.5.0) + :pypi:`pytest-missing-modules` Pytest plugin to easily fake missing modules Sep 03, 2024 N/A pytest>=8.3.2 + :pypi:`pytest-mitmproxy` pytest plugin for mitmproxy tests Nov 13, 2024 N/A pytest>=7.0 + :pypi:`pytest-mitmproxy-plugin` Use MITM Proxy in autotests with full control from code Apr 10, 2025 4 - Beta pytest>=7.2.0 + :pypi:`pytest-ml` Test your machine learning! May 04, 2019 4 - Beta N/A + :pypi:`pytest-mocha` pytest plugin to display test execution output like a mochajs Apr 02, 2020 4 - Beta pytest (>=5.4.0) + :pypi:`pytest-mock` Thin-wrapper around the mock package for easier use with pytest May 26, 2025 5 - Production/Stable pytest>=6.2.5 + :pypi:`pytest-mock-api` A mock API server with configurable routes and responses available as a fixture. Feb 13, 2019 1 - Planning pytest (>=4.0.0) + :pypi:`pytest-mock-generator` A pytest fixture wrapper for https://pypi.org/project/mock-generator May 16, 2022 5 - Production/Stable N/A + :pypi:`pytest-mock-helper` Help you mock HTTP call and generate mock code Jan 24, 2018 N/A pytest + :pypi:`pytest-mockito` Base fixtures for mockito Jul 11, 2018 4 - Beta N/A + :pypi:`pytest-mockredis` An in-memory mock of a Redis server that runs in a separate thread. This is to be used for unit-tests that require a Redis database. Jan 02, 2018 2 - Pre-Alpha N/A + :pypi:`pytest-mock-resources` A pytest plugin for easily instantiating reproducible mock resources. Mar 10, 2025 N/A pytest>=1.0 + :pypi:`pytest-mock-server` Mock server plugin for pytest Jan 09, 2022 4 - Beta pytest (>=3.5.0) + :pypi:`pytest-mockservers` A set of fixtures to test your requests to HTTP/UDP servers Mar 31, 2020 N/A pytest (>=4.3.0) + :pypi:`pytest-mocktcp` A pytest plugin for testing TCP clients Oct 11, 2022 N/A pytest + :pypi:`pytest-modalt` Massively distributed pytest runs using modal.com Feb 27, 2024 4 - Beta pytest >=6.2.0 + :pypi:`pytest-modified-env` Pytest plugin to fail a test if it leaves modified \`os.environ\` afterwards. Jan 29, 2022 4 - Beta N/A + :pypi:`pytest-modifyjunit` Utility for adding additional properties to junit xml for IDM QE Jan 10, 2019 N/A N/A + :pypi:`pytest-molecule` PyTest Molecule Plugin :: discover and run molecule tests Mar 29, 2022 5 - Production/Stable pytest (>=7.0.0) + :pypi:`pytest-molecule-JC` PyTest Molecule Plugin :: discover and run molecule tests Jul 18, 2023 5 - Production/Stable pytest (>=7.0.0) + :pypi:`pytest-mongo` MongoDB process and client fixtures plugin for Pytest. Feb 28, 2025 5 - Production/Stable pytest>=6.2 + :pypi:`pytest-mongodb` pytest plugin for MongoDB fixtures May 16, 2023 5 - Production/Stable N/A + :pypi:`pytest-mongodb-nono` pytest plugin for MongoDB Jan 07, 2025 N/A N/A + :pypi:`pytest-mongodb-ry` pytest plugin for MongoDB Jan 21, 2025 N/A N/A + :pypi:`pytest-monitor` Pytest plugin for analyzing resource usage. Jun 25, 2023 5 - Production/Stable pytest + :pypi:`pytest-monkeyplus` pytest's monkeypatch subclass with extra functionalities Sep 18, 2012 5 - Production/Stable N/A + :pypi:`pytest-monkeytype` pytest-monkeytype: Generate Monkeytype annotations from your pytest tests. Jul 29, 2020 4 - Beta N/A + :pypi:`pytest-moto` Fixtures for integration tests of AWS services,uses moto mocking library. Aug 28, 2015 1 - Planning N/A + :pypi:`pytest-moto-fixtures` Fixtures for testing code that interacts with AWS Feb 04, 2025 1 - Planning pytest<9,>=8.3; extra == "pytest" + :pypi:`pytest-motor` A pytest plugin for motor, the non-blocking MongoDB driver. Jul 21, 2021 3 - Alpha pytest + :pypi:`pytest-mp` A test batcher for multiprocessed Pytest runs May 23, 2018 4 - Beta pytest + :pypi:`pytest-mpi` pytest plugin to collect information from tests Jan 08, 2022 3 - Alpha pytest + :pypi:`pytest-mpiexec` pytest plugin for running individual tests with mpiexec Jul 29, 2024 3 - Alpha pytest + :pypi:`pytest-mpl` pytest plugin to help with testing figures output from Matplotlib Feb 14, 2024 4 - Beta pytest + :pypi:`pytest-mproc` low-startup-overhead, scalable, distributed-testing pytest plugin Nov 15, 2022 4 - Beta pytest (>=6) + :pypi:`pytest-mqtt` pytest-mqtt supports testing systems based on MQTT Jan 07, 2025 5 - Production/Stable pytest<9; extra == "test" + :pypi:`pytest-multihost` Utility for writing multi-host tests for pytest Apr 07, 2020 4 - Beta N/A + :pypi:`pytest-multilog` Multi-process logs handling and other helpers for pytest Jan 17, 2023 N/A pytest + :pypi:`pytest-multithreading` a pytest plugin for th and concurrent testing Aug 05, 2024 N/A N/A + :pypi:`pytest-multithreading-allure` pytest_multithreading_allure Nov 25, 2022 N/A N/A + :pypi:`pytest-mutagen` Add the mutation testing feature to pytest Jul 24, 2020 N/A pytest (>=5.4) + :pypi:`pytest-my-cool-lib` Nov 02, 2023 N/A pytest (>=7.1.3,<8.0.0) + :pypi:`pytest-my-plugin` A pytest plugin that does awesome things Jan 27, 2025 N/A pytest>=6.0 + :pypi:`pytest-mypy` A Pytest Plugin for Mypy Apr 02, 2025 5 - Production/Stable pytest>=7.0 + :pypi:`pytest-mypyd` Mypy static type checker plugin for Pytest Aug 20, 2019 4 - Beta pytest (<4.7,>=2.8) ; python_version < "3.5" + :pypi:`pytest-mypy-plugins` pytest plugin for writing tests for mypy plugins Dec 21, 2024 4 - Beta pytest>=7.0.0 + :pypi:`pytest-mypy-plugins-shim` Substitute for "pytest-mypy-plugins" for Python implementations which aren't supported by mypy. Feb 14, 2025 N/A pytest>=6.0.0 + :pypi:`pytest-mypy-runner` Run the mypy static type checker as a pytest test case Apr 23, 2024 N/A pytest>=8.0 + :pypi:`pytest-mypy-testing` Pytest plugin to check mypy output. Mar 04, 2024 N/A pytest>=7,<9 + :pypi:`pytest-mysql` MySQL process and client fixtures for pytest Dec 10, 2024 5 - Production/Stable pytest>=6.2 + :pypi:`pytest-ndb` pytest notebook debugger Apr 28, 2024 N/A pytest + :pypi:`pytest-needle` pytest plugin for visual testing websites using selenium Dec 10, 2018 4 - Beta pytest (<5.0.0,>=3.0.0) + :pypi:`pytest-neo` pytest-neo is a plugin for pytest that shows tests like screen of Matrix. Jan 08, 2022 3 - Alpha pytest (>=6.2.0) + :pypi:`pytest-neos` Pytest plugin for neos Sep 10, 2024 5 - Production/Stable pytest<8.0,>=7.2; extra == "dev" + :pypi:`pytest-netconf` A pytest plugin that provides a mock NETCONF (RFC6241/RFC6242) server for local testing. Jan 06, 2025 N/A N/A + :pypi:`pytest-netdut` "Automated software testing for switches using pytest" Apr 11, 2025 N/A pytest>=3.5.0 + :pypi:`pytest-network` A simple plugin to disable network on socket level. May 07, 2020 N/A N/A + :pypi:`pytest-network-endpoints` Network endpoints plugin for pytest Mar 06, 2022 N/A pytest + :pypi:`pytest-never-sleep` pytest plugin helps to avoid adding tests without mock \`time.sleep\` May 05, 2021 3 - Alpha pytest (>=3.5.1) + :pypi:`pytest-nginx` nginx fixture for pytest May 03, 2025 5 - Production/Stable pytest>=3.0.0 + :pypi:`pytest-nginx-iplweb` nginx fixture for pytest - iplweb temporary fork Mar 01, 2019 5 - Production/Stable N/A + :pypi:`pytest-ngrok` Jan 20, 2022 3 - Alpha pytest + :pypi:`pytest-ngsfixtures` pytest ngs fixtures Sep 06, 2019 2 - Pre-Alpha pytest (>=5.0.0) + :pypi:`pytest-nhsd-apim` Pytest plugin accessing NHSDigital's APIM proxies Apr 01, 2025 N/A pytest<9.0.0,>=8.2.0 + :pypi:`pytest-nice` A pytest plugin that alerts user of failed test cases with screen notifications May 04, 2019 4 - Beta pytest + :pypi:`pytest-nice-parametrize` A small snippet for nicer PyTest's Parametrize Apr 17, 2021 5 - Production/Stable N/A + :pypi:`pytest_nlcov` Pytest plugin to get the coverage of the new lines (based on git diff) only Aug 05, 2024 N/A N/A + :pypi:`pytest-nocustom` Run all tests without custom markers Aug 05, 2024 5 - Production/Stable N/A + :pypi:`pytest-node-dependency` pytest plugin for controlling execution flow Apr 10, 2024 5 - Production/Stable N/A + :pypi:`pytest-nodev` Test-driven source code search for Python. Jul 21, 2016 4 - Beta pytest (>=2.8.1) + :pypi:`pytest-nogarbage` Ensure a test produces no garbage Feb 24, 2025 5 - Production/Stable pytest>=4.6.0 + :pypi:`pytest-no-problem` Pytest plugin to tell you when there's no problem Apr 05, 2025 N/A pytest>=7.0 + :pypi:`pytest-nose-attrib` pytest plugin to use nose @attrib marks decorators and pick tests based on attributes and partially uses nose-attrib plugin approach Aug 13, 2023 N/A N/A + :pypi:`pytest_notebook` A pytest plugin for testing Jupyter Notebooks. Nov 28, 2023 4 - Beta pytest>=3.5.0 + :pypi:`pytest-notice` Send pytest execution result email Nov 05, 2020 N/A N/A + :pypi:`pytest-notification` A pytest plugin for sending a desktop notification and playing a sound upon completion of tests Jun 19, 2020 N/A pytest (>=4) + :pypi:`pytest-notifier` A pytest plugin to notify test result Jun 12, 2020 3 - Alpha pytest + :pypi:`pytest_notify` Get notifications when your tests ends Jul 05, 2017 N/A pytest>=3.0.0 + :pypi:`pytest-notimplemented` Pytest markers for not implemented features and tests. Aug 27, 2019 N/A pytest (>=5.1,<6.0) + :pypi:`pytest-notion` A PyTest Reporter to send test runs to Notion.so Aug 07, 2019 N/A N/A + :pypi:`pytest-nunit` A pytest plugin for generating NUnit3 test result XML output Feb 26, 2024 5 - Production/Stable N/A + :pypi:`pytest-oar` PyTest plugin for the OAR testing framework May 12, 2025 N/A pytest>=6.0.1 + :pypi:`pytest-oarepo` Feb 14, 2025 N/A pytest>=7.1.2; extra == "base" + :pypi:`pytest-object-getter` Import any object from a 3rd party module while mocking its namespace on demand. Jul 31, 2022 5 - Production/Stable pytest + :pypi:`pytest-ochrus` pytest results data-base and HTML reporter Feb 21, 2018 4 - Beta N/A + :pypi:`pytest-odc` A pytest plugin for simplifying ODC database tests Aug 04, 2023 4 - Beta pytest (>=3.5.0) + :pypi:`pytest-odoo` py.test plugin to run Odoo tests May 20, 2025 5 - Production/Stable pytest>=8 + :pypi:`pytest-odoo-fixtures` Project description Jun 25, 2019 N/A N/A + :pypi:`pytest-oerp` pytest plugin to test OpenERP modules Feb 28, 2012 3 - Alpha N/A + :pypi:`pytest-offline` Mar 09, 2023 1 - Planning pytest (>=7.0.0,<8.0.0) + :pypi:`pytest-ogsm-plugin` 针对特定项目定制化插件,优化了pytest报告展示方式,并添加了项目所需特定参数 May 16, 2023 N/A N/A + :pypi:`pytest-ok` The ultimate pytest output plugin Apr 01, 2019 4 - Beta N/A + :pypi:`pytest-only` Use @pytest.mark.only to run a single test May 27, 2024 5 - Production/Stable pytest<9,>=3.6.0 + :pypi:`pytest-oof` A Pytest plugin providing structured, programmatic access to a test run's results Dec 11, 2023 4 - Beta N/A + :pypi:`pytest-oot` Run object-oriented tests in a simple format Sep 18, 2016 4 - Beta N/A + :pypi:`pytest-openfiles` Pytest plugin for detecting inadvertent open file handles Jun 05, 2024 3 - Alpha pytest>=4.6 + :pypi:`pytest-open-html` Auto-open HTML reports after pytest runs Mar 31, 2025 N/A pytest>=6.0 + :pypi:`pytest-opentelemetry` A pytest plugin for instrumenting test runs via OpenTelemetry Apr 25, 2025 N/A pytest + :pypi:`pytest-opentmi` pytest plugin for publish results to opentmi Mar 22, 2025 5 - Production/Stable pytest>=5.0 + :pypi:`pytest-operator` Fixtures for Operators Sep 28, 2022 N/A pytest + :pypi:`pytest-optional` include/exclude values of fixtures in pytest Oct 07, 2015 N/A N/A + :pypi:`pytest-optional-tests` Easy declaration of optional tests (i.e., that are not run by default) Apr 15, 2025 4 - Beta pytest; extra == "dev" + :pypi:`pytest-orchestration` A pytest plugin for orchestrating tests Jul 18, 2019 N/A N/A + :pypi:`pytest-order` pytest plugin to run your tests in a specific order Aug 22, 2024 5 - Production/Stable pytest>=5.0; python_version < "3.10" + :pypi:`pytest-ordered` Declare the order in which tests should run in your pytest.ini Oct 07, 2024 N/A pytest>=6.2.0 + :pypi:`pytest-ordering` pytest plugin to run your tests in a specific order Nov 14, 2018 4 - Beta pytest + :pypi:`pytest-order-modify` 新增run_marker 来自定义用例的执行顺序 Nov 04, 2022 N/A N/A + :pypi:`pytest-osxnotify` OS X notifications for py.test results. May 15, 2015 N/A N/A + :pypi:`pytest-ot` A pytest plugin for instrumenting test runs via OpenTelemetry Mar 21, 2024 N/A pytest; extra == "dev" + :pypi:`pytest-otel` OpenTelemetry plugin for Pytest Apr 24, 2025 N/A pytest==8.3.5 + :pypi:`pytest-override-env-var` Pytest mark to override a value of an environment variable. Feb 25, 2023 N/A N/A + :pypi:`pytest-owner` Add owner mark for tests Aug 19, 2024 N/A pytest + :pypi:`pytest-pact` A simple plugin to use with pytest Jan 07, 2019 4 - Beta N/A + :pypi:`pytest-pagerduty` Pytest plugin for PagerDuty integration via automation testing. Mar 22, 2025 N/A pytest<9.0.0,>=7.4.0 + :pypi:`pytest-pahrametahrize` Parametrize your tests with a Boston accent. Nov 24, 2021 4 - Beta pytest (>=6.0,<7.0) + :pypi:`pytest-parallel` a pytest plugin for parallel and concurrent testing Oct 10, 2021 3 - Alpha pytest (>=3.0.0) + :pypi:`pytest-parallel-39` a pytest plugin for parallel and concurrent testing Jul 12, 2021 3 - Alpha pytest (>=3.0.0) + :pypi:`pytest-parallelize-tests` pytest plugin that parallelizes test execution across multiple hosts Jan 27, 2023 4 - Beta N/A + :pypi:`pytest-param` pytest plugin to test all, first, last or random params Sep 11, 2016 4 - Beta pytest (>=2.6.0) + :pypi:`pytest-parametrization` Simpler PyTest parametrization May 22, 2022 5 - Production/Stable N/A + :pypi:`pytest-parametrization-annotation` A pytest library for parametrizing tests using type hints. Dec 10, 2024 5 - Production/Stable pytest>=7 + :pypi:`pytest-parametrize` pytest decorator for parametrizing test cases in a dict-way Nov 10, 2024 5 - Production/Stable pytest<9.0.0,>=8.3.0 + :pypi:`pytest-parametrize-cases` A more user-friendly way to write parametrized tests. Mar 13, 2022 N/A pytest (>=6.1.2) + :pypi:`pytest-parametrized` Pytest decorator for parametrizing tests with default iterables. Dec 21, 2024 5 - Production/Stable pytest + :pypi:`pytest-parametrize-suite` A simple pytest extension for creating a named test suite. Jan 19, 2023 5 - Production/Stable pytest + :pypi:`pytest_param_files` Create pytest parametrize decorators from external files. Jul 29, 2023 N/A pytest + :pypi:`pytest-params` Simplified pytest test case parameters. Apr 27, 2025 5 - Production/Stable pytest>=7.0.0 + :pypi:`pytest-param-scope` pytest parametrize scope fixture workaround Oct 18, 2023 N/A pytest + :pypi:`pytest-parawtf` Finally spell paramete?ri[sz]e correctly Dec 03, 2018 4 - Beta pytest (>=3.6.0) + :pypi:`pytest-pass` Check out https://github.com/elilutsky/pytest-pass Dec 04, 2019 N/A N/A + :pypi:`pytest-passrunner` Pytest plugin providing the 'run_on_pass' marker Feb 10, 2021 5 - Production/Stable pytest (>=4.6.0) + :pypi:`pytest-paste-config` Allow setting the path to a paste config file Sep 18, 2013 3 - Alpha N/A + :pypi:`pytest-patch` An automagic \`patch\` fixture that can patch objects directly or by name. Apr 29, 2023 3 - Alpha pytest (>=7.0.0) + :pypi:`pytest-patches` A contextmanager pytest fixture for handling multiple mock patches Aug 30, 2021 4 - Beta pytest (>=3.5.0) + :pypi:`pytest-patterns` pytest plugin to make testing complicated long string output easy to write and easy to debug Oct 22, 2024 4 - Beta pytest>=6 + :pypi:`pytest-pdb` pytest plugin which adds pdb helper commands related to pytest. Jul 31, 2018 N/A N/A + :pypi:`pytest-peach` pytest plugin for fuzzing with Peach API Security Apr 12, 2019 4 - Beta pytest (>=2.8.7) + :pypi:`pytest-pep257` py.test plugin for pep257 Jul 09, 2016 N/A N/A + :pypi:`pytest-pep8` pytest plugin to check PEP8 requirements Apr 27, 2014 N/A N/A + :pypi:`pytest-percent` Change the exit code of pytest test sessions when a required percent of tests pass. May 21, 2020 N/A pytest (>=5.2.0) + :pypi:`pytest-percents` Mar 16, 2024 N/A N/A + :pypi:`pytest-perf` Run performance tests against the mainline code. May 20, 2024 5 - Production/Stable pytest!=8.1.*,>=6; extra == "testing" + :pypi:`pytest-performance` A simple plugin to ensure the execution of critical sections of code has not been impacted Sep 11, 2020 5 - Production/Stable pytest (>=3.7.0) + :pypi:`pytest-performancetotal` A performance plugin for pytest Feb 01, 2025 5 - Production/Stable N/A + :pypi:`pytest-persistence` Pytest tool for persistent objects Aug 21, 2024 N/A N/A + :pypi:`pytest-pexpect` Pytest pexpect plugin. Aug 13, 2024 4 - Beta pytest>=6.2.0 + :pypi:`pytest-pg` A tiny plugin for pytest which runs PostgreSQL in Docker May 18, 2025 5 - Production/Stable pytest>=7.4 + :pypi:`pytest-pgsql` Pytest plugins and helpers for tests using a Postgres database. May 13, 2020 5 - Production/Stable pytest (>=3.0.0) + :pypi:`pytest-phmdoctest` pytest plugin to test Python examples in Markdown using phmdoctest. Apr 15, 2022 4 - Beta pytest (>=5.4.3) + :pypi:`pytest-phoenix-interface` Pytest extension tool for phoenix projects. Mar 19, 2025 N/A N/A + :pypi:`pytest-picked` Run the tests related to the changed files Nov 06, 2024 N/A pytest>=3.7.0 + :pypi:`pytest-pickle-cache` A pytest plugin for caching test results using pickle. Feb 17, 2025 N/A pytest>=7 + :pypi:`pytest-pigeonhole` Jun 25, 2018 5 - Production/Stable pytest (>=3.4) + :pypi:`pytest-pikachu` Show surprise when tests are passing Aug 05, 2021 5 - Production/Stable pytest + :pypi:`pytest-pilot` Slice in your test base thanks to powerful markers. Oct 09, 2020 5 - Production/Stable N/A + :pypi:`pytest-pingguo-pytest-plugin` pingguo test Oct 26, 2022 4 - Beta N/A + :pypi:`pytest-pings` 🦊 The pytest plugin for Firefox Telemetry 📊 Jun 29, 2019 3 - Alpha pytest (>=5.0.0) + :pypi:`pytest-pinned` A simple pytest plugin for pinning tests Sep 17, 2021 4 - Beta pytest (>=3.5.0) + :pypi:`pytest-pinpoint` A pytest plugin which runs SBFL algorithms to detect faults. Sep 25, 2020 N/A pytest (>=4.4.0) + :pypi:`pytest-pipeline` Pytest plugin for functional testing of data analysispipelines Jan 24, 2017 3 - Alpha N/A + :pypi:`pytest-pitch` runs tests in an order such that coverage increases as fast as possible Nov 02, 2023 4 - Beta pytest >=7.3.1 + :pypi:`pytest-platform-adapter` Pytest集成自动化平台插件 Feb 18, 2025 5 - Production/Stable pytest>=6.2.5 + :pypi:`pytest-platform-markers` Markers for pytest to skip tests on specific platforms Sep 09, 2019 4 - Beta pytest (>=3.6.0) + :pypi:`pytest-play` pytest plugin that let you automate actions and assertions with test metrics reporting executing plain YAML files Jun 12, 2019 5 - Production/Stable N/A + :pypi:`pytest-playbook` Pytest plugin for reading playbooks. Jan 21, 2021 3 - Alpha pytest (>=6.1.2,<7.0.0) + :pypi:`pytest-playwright` A pytest wrapper with fixtures for Playwright to automate web browsers Jan 31, 2025 N/A pytest<9.0.0,>=6.2.4 + :pypi:`pytest_playwright_async` ASYNC Pytest plugin for Playwright Sep 28, 2024 N/A N/A + :pypi:`pytest-playwright-asyncio` A pytest wrapper with async fixtures for Playwright to automate web browsers Jan 31, 2025 N/A pytest<9.0.0,>=6.2.4 + :pypi:`pytest-playwright-axe` An axe-core integration for accessibility testing using Playwright Python. Mar 27, 2025 4 - Beta N/A + :pypi:`pytest-playwright-enhanced` A pytest plugin for playwright python Mar 24, 2024 N/A pytest<9.0.0,>=8.0.0 + :pypi:`pytest-playwrights` A pytest wrapper with fixtures for Playwright to automate web browsers Dec 02, 2021 N/A N/A + :pypi:`pytest-playwright-snapshot` A pytest wrapper for snapshot testing with playwright Aug 19, 2021 N/A N/A + :pypi:`pytest-playwright-visual` A pytest fixture for visual testing with Playwright Apr 28, 2022 N/A N/A + :pypi:`pytest-playwright-visual-snapshot` Easy pytest visual regression testing using playwright Apr 15, 2025 N/A N/A + :pypi:`pytest-plone` Pytest plugin to test Plone addons Mar 27, 2025 3 - Alpha pytest<8.0.0 + :pypi:`pytest-plt` Fixtures for quickly making Matplotlib plots in tests Jan 17, 2024 5 - Production/Stable pytest + :pypi:`pytest-plugin-helpers` A plugin to help developing and testing other plugins Nov 23, 2019 4 - Beta pytest (>=3.5.0) + :pypi:`pytest-plus` PyTest Plus Plugin :: extends pytest functionality Feb 02, 2025 5 - Production/Stable pytest>=7.4.2 + :pypi:`pytest-pmisc` Mar 21, 2019 5 - Production/Stable N/A + :pypi:`pytest-pogo` Pytest plugin for pogo-migrate May 05, 2025 4 - Beta pytest<9,>=7 + :pypi:`pytest-pointers` Pytest plugin to define functions you test with special marks for better navigation and reports Dec 26, 2022 N/A N/A + :pypi:`pytest-pokie` Pokie plugin for pytest Oct 19, 2023 5 - Production/Stable N/A + :pypi:`pytest-polarion-cfme` pytest plugin for collecting test cases and recording test results Nov 13, 2017 3 - Alpha N/A + :pypi:`pytest-polarion-collect` pytest plugin for collecting polarion test cases data Jun 18, 2020 3 - Alpha pytest + :pypi:`pytest-polecat` Provides Polecat pytest fixtures Aug 12, 2019 4 - Beta N/A + :pypi:`pytest-ponyorm` PonyORM in Pytest Oct 31, 2018 N/A pytest (>=3.1.1) + :pypi:`pytest-poo` Visualize your crappy tests Mar 25, 2021 5 - Production/Stable pytest (>=2.3.4) + :pypi:`pytest-poo-fail` Visualize your failed tests with poo Feb 12, 2015 5 - Production/Stable N/A + :pypi:`pytest-pook` Pytest plugin for pook Feb 15, 2024 4 - Beta pytest + :pypi:`pytest-pop` A pytest plugin to help with testing pop projects May 09, 2023 5 - Production/Stable pytest + :pypi:`pytest-porcochu` Show surprise when tests are passing Nov 28, 2024 5 - Production/Stable N/A + :pypi:`pytest-portion` Select a portion of the collected tests Jan 28, 2021 4 - Beta pytest (>=3.5.0) + :pypi:`pytest-postgres` Run PostgreSQL in Docker container in Pytest. Mar 22, 2020 N/A pytest + :pypi:`pytest-postgresql` Postgresql fixtures and fixture factories for Pytest. May 17, 2025 5 - Production/Stable pytest>=7.2 + :pypi:`pytest-power` pytest plugin with powerful fixtures Dec 31, 2020 N/A pytest (>=5.4) + :pypi:`pytest-powerpack` A plugin containing extra batteries for pytest Jan 04, 2025 N/A pytest<9.0.0,>=8.1.1 + :pypi:`pytest-prefer-nested-dup-tests` A Pytest plugin to drop duplicated tests during collection, but will prefer keeping nested packages. Apr 27, 2022 4 - Beta pytest (>=7.1.1,<8.0.0) + :pypi:`pytest-pretty` pytest plugin for printing summary data as I want it Apr 05, 2023 5 - Production/Stable pytest>=7 + :pypi:`pytest-pretty-terminal` pytest plugin for generating prettier terminal output Jan 31, 2022 N/A pytest (>=3.4.1) + :pypi:`pytest-pride` Minitest-style test colors Apr 02, 2016 3 - Alpha N/A + :pypi:`pytest-print` pytest-print adds the printer fixture you can use to print messages to the user (directly to the pytest runner, not stdout) Feb 25, 2025 5 - Production/Stable pytest>=8.3.2 + :pypi:`pytest-priority` pytest plugin for add priority for tests Aug 19, 2024 N/A pytest + :pypi:`pytest-proceed` Oct 01, 2024 N/A pytest + :pypi:`pytest-profiles` pytest plugin for configuration profiles Dec 09, 2021 4 - Beta pytest (>=3.7.0) + :pypi:`pytest-profiling` Profiling plugin for py.test Nov 29, 2024 5 - Production/Stable pytest + :pypi:`pytest-progress` pytest plugin for instant test progress status Jun 18, 2024 5 - Production/Stable pytest>=2.7 + :pypi:`pytest-prometheus` Report test pass / failures to a Prometheus PushGateway Oct 03, 2017 N/A N/A + :pypi:`pytest-prometheus-pushgateway` Pytest report plugin for Zulip Sep 27, 2022 5 - Production/Stable pytest + :pypi:`pytest-prometheus-pushgw` Pytest plugin to export test metrics to Prometheus Pushgateway May 19, 2025 N/A pytest>=6.0.0 + :pypi:`pytest-prosper` Test helpers for Prosper projects Sep 24, 2018 N/A N/A + :pypi:`pytest-prysk` Pytest plugin for prysk Dec 10, 2024 4 - Beta pytest>=7.3.2 + :pypi:`pytest-pspec` A rspec format reporter for Python ptest Jun 02, 2020 4 - Beta pytest (>=3.0.0) + :pypi:`pytest-psqlgraph` pytest plugin for testing applications that use psqlgraph Oct 19, 2021 4 - Beta pytest (>=6.0) + :pypi:`pytest-pt` pytest plugin to use \*.pt files as tests Sep 22, 2024 5 - Production/Stable pytest + :pypi:`pytest-ptera` Use ptera probes in tests Mar 01, 2022 N/A pytest (>=6.2.4,<7.0.0) + :pypi:`pytest-publish` Jun 04, 2024 N/A pytest<9.0.0,>=8.0.0 + :pypi:`pytest-pudb` Pytest PuDB debugger integration Oct 25, 2018 3 - Alpha pytest (>=2.0) + :pypi:`pytest-pumpkin-spice` A pytest plugin that makes your test reporting pumpkin-spiced Sep 18, 2022 4 - Beta N/A + :pypi:`pytest-purkinje` py.test plugin for purkinje test runner Oct 28, 2017 2 - Pre-Alpha N/A + :pypi:`pytest-pusher` pytest plugin for push report to minio Jan 06, 2023 5 - Production/Stable pytest (>=3.6) + :pypi:`pytest-py125` Dec 03, 2022 N/A N/A + :pypi:`pytest-pycharm` Plugin for py.test to enter PyCharm debugger on uncaught exceptions Aug 13, 2020 5 - Production/Stable pytest (>=2.3) + :pypi:`pytest-pycodestyle` pytest plugin to run pycodestyle Oct 10, 2024 3 - Alpha pytest>=7.0 + :pypi:`pytest-pydantic-schema-sync` Pytest plugin to synchronise Pydantic model schemas with JSONSchema files Aug 29, 2024 N/A pytest>=6 + :pypi:`pytest-pydev` py.test plugin to connect to a remote debug server with PyDev or PyCharm. Nov 15, 2017 3 - Alpha N/A + :pypi:`pytest-pydocstyle` pytest plugin to run pydocstyle Oct 09, 2024 3 - Alpha pytest>=7.0 + :pypi:`pytest-pylint` pytest plugin to check source code with pylint Oct 06, 2023 5 - Production/Stable pytest >=7.0 + :pypi:`pytest-pylyzer` A pytest plugin for pylyzer Feb 15, 2025 4 - Beta N/A + :pypi:`pytest-pymysql-autorecord` Record PyMySQL queries and mock with the stored data. Sep 02, 2022 N/A N/A + :pypi:`pytest-pyodide` Pytest plugin for testing applications that use Pyodide Nov 23, 2024 N/A pytest + :pypi:`pytest-pypi` Easily test your HTTP library against a local copy of pypi Mar 04, 2018 3 - Alpha N/A + :pypi:`pytest-pypom-navigation` Core engine for cookiecutter-qa and pytest-play packages Feb 18, 2019 4 - Beta pytest (>=3.0.7) + :pypi:`pytest-pyppeteer` A plugin to run pyppeteer in pytest Apr 28, 2022 N/A pytest (>=6.2.5,<7.0.0) + :pypi:`pytest-pyq` Pytest fixture "q" for pyq Mar 10, 2020 5 - Production/Stable N/A + :pypi:`pytest-pyramid` pytest_pyramid - provides fixtures for testing pyramid applications with pytest test suite Oct 24, 2024 5 - Production/Stable pytest + :pypi:`pytest-pyramid-server` Pyramid server fixture for py.test Oct 17, 2024 5 - Production/Stable pytest + :pypi:`pytest-pyreport` PyReport is a lightweight reporting plugin for Pytest that provides concise HTML report May 05, 2024 N/A pytest + :pypi:`pytest-pyright` Pytest plugin for type checking code with Pyright Jan 26, 2024 4 - Beta pytest >=7.0.0 + :pypi:`pytest-pyspec` A plugin that transforms the pytest output into a result similar to the RSpec. It enables the use of docstrings to display results and also enables the use of the prefixes "describe", "with" and "it". Aug 17, 2024 N/A pytest<9.0.0,>=8.3.2 + :pypi:`pytest-pystack` Plugin to run pystack after a timeout for a test suite. Nov 16, 2024 N/A pytest>=3.5.0 + :pypi:`pytest-pytestrail` Pytest plugin for interaction with TestRail Aug 27, 2020 4 - Beta pytest (>=3.8.0) + :pypi:`pytest-pythonhashseed` Pytest plugin to set PYTHONHASHSEED env var. Feb 25, 2024 4 - Beta pytest>=3.0.0 + :pypi:`pytest-pythonpath` pytest plugin for adding to the PYTHONPATH from command line or configs. Feb 10, 2022 5 - Production/Stable pytest (<7,>=2.5.2) + :pypi:`pytest-python-test-engineer-sort` Sort plugin for Pytest May 13, 2024 N/A pytest>=6.2.0 + :pypi:`pytest-pytorch` pytest plugin for a better developer experience when working with the PyTorch test suite May 25, 2021 4 - Beta pytest + :pypi:`pytest-pyvenv` A package for create venv in tests Feb 27, 2024 N/A pytest ; extra == 'test' + :pypi:`pytest-pyvista` Pytest-pyvista package Sep 29, 2023 4 - Beta pytest>=3.5.0 + :pypi:`pytest-qanova` A pytest plugin to collect test information Sep 05, 2024 3 - Alpha pytest + :pypi:`pytest-qaseio` Pytest plugin for Qase.io integration Mar 18, 2025 5 - Production/Stable pytest<9.0.0,>=7.2.2 + :pypi:`pytest-qasync` Pytest support for qasync. Jul 12, 2021 4 - Beta pytest (>=5.4.0) + :pypi:`pytest-qatouch` Pytest plugin for uploading test results to your QA Touch Testrun. Feb 14, 2023 4 - Beta pytest (>=6.2.0) + :pypi:`pytest-qgis` A pytest plugin for testing QGIS python plugins Jun 14, 2024 5 - Production/Stable pytest>=6.0 + :pypi:`pytest-qml` Run QML Tests with pytest Dec 02, 2020 4 - Beta pytest (>=6.0.0) + :pypi:`pytest-qr` pytest plugin to generate test result QR codes Nov 25, 2021 4 - Beta N/A + :pypi:`pytest-qt` pytest support for PyQt and PySide applications Feb 07, 2024 5 - Production/Stable pytest + :pypi:`pytest-qt-app` QT app fixture for py.test Oct 17, 2024 5 - Production/Stable pytest + :pypi:`pytest-quarantine` A plugin for pytest to manage expected test failures Nov 24, 2019 5 - Production/Stable pytest (>=4.6) + :pypi:`pytest-quickcheck` pytest plugin to generate random data inspired by QuickCheck Nov 05, 2022 4 - Beta pytest (>=4.0) + :pypi:`pytest_quickify` Run test suites with pytest-quickify. Jun 14, 2019 N/A pytest + :pypi:`pytest-rabbitmq` RabbitMQ process and client fixtures for pytest Oct 15, 2024 5 - Production/Stable pytest>=6.2 + :pypi:`pytest-race` Race conditions tester for pytest Jun 07, 2022 4 - Beta N/A + :pypi:`pytest-rage` pytest plugin to implement PEP712 Oct 21, 2011 3 - Alpha N/A + :pypi:`pytest-rail` pytest plugin for creating TestRail runs and adding results May 02, 2022 N/A pytest (>=3.6) + :pypi:`pytest-railflow-testrail-reporter` Generate json reports along with specified metadata defined in test markers. Jun 29, 2022 5 - Production/Stable pytest + :pypi:`pytest-raises` An implementation of pytest.raises as a pytest.mark fixture Apr 23, 2020 N/A pytest (>=3.2.2) + :pypi:`pytest-raisesregexp` Simple pytest plugin to look for regex in Exceptions Dec 18, 2015 N/A N/A + :pypi:`pytest-raisin` Plugin enabling the use of exception instances with pytest.raises Feb 06, 2022 N/A pytest + :pypi:`pytest-random` py.test plugin to randomize tests Apr 28, 2013 3 - Alpha N/A + :pypi:`pytest-randomly` Pytest plugin to randomly order tests and control random.seed. Oct 25, 2024 5 - Production/Stable pytest + :pypi:`pytest-randomness` Pytest plugin about random seed management May 30, 2019 3 - Alpha N/A + :pypi:`pytest-random-num` Randomise the order in which pytest tests are run with some control over the randomness Oct 19, 2020 5 - Production/Stable N/A + :pypi:`pytest-random-order` Randomise the order in which pytest tests are run with some control over the randomness Jan 20, 2024 5 - Production/Stable pytest >=3.0.0 + :pypi:`pytest-ranking` A Pytest plugin for faster fault detection via regression test prioritization Apr 08, 2025 4 - Beta pytest>=7.4.3 + :pypi:`pytest-readme` Test your README.md file Sep 02, 2022 5 - Production/Stable N/A + :pypi:`pytest-reana` Pytest fixtures for REANA. Sep 04, 2024 3 - Alpha N/A + :pypi:`pytest-recorder` Pytest plugin, meant to facilitate unit tests writing for tools consumming Web APIs. Mar 31, 2025 N/A N/A + :pypi:`pytest-recording` A pytest plugin powered by VCR.py to record and replay HTTP traffic May 08, 2025 4 - Beta pytest>=3.5.0 + :pypi:`pytest-recordings` Provides pytest plugins for reporting request/response traffic, screenshots, and more to ReportPortal Aug 13, 2020 N/A N/A + :pypi:`pytest-record-video` 用例执行过程中录制视频 Oct 31, 2024 N/A N/A + :pypi:`pytest-redis` Redis fixtures and fixture factories for Pytest. Nov 27, 2024 5 - Production/Stable pytest>=6.2 + :pypi:`pytest-redislite` Pytest plugin for testing code using Redis Apr 05, 2022 4 - Beta pytest + :pypi:`pytest-redmine` Pytest plugin for redmine Mar 19, 2018 1 - Planning N/A + :pypi:`pytest-ref` A plugin to store reference files to ease regression testing Nov 23, 2019 4 - Beta pytest (>=3.5.0) + :pypi:`pytest-reference-formatter` Conveniently run pytest with a dot-formatted test reference. Oct 01, 2019 4 - Beta N/A + :pypi:`pytest-regex` Select pytest tests with regular expressions May 29, 2023 4 - Beta pytest (>=3.5.0) + :pypi:`pytest-regex-dependency` Management of Pytest dependencies via regex patterns Jun 12, 2022 N/A pytest + :pypi:`pytest-regressions` Easy to use fixtures to write regression tests. May 30, 2025 5 - Production/Stable pytest>=6.2.0 + :pypi:`pytest-regtest` pytest plugin for snapshot regression testing Nov 12, 2024 N/A pytest>7.2 + :pypi:`pytest-relative-order` a pytest plugin that sorts tests using "before" and "after" markers May 17, 2021 4 - Beta N/A + :pypi:`pytest-relative-path` Handle relative path in pytest options or ini configs Aug 30, 2024 N/A pytest + :pypi:`pytest-relaxed` Relaxed test discovery/organization for pytest Mar 29, 2024 5 - Production/Stable pytest>=7 + :pypi:`pytest-remfiles` Pytest plugin to create a temporary directory with remote files Jul 01, 2019 5 - Production/Stable N/A + :pypi:`pytest-remotedata` Pytest plugin for controlling remote data access. Sep 26, 2023 5 - Production/Stable pytest >=4.6 + :pypi:`pytest-remote-response` Pytest plugin for capturing and mocking connection requests. Apr 26, 2023 5 - Production/Stable pytest (>=4.6) + :pypi:`pytest-remove-stale-bytecode` py.test plugin to remove stale byte code files. Jul 07, 2023 4 - Beta pytest + :pypi:`pytest-reorder` Reorder tests depending on their paths and names. May 31, 2018 4 - Beta pytest + :pypi:`pytest-repeat` pytest plugin for repeating tests Apr 07, 2025 5 - Production/Stable pytest + :pypi:`pytest_repeater` py.test plugin for repeating single test multiple times. Feb 09, 2018 1 - Planning N/A + :pypi:`pytest-replay` Saves previous test runs and allow re-execute previous pytest runs to reproduce crashes or flaky tests Feb 05, 2025 5 - Production/Stable pytest + :pypi:`pytest-repo-health` A pytest plugin to report on repository standards conformance May 05, 2025 3 - Alpha pytest + :pypi:`pytest-report` Creates json report that is compatible with atom.io's linter message format May 11, 2016 4 - Beta N/A + :pypi:`pytest-reporter` Generate Pytest reports with templates Feb 28, 2024 4 - Beta pytest + :pypi:`pytest-reporter-html1` A basic HTML report template for Pytest May 06, 2025 4 - Beta N/A + :pypi:`pytest-reporter-html-dots` A basic HTML report for pytest using Jinja2 template engine. Apr 26, 2025 N/A N/A + :pypi:`pytest-reporter-plus` Lightweight enhanced HTML reporter for Pytest May 31, 2025 N/A N/A + :pypi:`pytest-report-extras` Pytest plugin to enhance pytest-html and allure reports by adding comments, screenshots, webpage sources and attachments. Apr 04, 2025 N/A pytest>=8.0.0 + :pypi:`pytest-reportinfra` Pytest plugin for reportinfra Aug 11, 2019 3 - Alpha N/A + :pypi:`pytest-reporting` A plugin to report summarized results in a table format Oct 25, 2019 4 - Beta pytest (>=3.5.0) + :pypi:`pytest-reportlog` Replacement for the --resultlog option, focused in simplicity and extensibility May 22, 2023 3 - Alpha pytest + :pypi:`pytest-report-me` A pytest plugin to generate report. Dec 31, 2020 N/A pytest + :pypi:`pytest-report-parameters` pytest plugin for adding tests' parameters to junit report Jun 18, 2020 3 - Alpha pytest (>=2.4.2) + :pypi:`pytest-reportportal` Agent for Reporting results of tests to the Report Portal Feb 28, 2025 N/A pytest>=4.6.10 + :pypi:`pytest-report-stream` A pytest plugin which allows to stream test reports at runtime Oct 22, 2023 4 - Beta N/A + :pypi:`pytest-repo-structure` Pytest Repo Structure Mar 18, 2024 1 - Planning N/A + :pypi:`pytest-req` pytest requests plugin Aug 31, 2024 5 - Production/Stable pytest<9.0.0,>=8.3.2 + :pypi:`pytest-reqs` pytest plugin to check pinned requirements May 12, 2019 N/A pytest (>=2.4.2) + :pypi:`pytest-requests` A simple plugin to use with pytest Jun 24, 2019 4 - Beta pytest (>=3.5.0) + :pypi:`pytest-requestselapsed` collect and show http requests elapsed time Aug 14, 2022 N/A N/A + :pypi:`pytest-requests-futures` Pytest Plugin to Mock Requests Futures Jul 06, 2022 5 - Production/Stable pytest + :pypi:`pytest-requirements` pytest plugin for using custom markers to relate tests to requirements and usecases Feb 28, 2025 N/A pytest + :pypi:`pytest-requires` A pytest plugin to elegantly skip tests with optional requirements Dec 21, 2021 4 - Beta pytest (>=3.5.0) + :pypi:`pytest-reraise` Make multi-threaded pytest test cases fail when they should Sep 20, 2022 5 - Production/Stable pytest (>=4.6) + :pypi:`pytest-rerun` Re-run only changed files in specified branch Jul 08, 2019 N/A pytest (>=3.6) + :pypi:`pytest-rerun-all` Rerun testsuite for a certain time or iterations Nov 16, 2023 3 - Alpha pytest (>=7.0.0) + :pypi:`pytest-rerunclassfailures` pytest rerun class failures plugin Apr 24, 2024 5 - Production/Stable pytest>=7.2 + :pypi:`pytest-rerunfailures` pytest plugin to re-run tests to eliminate flaky failures May 08, 2025 5 - Production/Stable pytest!=8.2.2,>=7.4 + :pypi:`pytest-rerunfailures-all-logs` pytest plugin to re-run tests to eliminate flaky failures Mar 07, 2022 5 - Production/Stable N/A + :pypi:`pytest-reserial` Pytest fixture for recording and replaying serial port traffic. Dec 22, 2024 4 - Beta pytest + :pypi:`pytest-resilient-circuits` Resilient Circuits fixtures for PyTest Feb 28, 2025 N/A pytest~=7.0 + :pypi:`pytest-resource` Load resource fixture plugin to use with pytest Nov 14, 2018 4 - Beta N/A + :pypi:`pytest-resource-path` Provides path for uniform access to test resources in isolated directory May 15, 2025 5 - Production/Stable pytest>=3.5.0 + :pypi:`pytest-resource-usage` Pytest plugin for reporting running time and peak memory usage Nov 06, 2022 5 - Production/Stable pytest>=7.0.0 + :pypi:`pytest-responsemock` Simplified requests calls mocking for pytest Mar 10, 2022 5 - Production/Stable N/A + :pypi:`pytest-responses` py.test integration for responses Oct 11, 2022 N/A pytest (>=2.5) + :pypi:`pytest-rest-api` Aug 08, 2022 N/A pytest (>=7.1.2,<8.0.0) + :pypi:`pytest-restrict` Pytest plugin to restrict the test types allowed Oct 24, 2024 5 - Production/Stable pytest + :pypi:`pytest-result-log` A pytest plugin that records the start, end, and result information of each use case in a log file Jan 10, 2024 N/A pytest>=7.2.0 + :pypi:`pytest-result-notify` Default template for PDM package Apr 27, 2025 N/A pytest>=8.3.5 + :pypi:`pytest-results` Easily spot regressions in your tests. May 06, 2025 4 - Beta pytest + :pypi:`pytest-result-sender` Apr 20, 2023 N/A pytest>=7.3.1 + :pypi:`pytest-result-sender-jms` Default template for PDM package May 22, 2025 N/A pytest>=8.3.5 + :pypi:`pytest-result-sender-lj` Default template for PDM package Dec 17, 2024 N/A pytest>=8.3.4 + :pypi:`pytest-result-sender-lyt` Default template for PDM package Mar 14, 2025 N/A pytest>=8.3.5 + :pypi:`pytest-result-sender-misszhang` Default template for PDM package Mar 21, 2025 N/A pytest>=8.3.5 + :pypi:`pytest-resume` A Pytest plugin to resuming from the last run test Apr 22, 2023 4 - Beta pytest (>=7.0) + :pypi:`pytest-rethinkdb` A RethinkDB plugin for pytest. Jul 24, 2016 4 - Beta N/A + :pypi:`pytest-retry` Adds the ability to retry flaky tests in CI environments Jan 19, 2025 N/A pytest>=7.0.0 + :pypi:`pytest-retry-class` A pytest plugin to rerun entire class on failure Nov 24, 2024 N/A pytest>=5.3 + :pypi:`pytest-reusable-testcases` Apr 28, 2023 N/A N/A + :pypi:`pytest-revealtype-injector` Pytest plugin for replacing reveal_type() calls inside test functions with static and runtime type checking result comparison, for confirming type annotation validity. Mar 18, 2025 4 - Beta pytest<9,>=7.0 + :pypi:`pytest-reverse` Pytest plugin to reverse test order. Oct 25, 2024 5 - Production/Stable pytest + :pypi:`pytest-rich` Leverage rich for richer test session output Dec 12, 2024 4 - Beta pytest>=7.0 + :pypi:`pytest-richer` Pytest plugin providing a Rich based reporter. Oct 27, 2023 3 - Alpha pytest + :pypi:`pytest-rich-reporter` A pytest plugin using Rich for beautiful test result formatting. Feb 17, 2022 1 - Planning pytest (>=5.0.0) + :pypi:`pytest-richtrace` A pytest plugin that displays the names and information of the pytest hook functions as they are executed. Jun 20, 2023 N/A N/A + :pypi:`pytest-ringo` pytest plugin to test webapplications using the Ringo webframework Sep 27, 2017 3 - Alpha N/A + :pypi:`pytest-rmsis` Sycronise pytest results to Jira RMsis Aug 10, 2022 N/A pytest (>=5.3.5) + :pypi:`pytest-rng` Fixtures for seeding tests and making randomness reproducible Aug 08, 2019 5 - Production/Stable pytest + :pypi:`pytest-roast` pytest plugin for ROAST configuration override and fixtures Nov 09, 2022 5 - Production/Stable pytest + :pypi:`pytest_robotframework` a pytest plugin that can run both python and robotframework tests while generating robot reports for them Apr 13, 2025 N/A pytest<9,>=7 + :pypi:`pytest-rocketchat` Pytest to Rocket.Chat reporting plugin Apr 18, 2021 5 - Production/Stable N/A + :pypi:`pytest-rotest` Pytest integration with rotest Sep 08, 2019 N/A pytest (>=3.5.0) + :pypi:`pytest-rpc` Extend py.test for RPC OpenStack testing. Feb 22, 2019 4 - Beta pytest (~=3.6) + :pypi:`pytest-rst` Test code from RST documents with pytest Jan 26, 2023 N/A N/A + :pypi:`pytest-rt` pytest data collector plugin for Testgr May 05, 2022 N/A N/A + :pypi:`pytest-rts` Coverage-based regression test selection (RTS) plugin for pytest May 17, 2021 N/A pytest + :pypi:`pytest-ruff` pytest plugin to check ruff requirements. Jul 21, 2024 4 - Beta pytest>=5 + :pypi:`pytest-run-changed` Pytest plugin that runs changed tests only Apr 02, 2021 3 - Alpha pytest + :pypi:`pytest-runfailed` implement a --failed option for pytest Mar 24, 2016 N/A N/A + :pypi:`pytest-run-parallel` A simple pytest plugin to run tests concurrently May 27, 2025 4 - Beta pytest>=6.2.0 + :pypi:`pytest-run-subprocess` Pytest Plugin for running and testing subprocesses. Nov 12, 2022 5 - Production/Stable pytest + :pypi:`pytest-runtime-types` Checks type annotations on runtime while running tests. Feb 09, 2023 N/A pytest + :pypi:`pytest-runtime-xfail` Call runtime_xfail() to mark running test as xfail. Aug 26, 2021 N/A pytest>=5.0.0 + :pypi:`pytest-runtime-yoyo` run case mark timeout Jun 12, 2023 N/A pytest (>=7.2.0) + :pypi:`pytest-saccharin` pytest-saccharin is a updated fork of pytest-sugar, a plugin for pytest that changes the default look and feel of pytest (e.g. progressbar, show tests that fail instantly). Oct 31, 2022 3 - Alpha N/A + :pypi:`pytest-salt` Pytest Salt Plugin Jan 27, 2020 4 - Beta N/A + :pypi:`pytest-salt-containers` A Pytest plugin that builds and creates docker containers Nov 09, 2016 4 - Beta N/A + :pypi:`pytest-salt-factories` Pytest Salt Plugin Oct 22, 2024 5 - Production/Stable pytest>=7.4.0 + :pypi:`pytest-salt-from-filenames` Simple PyTest Plugin For Salt's Test Suite Specifically Jan 29, 2019 4 - Beta pytest (>=4.1) + :pypi:`pytest-salt-runtests-bridge` Simple PyTest Plugin For Salt's Test Suite Specifically Dec 05, 2019 4 - Beta pytest (>=4.1) + :pypi:`pytest-sample-argvalues` A utility function to help choose a random sample from your argvalues in pytest. May 07, 2024 N/A pytest + :pypi:`pytest-sanic` a pytest plugin for Sanic Oct 25, 2021 N/A pytest (>=5.2) + :pypi:`pytest-sanitizer` A pytest plugin to sanitize output for LLMs (personal tool, no warranty or liability) Mar 16, 2025 3 - Alpha pytest>=6.0.0 + :pypi:`pytest-sanity` Dec 07, 2020 N/A N/A + :pypi:`pytest-sa-pg` May 14, 2019 N/A N/A + :pypi:`pytest_sauce` pytest_sauce provides sane and helpful methods worked out in clearcode to run py.test tests with selenium/saucelabs Jul 14, 2014 3 - Alpha N/A + :pypi:`pytest-sbase` A complete web automation framework for end-to-end testing. May 27, 2025 5 - Production/Stable N/A + :pypi:`pytest-scenario` pytest plugin for test scenarios Feb 06, 2017 3 - Alpha N/A + :pypi:`pytest-scenario-files` A pytest plugin that generates unit test scenarios from data files. May 21, 2025 5 - Production/Stable pytest>=7.0 + :pypi:`pytest-schedule` Automate and customize test scheduling effortlessly on local machines. Oct 31, 2024 N/A N/A + :pypi:`pytest-schema` 👍 Validate return values against a schema-like object in testing Feb 16, 2024 5 - Production/Stable pytest >=3.5.0 + :pypi:`pytest-scim2-server` SCIM2 server fixture for Pytest May 14, 2025 4 - Beta pytest>=8.3.4 + :pypi:`pytest-screenshot-on-failure` Saves a screenshot when a test case from a pytest execution fails Jul 21, 2023 4 - Beta N/A + :pypi:`pytest-scrutinize` Scrutinize your pytest test suites for slow fixtures, tests and more. Aug 19, 2024 4 - Beta pytest>=6 + :pypi:`pytest-securestore` An encrypted password store for use within pytest cases Nov 08, 2021 4 - Beta N/A + :pypi:`pytest-select` A pytest plugin which allows to (de-)select tests from a file. Jan 18, 2019 3 - Alpha pytest (>=3.0) + :pypi:`pytest-selenium` pytest plugin for Selenium Feb 01, 2024 5 - Production/Stable pytest>=6.0.0 + :pypi:`pytest-selenium-auto` pytest plugin to automatically capture screenshots upon selenium webdriver events Nov 07, 2023 N/A pytest >= 7.0.0 + :pypi:`pytest-seleniumbase` A complete web automation framework for end-to-end testing. May 27, 2025 5 - Production/Stable N/A + :pypi:`pytest-selenium-enhancer` pytest plugin for Selenium Apr 29, 2022 5 - Production/Stable N/A + :pypi:`pytest-selenium-pdiff` A pytest package implementing perceptualdiff for Selenium tests. Apr 06, 2017 2 - Pre-Alpha N/A + :pypi:`pytest-selfie` A pytest plugin for selfie snapshot testing. Dec 16, 2024 N/A pytest>=8.0.0 + :pypi:`pytest-send-email` Send pytest execution result email Sep 02, 2024 N/A pytest + :pypi:`pytest-sentry` A pytest plugin to send testrun information to Sentry.io May 23, 2025 N/A pytest + :pypi:`pytest-sequence-markers` Pytest plugin for sequencing markers for execution of tests May 23, 2023 5 - Production/Stable N/A + :pypi:`pytest-server` test server exec cmd Sep 09, 2024 N/A N/A + :pypi:`pytest-server-fixtures` Extensible server fixtures for py.test Nov 29, 2024 5 - Production/Stable pytest + :pypi:`pytest-serverless` Automatically mocks resources from serverless.yml in pytest using moto. May 09, 2022 4 - Beta N/A + :pypi:`pytest-servers` pytest servers Mar 12, 2025 3 - Alpha pytest>=6.2 + :pypi:`pytest-service` Aug 06, 2024 5 - Production/Stable pytest>=6.0.0 + :pypi:`pytest-services` Services plugin for pytest testing framework Oct 30, 2020 6 - Mature N/A + :pypi:`pytest-session2file` pytest-session2file (aka: pytest-session_to_file for v0.1.0 - v0.1.2) is a py.test plugin for capturing and saving to file the stdout of py.test. Jan 26, 2021 3 - Alpha pytest + :pypi:`pytest-session-fixture-globalize` py.test plugin to make session fixtures behave as if written in conftest, even if it is written in some modules May 15, 2018 4 - Beta N/A + :pypi:`pytest-session_to_file` pytest-session_to_file is a py.test plugin for capturing and saving to file the stdout of py.test. Oct 01, 2015 3 - Alpha N/A + :pypi:`pytest-setupinfo` Displaying setup info during pytest command run Jan 23, 2023 N/A N/A + :pypi:`pytest-sftpserver` py.test plugin to locally test sftp server connections. Sep 16, 2019 4 - Beta N/A + :pypi:`pytest-shard` Dec 11, 2020 4 - Beta pytest + :pypi:`pytest-shard-fork` Shard tests to support parallelism across multiple machines May 17, 2025 4 - Beta pytest + :pypi:`pytest-shared-session-scope` Pytest session-scoped fixture that works with xdist Sep 22, 2024 N/A pytest>=7.0.0 + :pypi:`pytest-share-hdf` Plugin to save test data in HDF files and retrieve them for comparison Sep 21, 2022 4 - Beta pytest (>=3.5.0) + :pypi:`pytest-sharkreport` this is pytest report plugin. Jul 11, 2022 N/A pytest (>=3.5) + :pypi:`pytest-shell` A pytest plugin to help with testing shell scripts / black box commands Mar 27, 2022 N/A N/A + :pypi:`pytest-shell-utilities` Pytest plugin to simplify running shell commands against the system Oct 22, 2024 5 - Production/Stable pytest>=7.4.0 + :pypi:`pytest-sheraf` Versatile ZODB abstraction layer - pytest fixtures Feb 11, 2020 N/A pytest + :pypi:`pytest-sherlock` pytest plugin help to find coupled tests Aug 14, 2023 5 - Production/Stable pytest >=3.5.1 + :pypi:`pytest-shortcuts` Expand command-line shortcuts listed in pytest configuration Oct 29, 2020 4 - Beta pytest (>=3.5.0) + :pypi:`pytest-shutil` A goodie-bag of unix shell and environment tools for py.test Nov 29, 2024 5 - Production/Stable pytest + :pypi:`pytest-simbind` Pytest plugin to operate with objects generated by Simbind tool. Mar 28, 2024 N/A pytest>=7.0.0 + :pypi:`pytest-simplehttpserver` Simple pytest fixture to spin up an HTTP server Jun 24, 2021 4 - Beta N/A + :pypi:`pytest-simple-plugin` Simple pytest plugin Nov 27, 2019 N/A N/A + :pypi:`pytest-simple-settings` simple-settings plugin for pytest Nov 17, 2020 4 - Beta pytest + :pypi:`pytest-single-file-logging` Allow for multiple processes to log to a single file May 05, 2016 4 - Beta pytest (>=2.8.1) + :pypi:`pytest-skip` A pytest plugin which allows to (de-)select or skip tests from a file. Apr 04, 2025 3 - Alpha pytest + :pypi:`pytest-skip-markers` Pytest Salt Plugin Aug 09, 2024 5 - Production/Stable pytest>=7.1.0 + :pypi:`pytest-skipper` A plugin that selects only tests with changes in execution path Mar 26, 2017 3 - Alpha pytest (>=3.0.6) + :pypi:`pytest-skippy` Automatically skip tests that don't need to run! Jan 27, 2018 3 - Alpha pytest (>=2.3.4) + :pypi:`pytest-skip-slow` A pytest plugin to skip \`@pytest.mark.slow\` tests by default. Feb 09, 2023 N/A pytest>=6.2.0 + :pypi:`pytest-skipuntil` A simple pytest plugin to skip flapping test with deadline Nov 25, 2023 4 - Beta pytest >=3.8.0 + :pypi:`pytest-slack` Pytest to Slack reporting plugin Dec 15, 2020 5 - Production/Stable N/A + :pypi:`pytest-slow` A pytest plugin to skip \`@pytest.mark.slow\` tests by default. Sep 28, 2021 N/A N/A + :pypi:`pytest-slowest-first` Sort tests by their last duration, slowest first Dec 11, 2022 4 - Beta N/A + :pypi:`pytest-slow-first` Prioritize running the slowest tests first. Jan 30, 2024 4 - Beta pytest >=3.5.0 + :pypi:`pytest-slow-last` Run tests in order of execution time (faster tests first) Mar 16, 2025 4 - Beta pytest>=3.5.0 + :pypi:`pytest-smartcollect` A plugin for collecting tests that touch changed code Oct 04, 2018 N/A pytest (>=3.5.0) + :pypi:`pytest-smartcov` Smart coverage plugin for pytest. Sep 30, 2017 3 - Alpha N/A + :pypi:`pytest-smell` Automated bad smell detection tool for Pytest Jun 26, 2022 N/A N/A + :pypi:`pytest-smoke` Pytest plugin for smoke testing May 23, 2025 4 - Beta pytest<9,>=7.0.0 + :pypi:`pytest-smtp` Send email with pytest execution result Feb 20, 2021 N/A pytest + :pypi:`pytest-smtp4dev` Plugin for smtp4dev API Jun 27, 2023 5 - Production/Stable N/A + :pypi:`pytest-smtpd` An SMTP server for testing built on aiosmtpd May 15, 2023 N/A pytest + :pypi:`pytest-smtp-test-server` pytest plugin for using \`smtp-test-server\` as a fixture Dec 03, 2023 2 - Pre-Alpha pytest (>=7.4.3,<8.0.0) + :pypi:`pytest-snail` Plugin for adding a marker to slow running tests. 🐌 Nov 04, 2019 3 - Alpha pytest (>=5.0.1) + :pypi:`pytest-snapci` py.test plugin for Snap-CI Nov 12, 2015 N/A N/A + :pypi:`pytest-snapmock` Snapshots for your mocks. Nov 15, 2024 N/A N/A + :pypi:`pytest-snapshot` A plugin for snapshot testing with pytest. Apr 23, 2022 4 - Beta pytest (>=3.0.0) + :pypi:`pytest-snapshot-with-message-generator` A plugin for snapshot testing with pytest. Jul 25, 2023 4 - Beta pytest (>=3.0.0) + :pypi:`pytest-snmpserver` May 12, 2021 N/A N/A + :pypi:`pytest-snob` A pytest plugin that only selects meaningful python tests to run. Jan 12, 2025 N/A pytest + :pypi:`pytest-snowflake-bdd` Setup test data and run tests on snowflake in BDD style! Jan 05, 2022 4 - Beta pytest (>=6.2.0) + :pypi:`pytest-socket` Pytest Plugin to disable socket calls during tests Jan 28, 2024 4 - Beta pytest (>=6.2.5) + :pypi:`pytest-sofaepione` Test the installation of SOFA and the SofaEpione plugin. Aug 17, 2022 N/A N/A + :pypi:`pytest-soft-assertions` May 05, 2020 3 - Alpha pytest + :pypi:`pytest-solidity` A PyTest library plugin for Solidity language. Jan 15, 2022 1 - Planning pytest (<7,>=6.0.1) ; extra == 'tests' + :pypi:`pytest-solr` Solr process and client fixtures for py.test. May 11, 2020 3 - Alpha pytest (>=3.0.0) + :pypi:`pytest-sort` Tools for sorting test cases Mar 22, 2025 N/A pytest>=7.4.0 + :pypi:`pytest-sorter` A simple plugin to first execute tests that historically failed more Apr 20, 2021 4 - Beta pytest (>=3.1.1) + :pypi:`pytest-sosu` Unofficial PyTest plugin for Sauce Labs Aug 04, 2023 2 - Pre-Alpha pytest + :pypi:`pytest-sourceorder` Test-ordering plugin for pytest Sep 01, 2021 4 - Beta pytest + :pypi:`pytest-spark` pytest plugin to run the tests with support of pyspark. May 21, 2025 4 - Beta pytest + :pypi:`pytest-spawner` py.test plugin to spawn process and communicate with them. Jul 31, 2015 4 - Beta N/A + :pypi:`pytest-spec` Library pytest-spec is a pytest plugin to display test execution output like a SPECIFICATION. Aug 04, 2024 N/A pytest; extra == "test" + :pypi:`pytest-spec2md` Library pytest-spec2md is a pytest plugin to create a markdown specification while running pytest. Apr 10, 2024 N/A pytest>7.0 + :pypi:`pytest-speed` Modern benchmarking library for python with pytest integration. Jan 22, 2023 3 - Alpha pytest>=7 + :pypi:`pytest-sphinx` Doctest plugin for pytest with support for Sphinx-specific doctest-directives Apr 13, 2024 4 - Beta pytest>=8.1.1 + :pypi:`pytest-spiratest` Exports unit tests as test runs in Spira (SpiraTest/Team/Plan) Jan 01, 2024 N/A N/A + :pypi:`pytest-splinter` Splinter plugin for pytest testing framework Sep 09, 2022 6 - Mature pytest (>=3.0.0) + :pypi:`pytest-splinter4` Pytest plugin for the splinter automation library Feb 01, 2024 6 - Mature pytest >=8.0.0 + :pypi:`pytest-split` Pytest plugin which splits the test suite to equally sized sub suites based on test execution time. Oct 16, 2024 4 - Beta pytest<9,>=5 + :pypi:`pytest-split-ext` Pytest plugin which splits the test suite to equally sized sub suites based on test execution time. Sep 23, 2023 4 - Beta pytest (>=5,<8) + :pypi:`pytest-splitio` Split.io SDK integration for e2e tests Sep 22, 2020 N/A pytest (<7,>=5.0) + :pypi:`pytest-split-tests` A Pytest plugin for running a subset of your tests by splitting them in to equally sized groups. Forked from Mark Adams' original project pytest-test-groups. Jul 30, 2021 5 - Production/Stable pytest (>=2.5) + :pypi:`pytest-split-tests-tresorit` Feb 22, 2021 1 - Planning N/A + :pypi:`pytest-splunk-addon` A Dynamic test tool for Splunk Apps and Add-ons May 14, 2025 N/A pytest<8,>5.4.0 + :pypi:`pytest-splunk-addon-ui-smartx` Library to support testing Splunk Add-on UX Mar 19, 2025 N/A N/A + :pypi:`pytest-splunk-env` pytest fixtures for interaction with Splunk Enterprise and Splunk Cloud Oct 22, 2020 N/A pytest (>=6.1.1,<7.0.0) + :pypi:`pytest-sqitch` sqitch for pytest Apr 06, 2020 4 - Beta N/A + :pypi:`pytest-sqlalchemy` pytest plugin with sqlalchemy related fixtures Apr 19, 2025 3 - Alpha pytest>=8.0 + :pypi:`pytest-sqlalchemy-mock` pytest sqlalchemy plugin for mock Aug 10, 2024 3 - Alpha pytest>=7.0.0 + :pypi:`pytest-sqlalchemy-session` A pytest plugin for preserving test isolation that use SQLAlchemy. May 19, 2023 4 - Beta pytest (>=7.0) + :pypi:`pytest-sql-bigquery` Yet another SQL-testing framework for BigQuery provided by pytest plugin Dec 19, 2019 N/A pytest + :pypi:`pytest-sqlfluff` A pytest plugin to use sqlfluff to enable format checking of sql files. Dec 21, 2022 4 - Beta pytest (>=3.5.0) + :pypi:`pytest-sqlguard` Pytest fixture to record and check SQL Queries made by SQLAlchemy Mar 11, 2025 4 - Beta pytest>=7 + :pypi:`pytest-squadcast` Pytest report plugin for Squadcast Feb 22, 2022 5 - Production/Stable pytest + :pypi:`pytest-srcpaths` Add paths to sys.path Oct 15, 2021 N/A pytest>=6.2.0 + :pypi:`pytest-ssh` pytest plugin for ssh command run May 27, 2019 N/A pytest + :pypi:`pytest-start-from` Start pytest run from a given point Apr 11, 2016 N/A N/A + :pypi:`pytest-static` pytest-static May 25, 2025 3 - Alpha pytest<8.0.0,>=7.4.3 + :pypi:`pytest-stats` Collects tests metadata for future analysis, easy to extend for any data store Jul 18, 2024 N/A pytest>=8.0.0 + :pypi:`pytest-statsd` pytest plugin for reporting to graphite Nov 30, 2018 5 - Production/Stable pytest (>=3.0.0) + :pypi:`pytest-status` Add status mark for tests Aug 22, 2024 N/A pytest + :pypi:`pytest-stepfunctions` A small description May 08, 2021 4 - Beta pytest + :pypi:`pytest-steps` Create step-wise / incremental tests in pytest. Sep 23, 2021 5 - Production/Stable N/A + :pypi:`pytest-stepwise` Run a test suite one failing test at a time. Dec 01, 2015 4 - Beta N/A + :pypi:`pytest-stf` pytest plugin for openSTF Sep 24, 2024 N/A pytest>=5.0 + :pypi:`pytest-stochastics` pytest plugin that allows selectively running tests several times and accepting \*some\* failures. Dec 01, 2024 N/A pytest<9.0.0,>=8.0.0 + :pypi:`pytest-stoq` A plugin to pytest stoq Feb 09, 2021 4 - Beta N/A + :pypi:`pytest-store` Pytest plugin to store values from test runs Sep 04, 2024 3 - Alpha pytest>=7.0.0 + :pypi:`pytest-streaming` Plugin for testing pubsub, pulsar, and kafka systems with pytest locally and in ci/cd May 28, 2025 5 - Production/Stable pytest>=8.3.5 + :pypi:`pytest-stress` A Pytest plugin that allows you to loop tests for a user defined amount of time. Dec 07, 2019 4 - Beta pytest (>=3.6.0) + :pypi:`pytest-structlog` Structured logging assertions Jul 25, 2024 N/A pytest + :pypi:`pytest-structmpd` provide structured temporary directory Oct 17, 2018 N/A N/A + :pypi:`pytest-stub` Stub packages, modules and attributes. Apr 28, 2020 5 - Production/Stable N/A + :pypi:`pytest-stubprocess` Provide stub implementations for subprocesses in Python tests Sep 17, 2018 3 - Alpha pytest (>=3.5.0) + :pypi:`pytest-study` A pytest plugin to organize long run tests (named studies) without interfering the regular tests Sep 26, 2017 3 - Alpha pytest (>=2.0) + :pypi:`pytest-subinterpreter` Run pytest in a subinterpreter Nov 25, 2023 N/A pytest>=7.0.0 + :pypi:`pytest-subprocess` A plugin to fake subprocess for pytest Jan 04, 2025 5 - Production/Stable pytest>=4.0.0 + :pypi:`pytest-subtesthack` A hack to explicitly set up and tear down fixtures. Jul 16, 2022 N/A N/A + :pypi:`pytest-subtests` unittest subTest() support and subtests fixture Dec 10, 2024 4 - Beta pytest>=7.4 + :pypi:`pytest-subunit` pytest-subunit is a plugin for py.test which outputs testsresult in subunit format. Sep 17, 2023 N/A pytest (>=2.3) + :pypi:`pytest-sugar` pytest-sugar is a plugin for pytest that changes the default look and feel of pytest (e.g. progressbar, show tests that fail instantly). Feb 01, 2024 4 - Beta pytest >=6.2.0 + :pypi:`pytest-suitemanager` A simple plugin to use with pytest Apr 28, 2023 4 - Beta N/A + :pypi:`pytest-suite-timeout` A pytest plugin for ensuring max suite time Jan 26, 2024 N/A pytest>=7.0.0 + :pypi:`pytest-supercov` Pytest plugin for measuring explicit test-file to source-file coverage Jul 02, 2023 N/A N/A + :pypi:`pytest-svn` SVN repository fixture for py.test Oct 17, 2024 5 - Production/Stable pytest + :pypi:`pytest-symbols` pytest-symbols is a pytest plugin that adds support for passing test environment symbols into pytest tests. Nov 20, 2017 3 - Alpha N/A + :pypi:`pytest-system-statistics` Pytest plugin to track and report system usage statistics Feb 16, 2022 5 - Production/Stable pytest (>=6.0.0) + :pypi:`pytest-system-test-plugin` Pyst - Pytest System-Test Plugin Feb 03, 2022 N/A N/A + :pypi:`pytest_tagging` a pytest plugin to tag tests Nov 08, 2024 N/A pytest>=7.1.3 + :pypi:`pytest-takeltest` Fixtures for ansible, testinfra and molecule Sep 07, 2024 N/A N/A + :pypi:`pytest-talisker` Nov 28, 2021 N/A N/A + :pypi:`pytest-tally` A Pytest plugin to generate realtime summary stats, and display them in-console using a text-based dashboard. May 22, 2023 4 - Beta pytest (>=6.2.5) + :pypi:`pytest-tap` Test Anything Protocol (TAP) reporting plugin for pytest Jan 30, 2025 5 - Production/Stable pytest>=3.0 + :pypi:`pytest-tape` easy assertion with expected results saved to yaml files Mar 17, 2021 4 - Beta N/A + :pypi:`pytest-target` Pytest plugin for remote target orchestration. Jan 21, 2021 3 - Alpha pytest (>=6.1.2,<7.0.0) + :pypi:`pytest-taskgraph` Add your description here Apr 09, 2025 N/A pytest + :pypi:`pytest-tblineinfo` tblineinfo is a py.test plugin that insert the node id in the final py.test report when --tb=line option is used Dec 01, 2015 3 - Alpha pytest (>=2.0) + :pypi:`pytest-tcpclient` A pytest plugin for testing TCP clients Nov 16, 2022 N/A pytest (<8,>=7.1.3) + :pypi:`pytest-tdd` run pytest on a python module Aug 18, 2023 4 - Beta N/A + :pypi:`pytest-teamcity-logblock` py.test plugin to introduce block structure in teamcity build log, if output is not captured May 15, 2018 4 - Beta N/A + :pypi:`pytest-teardown` Apr 15, 2025 N/A pytest<9.0.0,>=7.4.1 + :pypi:`pytest-telegram` Pytest to Telegram reporting plugin Apr 25, 2024 5 - Production/Stable N/A + :pypi:`pytest-telegram-notifier` Telegram notification plugin for Pytest Jun 27, 2023 5 - Production/Stable N/A + :pypi:`pytest-tempdir` Predictable and repeatable tempdir support. Oct 11, 2019 4 - Beta pytest (>=2.8.1) + :pypi:`pytest-terra-fixt` Terraform and Terragrunt fixtures for pytest Sep 15, 2022 N/A pytest (==6.2.5) + :pypi:`pytest-terraform` A pytest plugin for using terraform fixtures May 21, 2024 N/A pytest>=6.0 + :pypi:`pytest-terraform-fixture` generate terraform resources to use with pytest Nov 14, 2018 4 - Beta N/A + :pypi:`pytest-testbook` A plugin to run tests written in Jupyter notebook Dec 11, 2016 3 - Alpha N/A + :pypi:`pytest-testconfig` Test configuration plugin for pytest. Jan 11, 2020 4 - Beta pytest (>=3.5.0) + :pypi:`pytest-testdata` Get and load testdata in pytest projects Aug 30, 2024 N/A pytest + :pypi:`pytest-testdirectory` A py.test plugin providing temporary directories in unit tests. May 02, 2023 5 - Production/Stable pytest + :pypi:`pytest-testdox` A testdox format reporter for pytest Jul 22, 2023 5 - Production/Stable pytest (>=4.6.0) + :pypi:`pytest-test-grouping` A Pytest plugin for running a subset of your tests by splitting them in to equally sized groups. Feb 01, 2023 5 - Production/Stable pytest (>=2.5) + :pypi:`pytest-test-groups` A Pytest plugin for running a subset of your tests by splitting them in to equally sized groups. May 08, 2025 5 - Production/Stable pytest>=7.0.0 + :pypi:`pytest-testinfra` Test infrastructures Mar 30, 2025 5 - Production/Stable pytest>=6 + :pypi:`pytest-testinfra-jpic` Test infrastructures Sep 21, 2023 5 - Production/Stable N/A + :pypi:`pytest-testinfra-winrm-transport` Test infrastructures Sep 21, 2023 5 - Production/Stable N/A + :pypi:`pytest-testit-parametrize` A pytest plugin for uploading parameterized tests parameters into TMS TestIT Dec 04, 2024 4 - Beta pytest>=8.3.3 + :pypi:`pytest-testlink-adaptor` pytest reporting plugin for testlink Dec 20, 2018 4 - Beta pytest (>=2.6) + :pypi:`pytest-testmon` selects tests affected by changed files and methods Dec 22, 2024 4 - Beta pytest<9,>=5 + :pypi:`pytest-testmon-dev` selects tests affected by changed files and methods Mar 30, 2023 4 - Beta pytest (<8,>=5) + :pypi:`pytest-testmon-oc` nOly selects tests affected by changed files and methods Jun 01, 2022 4 - Beta pytest (<8,>=5) + :pypi:`pytest-testmon-skip-libraries` selects tests affected by changed files and methods Mar 03, 2023 4 - Beta pytest (<8,>=5) + :pypi:`pytest-testobject` Plugin to use TestObject Suites with Pytest Sep 24, 2019 4 - Beta pytest (>=3.1.1) + :pypi:`pytest-testpluggy` set your encoding Jan 07, 2022 N/A pytest + :pypi:`pytest-testrail` pytest plugin for creating TestRail runs and adding results Aug 27, 2020 N/A pytest (>=3.6) + :pypi:`pytest-testrail2` A pytest plugin to upload results to TestRail. Feb 10, 2023 N/A pytest (<8.0,>=7.2.0) + :pypi:`pytest-testrail-api` TestRail Api Python Client Mar 17, 2025 N/A pytest + :pypi:`pytest-testrail-api-client` TestRail Api Python Client Dec 14, 2021 N/A pytest + :pypi:`pytest-testrail-appetize` pytest plugin for creating TestRail runs and adding results Sep 29, 2021 N/A N/A + :pypi:`pytest-testrail-client` pytest plugin for Testrail Sep 29, 2020 5 - Production/Stable N/A + :pypi:`pytest-testrail-e2e` pytest plugin for creating TestRail runs and adding results Oct 11, 2021 N/A pytest (>=3.6) + :pypi:`pytest-testrail-integrator` Pytest plugin for sending report to testrail system. Aug 01, 2022 N/A pytest (>=6.2.5) + :pypi:`pytest-testrail-ns` pytest plugin for creating TestRail runs and adding results Aug 12, 2022 N/A N/A + :pypi:`pytest-testrail-plugin` PyTest plugin for TestRail Apr 21, 2020 3 - Alpha pytest + :pypi:`pytest-testrail-reporter` Sep 10, 2018 N/A N/A + :pypi:`pytest-testrail-results` A pytest plugin to upload results to TestRail. Mar 04, 2024 N/A pytest >=7.2.0 + :pypi:`pytest-testreport` Dec 01, 2022 4 - Beta pytest (>=3.5.0) + :pypi:`pytest-testreport-new` Oct 07, 2023 4 - Beta pytest >=3.5.0 + :pypi:`pytest-testslide` TestSlide fixture for pytest Jan 07, 2021 5 - Production/Stable pytest (~=6.2) + :pypi:`pytest-test-this` Plugin for py.test to run relevant tests, based on naively checking if a test contains a reference to the symbol you supply Sep 15, 2019 2 - Pre-Alpha pytest (>=2.3) + :pypi:`pytest-test-tracer-for-pytest` A plugin that allows coll test data for use on Test Tracer Jun 28, 2024 4 - Beta pytest>=6.2.0 + :pypi:`pytest-test-tracer-for-pytest-bdd` A plugin that allows coll test data for use on Test Tracer Aug 20, 2024 4 - Beta pytest>=6.2.0 + :pypi:`pytest-test-utils` Feb 08, 2024 N/A pytest >=3.9 + :pypi:`pytest-tesults` Tesults plugin for pytest Nov 12, 2024 5 - Production/Stable pytest>=3.5.0 + :pypi:`pytest-textual-snapshot` Snapshot testing for Textual apps Jan 23, 2025 5 - Production/Stable pytest>=8.0.0 + :pypi:`pytest-tezos` pytest-ligo Jan 16, 2020 4 - Beta N/A + :pypi:`pytest-tf` Test your OpenTofu and Terraform config using a PyTest plugin May 29, 2024 N/A pytest<9.0.0,>=8.2.1 + :pypi:`pytest-th2-bdd` pytest_th2_bdd May 13, 2022 N/A N/A + :pypi:`pytest-thawgun` Pytest plugin for time travel May 26, 2020 3 - Alpha N/A + :pypi:`pytest-thread` Jul 07, 2023 N/A N/A + :pypi:`pytest-threadleak` Detects thread leaks Jul 03, 2022 4 - Beta pytest (>=3.1.1) + :pypi:`pytest-tick` Ticking on tests Aug 31, 2021 5 - Production/Stable pytest (>=6.2.5,<7.0.0) + :pypi:`pytest-time` Jan 20, 2025 3 - Alpha pytest + :pypi:`pytest-timeassert-ethan` execution duration Dec 25, 2023 N/A pytest + :pypi:`pytest-timeit` A pytest plugin to time test function runs Oct 13, 2016 4 - Beta N/A + :pypi:`pytest-timeout` pytest plugin to abort hanging tests May 05, 2025 5 - Production/Stable pytest>=7.0.0 + :pypi:`pytest-timeouts` Linux-only Pytest plugin to control durations of various test case execution phases Sep 21, 2019 5 - Production/Stable N/A + :pypi:`pytest-timer` A timer plugin for pytest Dec 26, 2023 N/A pytest + :pypi:`pytest-timestamper` Pytest plugin to add a timestamp prefix to the pytest output Mar 27, 2024 N/A N/A + :pypi:`pytest-timestamps` A simple plugin to view timestamps for each test Sep 11, 2023 N/A pytest (>=7.3,<8.0) + :pypi:`pytest-tiny-api-client` The companion pytest plugin for tiny-api-client Jan 04, 2024 5 - Production/Stable pytest + :pypi:`pytest-tinybird` A pytest plugin to report test results to tinybird May 07, 2025 4 - Beta pytest>=3.8.0 + :pypi:`pytest-tipsi-django` Better fixtures for django Feb 05, 2024 5 - Production/Stable pytest>=6.0.0 + :pypi:`pytest-tipsi-testing` Better fixtures management. Various helpers Feb 04, 2024 5 - Production/Stable pytest>=3.3.0 + :pypi:`pytest-tldr` A pytest plugin that limits the output to just the things you need. Oct 26, 2022 4 - Beta pytest (>=3.5.0) + :pypi:`pytest-tm4j-reporter` Cloud Jira Test Management (TM4J) PyTest reporter plugin Sep 01, 2020 N/A pytest + :pypi:`pytest-tmnet` A small example package Mar 01, 2022 N/A N/A + :pypi:`pytest-tmp-files` Utilities to create temporary file hierarchies in pytest. Dec 08, 2023 N/A pytest + :pypi:`pytest-tmpfs` A pytest plugin that helps you on using a temporary filesystem for testing. Aug 29, 2022 N/A pytest + :pypi:`pytest-tmreport` this is a vue-element ui report for pytest Aug 12, 2022 N/A N/A + :pypi:`pytest-tmux` A pytest plugin that enables tmux driven tests Apr 22, 2023 4 - Beta N/A + :pypi:`pytest-todo` A small plugin for the pytest testing framework, marking TODO comments as failure May 23, 2019 4 - Beta pytest + :pypi:`pytest-tomato` Mar 01, 2019 5 - Production/Stable N/A + :pypi:`pytest-toolbelt` This is just a collection of utilities for pytest, but don't really belong in pytest proper. Aug 12, 2019 3 - Alpha N/A + :pypi:`pytest-toolbox` Numerous useful plugins for pytest. Apr 07, 2018 N/A pytest (>=3.5.0) + :pypi:`pytest-toolkit` Useful utils for testing Jun 07, 2024 N/A N/A + :pypi:`pytest-tools` Pytest tools Oct 21, 2022 4 - Beta N/A + :pypi:`pytest-topo` Topological sorting for pytest Jun 05, 2024 N/A pytest>=7.0.0 + :pypi:`pytest-tornado` A py.test plugin providing fixtures and markers to simplify testing of asynchronous tornado applications. Jun 17, 2020 5 - Production/Stable pytest (>=3.6) + :pypi:`pytest-tornado5` A py.test plugin providing fixtures and markers to simplify testing of asynchronous tornado applications. Nov 16, 2018 5 - Production/Stable pytest (>=3.6) + :pypi:`pytest-tornado-yen3` A py.test plugin providing fixtures and markers to simplify testing of asynchronous tornado applications. Oct 15, 2018 5 - Production/Stable N/A + :pypi:`pytest-tornasync` py.test plugin for testing Python 3.5+ Tornado code Jul 15, 2019 3 - Alpha pytest (>=3.0) + :pypi:`pytest-trace` Save OpenTelemetry spans generated during testing Jun 19, 2022 N/A pytest (>=4.6) + :pypi:`pytest-track` Feb 26, 2021 3 - Alpha pytest (>=3.0) + :pypi:`pytest-translations` Test your translation files. Sep 11, 2023 5 - Production/Stable pytest (>=7) + :pypi:`pytest-travis-fold` Folds captured output sections in Travis CI build log Nov 29, 2017 4 - Beta pytest (>=2.6.0) + :pypi:`pytest-trello` Plugin for py.test that integrates trello using markers Nov 20, 2015 5 - Production/Stable N/A + :pypi:`pytest-trepan` Pytest plugin for trepan debugger. Jul 28, 2018 5 - Production/Stable N/A + :pypi:`pytest-trialtemp` py.test plugin for using the same _trial_temp working directory as trial Jun 08, 2015 N/A N/A + :pypi:`pytest-trio` Pytest plugin for trio Nov 01, 2022 N/A pytest (>=7.2.0) + :pypi:`pytest-trytond` Pytest plugin for the Tryton server framework Nov 04, 2022 4 - Beta pytest (>=5) + :pypi:`pytest-tspwplib` A simple plugin to use with tspwplib Jan 08, 2021 4 - Beta pytest (>=3.5.0) + :pypi:`pytest-tst` Customize pytest options, output and exit code to make it compatible with tst Apr 27, 2022 N/A pytest (>=5.0.0) + :pypi:`pytest-tstcls` Test Class Base Mar 23, 2020 5 - Production/Stable N/A + :pypi:`pytest-tui` Text User Interface (TUI) and HTML report for Pytest test runs Dec 08, 2023 4 - Beta N/A + :pypi:`pytest-tuitest` pytest plugin for testing TUI and regular command-line applications. Apr 11, 2025 N/A pytest>=7.4.0 + :pypi:`pytest-tutorials` Mar 11, 2023 N/A N/A + :pypi:`pytest-twilio-conversations-client-mock` Aug 02, 2022 N/A N/A + :pypi:`pytest-twisted` A twisted plugin for pytest. Sep 10, 2024 5 - Production/Stable pytest>=2.3 + :pypi:`pytest-typechecker` Run type checkers on specified test files Feb 04, 2022 N/A pytest (>=6.2.5,<7.0.0) + :pypi:`pytest-typed-schema-shot` Pytest plugin for automatic JSON Schema generation and validation from examples May 24, 2025 N/A pytest + :pypi:`pytest-typhoon-config` A Typhoon HIL plugin that facilitates test parameter configuration at runtime Apr 07, 2022 5 - Production/Stable N/A + :pypi:`pytest-typhoon-polarion` Typhoontest plugin for Siemens Polarion Feb 01, 2024 4 - Beta N/A + :pypi:`pytest-typhoon-xray` Typhoon HIL plugin for pytest Aug 15, 2023 4 - Beta N/A + :pypi:`pytest-typing-runner` Pytest plugin to make it easier to run and check python code against static type checkers May 31, 2025 N/A N/A + :pypi:`pytest-tytest` Typhoon HIL plugin for pytest May 25, 2020 4 - Beta pytest (>=5.4.2) + :pypi:`pytest-ubersmith` Easily mock calls to ubersmith at the \`requests\` level. Apr 13, 2015 N/A N/A + :pypi:`pytest-ui` Text User Interface for running python tests Jul 05, 2021 4 - Beta pytest + :pypi:`pytest-ui-failed-screenshot` UI自动测试失败时自动截图,并将截图加入到测试报告中 Dec 06, 2022 N/A N/A + :pypi:`pytest-ui-failed-screenshot-allure` UI自动测试失败时自动截图,并将截图加入到Allure测试报告中 Dec 06, 2022 N/A N/A + :pypi:`pytest-uncollect-if` A plugin to uncollect pytests tests rather than using skipif Dec 26, 2024 4 - Beta pytest>=6.2.0 + :pypi:`pytest-unflakable` Unflakable plugin for PyTest Apr 30, 2024 4 - Beta pytest>=6.2.0 + :pypi:`pytest-unhandled-exception-exit-code` Plugin for py.test set a different exit code on uncaught exceptions Jun 22, 2020 5 - Production/Stable pytest (>=2.3) + :pypi:`pytest-unique` Pytest fixture to generate unique values. Mar 23, 2025 N/A pytest<8.0.0,>=7.4.2 + :pypi:`pytest-unittest-filter` A pytest plugin for filtering unittest-based test classes Jan 12, 2019 4 - Beta pytest (>=3.1.0) + :pypi:`pytest-unittest-id-runner` A pytest plugin to run tests using unittest-style test IDs Feb 09, 2025 N/A pytest>=6.0.0 + :pypi:`pytest-unmagic` Pytest fixtures with conventional import semantics Oct 22, 2024 5 - Production/Stable pytest + :pypi:`pytest-unmarked` Run only unmarked tests Aug 27, 2019 5 - Production/Stable N/A + :pypi:`pytest-unordered` Test equality of unordered collections in pytest Jul 05, 2024 4 - Beta pytest>=7.0.0 + :pypi:`pytest-unstable` Set a test as unstable to return 0 even if it failed Sep 27, 2022 4 - Beta N/A + :pypi:`pytest-unused-fixtures` A pytest plugin to list unused fixtures after a test run. Mar 15, 2025 4 - Beta pytest>7.3.2 + :pypi:`pytest-upload-report` pytest-upload-report is a plugin for pytest that upload your test report for test results. Jun 18, 2021 5 - Production/Stable N/A + :pypi:`pytest-utils` Some helpers for pytest. Feb 02, 2023 4 - Beta pytest (>=7.0.0,<8.0.0) + :pypi:`pytest-vagrant` A py.test plugin providing access to vagrant. Sep 07, 2021 5 - Production/Stable pytest + :pypi:`pytest-valgrind` May 19, 2021 N/A N/A + :pypi:`pytest-variables` pytest plugin for providing variables to tests/fixtures Feb 01, 2024 5 - Production/Stable pytest>=7.0.0 + :pypi:`pytest-variant` Variant support for Pytest Jun 06, 2022 N/A N/A + :pypi:`pytest-vcr` Plugin for managing VCR.py cassettes Apr 26, 2019 5 - Production/Stable pytest (>=3.6.0) + :pypi:`pytest-vcr-delete-on-fail` A pytest plugin that automates vcrpy cassettes deletion on test failure. Feb 16, 2024 5 - Production/Stable pytest (>=8.0.0,<9.0.0) + :pypi:`pytest-vcrpandas` Test from HTTP interactions to dataframe processed. Jan 12, 2019 4 - Beta pytest + :pypi:`pytest-vcs` Sep 22, 2022 4 - Beta N/A + :pypi:`pytest-venv` py.test fixture for creating a virtual environment Nov 23, 2023 4 - Beta pytest + :pypi:`pytest-verbose-parametrize` More descriptive output for parametrized py.test tests Nov 29, 2024 5 - Production/Stable pytest + :pypi:`pytest-vimqf` A simple pytest plugin that will shrink pytest output when specified, to fit vim quickfix window. Feb 08, 2021 4 - Beta pytest (>=6.2.2,<7.0.0) + :pypi:`pytest-virtualenv` Virtualenv fixture for py.test Nov 29, 2024 5 - Production/Stable pytest + :pypi:`pytest-visual` Nov 28, 2024 4 - Beta pytest>=7.0.0 + :pypi:`pytest-vnc` VNC client for Pytest Nov 06, 2023 N/A pytest + :pypi:`pytest-voluptuous` Pytest plugin for asserting data against voluptuous schema. Jun 09, 2020 N/A pytest + :pypi:`pytest-vscodedebug` A pytest plugin to easily enable debugging tests within Visual Studio Code Dec 04, 2020 4 - Beta N/A + :pypi:`pytest-vscode-pycharm-cls` A PyTest helper to enable start remote debugger on test start or failure or when pytest.set_trace is used. Feb 01, 2023 N/A pytest + :pypi:`pytest-vtestify` A pytest plugin for visual assertion using SSIM and image comparison. Feb 04, 2025 N/A pytest + :pypi:`pytest-vts` pytest plugin for automatic recording of http stubbed tests Jun 05, 2019 N/A pytest (>=2.3) + :pypi:`pytest-vulture` A pytest plugin to checks dead code with vulture Nov 25, 2024 N/A pytest>=7.0.0 + :pypi:`pytest-vw` pytest-vw makes your failing test cases succeed under CI tools scrutiny Oct 07, 2015 4 - Beta N/A + :pypi:`pytest-vyper` Plugin for the vyper smart contract language. May 28, 2020 2 - Pre-Alpha N/A + :pypi:`pytest-wa-e2e-plugin` Pytest plugin for testing whatsapp bots with end to end tests Feb 18, 2020 4 - Beta pytest (>=3.5.0) + :pypi:`pytest-wake` Nov 19, 2024 N/A pytest + :pypi:`pytest-watch` Local continuous test runner with pytest and watchdog. May 20, 2018 N/A N/A + :pypi:`pytest-watcher` Automatically rerun your tests on file modifications Aug 28, 2024 4 - Beta N/A + :pypi:`pytest-watch-plugin` Placeholder for internal package Sep 12, 2024 N/A N/A + :pypi:`pytest_wdb` Trace pytest tests with wdb to halt on error with --wdb. Jul 04, 2016 N/A N/A + :pypi:`pytest-wdl` Pytest plugin for testing WDL workflows. Nov 17, 2020 5 - Production/Stable N/A + :pypi:`pytest-web3-data` A pytest plugin to fetch test data from IPFS HTTP gateways during pytest execution. Oct 04, 2023 4 - Beta pytest + :pypi:`pytest-webdriver` Selenium webdriver fixture for py.test Oct 17, 2024 5 - Production/Stable pytest + :pypi:`pytest-webstage` Test web apps with pytest Sep 20, 2024 N/A pytest<9.0,>=7.0 + :pypi:`pytest-webtest-extras` Pytest plugin to enhance pytest-html and allure reports of webtest projects by adding screenshots, comments and webpage sources. Dec 28, 2024 N/A pytest>=7.0.0 + :pypi:`pytest-wetest` Welian API Automation test framework pytest plugin Nov 10, 2018 4 - Beta N/A + :pypi:`pytest-when` Utility which makes mocking more readable and controllable Nov 29, 2024 N/A pytest>=7.3.1 + :pypi:`pytest-whirlwind` Testing Tornado. Jun 12, 2020 N/A N/A + :pypi:`pytest-wholenodeid` pytest addon for displaying the whole node id for failures Aug 26, 2015 4 - Beta pytest (>=2.0) + :pypi:`pytest-win32consoletitle` Pytest progress in console title (Win32 only) Aug 08, 2021 N/A N/A + :pypi:`pytest-winnotify` Windows tray notifications for py.test results. Apr 22, 2016 N/A N/A + :pypi:`pytest-wiremock` A pytest plugin for programmatically using wiremock in integration tests Mar 27, 2022 N/A pytest (>=7.1.1,<8.0.0) + :pypi:`pytest-wiretap` \`pytest\` plugin for recording call stacks Mar 18, 2025 N/A pytest + :pypi:`pytest-with-docker` pytest with docker helpers. Nov 09, 2021 N/A pytest + :pypi:`pytest-workaround-12888` forces an import of readline early in the process to work around pytest bug #12888 Jan 15, 2025 N/A N/A + :pypi:`pytest-workflow` A pytest plugin for configuring workflow/pipeline tests using YAML files Mar 18, 2024 5 - Production/Stable pytest >=7.0.0 + :pypi:`pytest-xdist` pytest xdist plugin for distributed testing, most importantly across multiple CPUs May 26, 2025 5 - Production/Stable pytest>=7.0.0 + :pypi:`pytest-xdist-debug-for-graingert` pytest xdist plugin for distributed testing and loop-on-failing modes Jul 24, 2019 5 - Production/Stable pytest (>=4.4.0) + :pypi:`pytest-xdist-forked` forked from pytest-xdist Feb 10, 2020 5 - Production/Stable pytest (>=4.4.0) + :pypi:`pytest-xdist-lock` Extension for pytest-xdist adding test and resource group locks for local and distributed runs Apr 26, 2025 N/A pytest>=6.0 + :pypi:`pytest-xdist-tracker` pytest plugin helps to reproduce failures for particular xdist node Nov 18, 2021 3 - Alpha pytest (>=3.5.1) + :pypi:`pytest-xdist-worker-stats` A pytest plugin to list worker statistics after a xdist run. Mar 15, 2025 4 - Beta pytest>=7.0.0 + :pypi:`pytest-xdocker` Pytest fixture to run docker across test runs. Mar 23, 2025 N/A pytest<8.0.0,>=7.4.2 + :pypi:`pytest-xfaillist` Maintain a xfaillist in an additional file to avoid merge-conflicts. Sep 17, 2021 N/A pytest (>=6.2.2,<7.0.0) + :pypi:`pytest-xfiles` Pytest fixtures providing data read from function, module or package related (x)files. Feb 27, 2018 N/A N/A + :pypi:`pytest-xflaky` A simple plugin to use with pytest Oct 14, 2024 4 - Beta pytest>=8.2.1 + :pypi:`pytest-xiuyu` This is a pytest plugin Jul 25, 2023 5 - Production/Stable N/A + :pypi:`pytest-xlog` Extended logging for test and decorators May 31, 2020 4 - Beta N/A + :pypi:`pytest-xlsx` pytest plugin for generating test cases by xlsx(excel) Aug 07, 2024 N/A pytest~=8.2.2 + :pypi:`pytest-xml` Create simple XML results for parsing Nov 14, 2024 4 - Beta pytest>=8.0.0 + :pypi:`pytest-xpara` An extended parametrizing plugin of pytest. Aug 07, 2024 3 - Alpha pytest + :pypi:`pytest-xprocess` A pytest plugin for managing processes across test runs. May 19, 2024 4 - Beta pytest>=2.8 + :pypi:`pytest-xray` May 30, 2019 3 - Alpha N/A + :pypi:`pytest-xrayjira` Mar 17, 2020 3 - Alpha pytest (==4.3.1) + :pypi:`pytest-xray-reporter` Pytest plugin for generating Xray JSON reports May 21, 2025 4 - Beta pytest>=7.0.0 + :pypi:`pytest-xray-server` May 03, 2022 3 - Alpha pytest (>=5.3.1) + :pypi:`pytest-xstress` Jun 01, 2024 N/A pytest<9.0.0,>=8.0.0 + :pypi:`pytest-xvfb` A pytest plugin to run Xvfb (or Xephyr/Xvnc) for tests. Mar 12, 2025 4 - Beta pytest>=2.8.1 + :pypi:`pytest-xvirt` A pytest plugin to virtualize test. For example to transparently running them on a remote box. Dec 15, 2024 4 - Beta pytest>=7.2.2 + :pypi:`pytest-yaml` This plugin is used to load yaml output to your test using pytest framework. Oct 05, 2018 N/A pytest + :pypi:`pytest-yaml-fei` a pytest yaml allure package Feb 09, 2025 N/A pytest + :pypi:`pytest-yaml-sanmu` Pytest plugin for generating test cases with YAML. In test cases, you can use markers, fixtures, variables, and even call Python functions. Jan 03, 2025 N/A pytest>=8.2.2 + :pypi:`pytest-yamltree` Create or check file/directory trees described by YAML Mar 02, 2020 4 - Beta pytest (>=3.1.1) + :pypi:`pytest-yamlwsgi` Run tests against wsgi apps defined in yaml May 11, 2010 N/A N/A + :pypi:`pytest-yaml-yoyo` http/https API run by yaml Jun 19, 2023 N/A pytest (>=7.2.0) + :pypi:`pytest-yapf` Run yapf Jul 06, 2017 4 - Beta pytest (>=3.1.1) + :pypi:`pytest-yapf3` Validate your Python file format with yapf Mar 29, 2023 5 - Production/Stable pytest (>=7) + :pypi:`pytest-yield` PyTest plugin to run tests concurrently, each \`yield\` switch context to other one Jan 23, 2019 N/A N/A + :pypi:`pytest-yls` Pytest plugin to test the YLS as a whole. Apr 09, 2025 N/A pytest<9.0.0,>=8.3.3 + :pypi:`pytest-youqu-playwright` pytest-youqu-playwright Jun 12, 2024 N/A pytest + :pypi:`pytest-yuk` Display tests you are uneasy with, using 🤢/🤮 for pass/fail of tests marked with yuk. Mar 26, 2021 N/A pytest>=5.0.0 + :pypi:`pytest-zafira` A Zafira plugin for pytest Sep 18, 2019 5 - Production/Stable pytest (==4.1.1) + :pypi:`pytest-zap` OWASP ZAP plugin for py.test. May 12, 2014 4 - Beta N/A + :pypi:`pytest-zcc` eee Jun 02, 2024 N/A N/A + :pypi:`pytest-zebrunner` Pytest connector for Zebrunner reporting Jul 04, 2024 5 - Production/Stable pytest>=4.5.0 + :pypi:`pytest-zeebe` Pytest fixtures for testing Camunda 8 processes using a Zeebe test engine. Feb 01, 2024 N/A pytest (>=7.4.2,<8.0.0) + :pypi:`pytest-zephyr-scale-integration` A library for integrating Jira Zephyr Scale (Adaptavist\TM4J) with pytest May 15, 2025 N/A pytest + :pypi:`pytest-zephyr-telegram` Плагин для отправки данных автотестов в Телеграм и Зефир Sep 30, 2024 N/A pytest==8.3.2 + :pypi:`pytest-zest` Zesty additions to pytest. Nov 17, 2022 N/A N/A + :pypi:`pytest-zhongwen-wendang` PyTest 中文文档 Mar 04, 2024 4 - Beta N/A + :pypi:`pytest-zigzag` Extend py.test for RPC OpenStack testing. Feb 27, 2019 4 - Beta pytest (~=3.6) + :pypi:`pytest-zulip` Pytest report plugin for Zulip May 07, 2022 5 - Production/Stable pytest + :pypi:`pytest-zy` 接口自动化测试框架 Mar 24, 2024 N/A pytest~=7.2.0 + :pypi:`tursu` 🎬 A pytest plugin that transpiles Gherkin feature files to Python using AST, enforcing typing for ease of use and debugging. May 05, 2025 4 - Beta pytest>=8.3.5 + =============================================== ====================================================================================================================================================================================================================================================================================================================================================================================== ============== ===================== ================================================ .. only:: latex + :pypi:`databricks-labs-pytester` + *last release*: May 13, 2025, + *status*: 4 - Beta, + *requires*: pytest>=8.3 + + Python Testing for Databricks + :pypi:`logassert` - *last release*: May 20, 2022, + *last release*: May 15, 2025, *status*: 5 - Production/Stable, - *requires*: N/A + *requires*: pytest; extra == "dev" + + Simple but powerful assertion and verification of logged lines + + :pypi:`logot` + *last release*: May 05, 2025, + *status*: 5 - Production/Stable, + *requires*: pytest; extra == "pytest" + + Test whether your code is logging correctly 🪵 + + :pypi:`nuts` + *last release*: May 10, 2025, + *status*: N/A, + *requires*: pytest<8,>=7 - Simple but powerful assertion and verification of logged lines. + Network Unit Testing System :pypi:`pytest-abq` *last release*: Apr 07, 2023, @@ -1314,9 +1723,9 @@ This list contains 1267 plugins. A contextmanager pytest fixture for handling multiple mock abstracts :pypi:`pytest-accept` - *last release*: Dec 21, 2022, + *last release*: Dec 08, 2024, *status*: N/A, - *requires*: pytest (>=6,<8) + *requires*: pytest>=7 A pytest-plugin for updating doctest outputs @@ -1327,6 +1736,13 @@ This list contains 1267 plugins. pytest plugin for generating test execution results within Jira Test Management (tm4j) + :pypi:`pytest-adaptavist-fixed` + *last release*: Jan 17, 2025, + *status*: N/A, + *requires*: pytest>=5.4.0 + + pytest plugin for generating test execution results within Jira Test Management (tm4j) + :pypi:`pytest-addons-test` *last release*: Aug 02, 2021, *status*: N/A, @@ -1355,6 +1771,13 @@ This list contains 1267 plugins. Azure DevOps Test Case reporting for pytest tests + :pypi:`pytest-affected` + *last release*: Nov 06, 2023, + *status*: N/A, + *requires*: N/A + + + :pypi:`pytest-agent` *last release*: Nov 25, 2021, *status*: N/A, @@ -1369,13 +1792,34 @@ This list contains 1267 plugins. pytest plugin for pytest-repeat that generate aggregate report of the same test cases with additional statistics details. + :pypi:`pytest-ai` + *last release*: Jan 22, 2025, + *status*: N/A, + *requires*: N/A + + A Python package to generate regular, edge-case, and security HTTP tests. + + :pypi:`pytest-ai1899` + *last release*: Mar 13, 2024, + *status*: 5 - Production/Stable, + *requires*: N/A + + pytest plugin for connecting to ai1899 smart system stack + :pypi:`pytest-aio` - *last release*: Feb 03, 2023, - *status*: 4 - Beta, + *last release*: Jul 31, 2024, + *status*: 5 - Production/Stable, *requires*: pytest Pytest plugin for testing async python code + :pypi:`pytest-aioboto3` + *last release*: Jan 17, 2025, + *status*: N/A, + *requires*: N/A + + Aioboto3 Pytest with Moto + :pypi:`pytest-aiofiles` *last release*: May 14, 2017, *status*: 5 - Production/Stable, @@ -1391,9 +1835,9 @@ This list contains 1267 plugins. :pypi:`pytest-aiohttp` - *last release*: Feb 12, 2022, + *last release*: Jan 23, 2025, *status*: 4 - Beta, - *requires*: pytest (>=6.1.0) + *requires*: pytest>=6.1.0 Pytest plugin for aiohttp support @@ -1405,23 +1849,23 @@ This list contains 1267 plugins. Pytest \`client\` fixture for the Aiohttp :pypi:`pytest-aiomoto` - *last release*: Nov 09, 2022, + *last release*: Jun 24, 2023, *status*: N/A, *requires*: pytest (>=7.0,<8.0) pytest-aiomoto :pypi:`pytest-aioresponses` - *last release*: Jul 29, 2021, + *last release*: Jan 02, 2025, *status*: 4 - Beta, - *requires*: pytest (>=3.5.0) + *requires*: pytest>=3.5.0 py.test integration for aioresponses :pypi:`pytest-aioworkers` - *last release*: May 01, 2023, + *last release*: Dec 26, 2024, *status*: 5 - Production/Stable, - *requires*: pytest>=6.1.0 + *requires*: pytest>=8.3.4 A plugin to test aioworkers project with pytest @@ -1440,12 +1884,19 @@ This list contains 1267 plugins. :pypi:`pytest-alembic` - *last release*: May 23, 2023, + *last release*: May 27, 2025, *status*: N/A, - *requires*: pytest (>=6.0) + *requires*: pytest>=7.0 A pytest plugin for verifying alembic migrations. + :pypi:`pytest-alerts` + *last release*: Feb 21, 2025, + *status*: 4 - Beta, + *requires*: pytest>=7.4.0 + + A pytest plugin for sending test results to Slack and Telegram + :pypi:`pytest-allclose` *last release*: Jul 30, 2019, *status*: 5 - Production/Stable, @@ -1481,6 +1932,13 @@ This list contains 1267 plugins. pytest plugin to test case doc string dls instructions + :pypi:`pytest-allure-id2history` + *last release*: May 14, 2024, + *status*: 4 - Beta, + *requires*: pytest>=6.2.0 + + Overwrite allure history id with testcase full name and testcase id if testcase has id, exclude parameters. + :pypi:`pytest-allure-intersection` *last release*: Oct 27, 2022, *status*: N/A, @@ -1502,6 +1960,20 @@ This list contains 1267 plugins. Static code checks used at Alphamoon + :pypi:`pytest-amaranth-sim` + *last release*: Sep 21, 2024, + *status*: 4 - Beta, + *requires*: pytest>=6.2.0 + + Fixture to automate running Amaranth simulations + + :pypi:`pytest-analyzer` + *last release*: Feb 21, 2024, + *status*: N/A, + *requires*: pytest <8.0.0,>=7.3.1 + + this plugin allows to analyze tests in pytest project, collect test metadata and sync it with testomat.io TCM system + :pypi:`pytest-android` *last release*: Feb 21, 2019, *status*: 3 - Alpha, @@ -1523,10 +1995,17 @@ This list contains 1267 plugins. pytest-annotate: Generate PyAnnotate annotations from your pytest tests. + :pypi:`pytest-annotated` + *last release*: Sep 30, 2024, + *status*: N/A, + *requires*: pytest>=8.3.3 + + Pytest plugin to allow use of Annotated in tests to resolve fixtures + :pypi:`pytest-ansible` - *last release*: May 15, 2023, + *last release*: May 26, 2025, *status*: 5 - Production/Stable, - *requires*: pytest (<8.0.0,>=6) + *requires*: pytest>=6 Plugin for pytest to simplify calling ansible modules from tests or fixtures @@ -1552,9 +2031,9 @@ This list contains 1267 plugins. A pytest plugin for running unit tests within an ansible collection :pypi:`pytest-antilru` - *last release*: Jul 05, 2022, + *last release*: Jul 28, 2024, *status*: 5 - Production/Stable, - *requires*: pytest + *requires*: pytest>=7; python_version >= "3.10" Bust functools.lru_cache when running pytest to avoid test pollution @@ -1566,15 +2045,15 @@ This list contains 1267 plugins. The pytest anyio plugin is built into anyio. You don't need this package. :pypi:`pytest-anything` - *last release*: Oct 13, 2022, + *last release*: Jan 18, 2024, *status*: N/A, *requires*: pytest Pytest fixtures to assert anything and something :pypi:`pytest-aoc` - *last release*: Dec 08, 2022, - *status*: N/A, + *last release*: Dec 02, 2023, + *status*: 5 - Production/Stable, *requires*: pytest ; extra == 'test' Downloads puzzle inputs for Advent of Code and synthesizes PyTest fixtures @@ -1607,6 +2086,13 @@ This list contains 1267 plugins. apistellar plugin for pytest. + :pypi:`pytest-apiver` + *last release*: Jun 21, 2024, + *status*: N/A, + *requires*: pytest + + + :pypi:`pytest-appengine` *last release*: Feb 27, 2017, *status*: N/A, @@ -1629,16 +2115,16 @@ This list contains 1267 plugins. A plugin to use approvaltests with pytest :pypi:`pytest-approvaltests-geo` - *last release*: Mar 04, 2023, + *last release*: Feb 05, 2024, *status*: 5 - Production/Stable, *requires*: pytest Extension for ApprovalTests.Python specific to geo data verification :pypi:`pytest-archon` - *last release*: Jan 31, 2023, + *last release*: Dec 18, 2023, *status*: 5 - Production/Stable, - *requires*: pytest (>=7.2) + *requires*: pytest >=7.2 Rule your architecture like a real developer @@ -1649,10 +2135,17 @@ This list contains 1267 plugins. pyest results colection plugin + :pypi:`pytest-argus-server` + *last release*: Mar 24, 2025, + *status*: 4 - Beta, + *requires*: pytest>=6.2.0 + + A plugin that provides a running Argus API server for tests + :pypi:`pytest-arraydiff` - *last release*: Jan 13, 2022, + *last release*: Nov 27, 2023, *status*: 4 - Beta, - *requires*: pytest (>=4.6) + *requires*: pytest >=4.6 pytest plugin to help with comparing array output from tests @@ -1663,6 +2156,13 @@ This list contains 1267 plugins. Convenient ASGI client/server fixtures for Pytest + :pypi:`pytest-aspec` + *last release*: Dec 20, 2023, + *status*: 4 - Beta, + *requires*: N/A + + A rspec format reporter for pytest + :pypi:`pytest-asptest` *last release*: Apr 28, 2018, *status*: 4 - Beta, @@ -1698,6 +2198,13 @@ This list contains 1267 plugins. Useful assertion utilities for use with pytest + :pypi:`pytest-assist` + *last release*: Mar 17, 2025, + *status*: N/A, + *requires*: pytest + + load testing library + :pypi:`pytest-assume` *last release*: Jun 24, 2021, *status*: N/A, @@ -1727,9 +2234,9 @@ This list contains 1267 plugins. PyTest plugin for docker-based testing on database images :pypi:`pytest-astropy` - *last release*: Apr 12, 2022, + *last release*: Sep 26, 2023, *status*: 5 - Production/Stable, - *requires*: pytest (>=4.6) + *requires*: pytest >=4.6 Meta-package containing dependencies for testing @@ -1747,15 +2254,43 @@ This list contains 1267 plugins. + :pypi:`pytest_async` + *last release*: Feb 26, 2020, + *status*: N/A, + *requires*: N/A + + pytest-async - Run your coroutine in event loop without decorator + + :pypi:`pytest-async-benchmark` + *last release*: May 28, 2025, + *status*: N/A, + *requires*: pytest>=8.3.5 + + pytest-async-benchmark: Modern pytest benchmarking for async code. 🚀 + + :pypi:`pytest-async-generators` + *last release*: Jul 05, 2023, + *status*: N/A, + *requires*: N/A + + Pytest fixtures for async generators + :pypi:`pytest-asyncio` - *last release*: Mar 19, 2023, + *last release*: May 26, 2025, *status*: 4 - Beta, - *requires*: pytest (>=7.0.0) + *requires*: pytest<9,>=8.2 Pytest support for asyncio + :pypi:`pytest-asyncio-concurrent` + *last release*: May 17, 2025, + *status*: 4 - Beta, + *requires*: pytest>=6.2.0 + + Pytest plugin to execute python async tests concurrently. + :pypi:`pytest-asyncio-cooperative` - *last release*: May 31, 2023, + *last release*: Apr 26, 2025, *status*: N/A, *requires*: N/A @@ -1782,6 +2317,13 @@ This list contains 1267 plugins. Database testing fixtures using the SQLAlchemy asyncio API + :pypi:`pytest-atf-allure` + *last release*: Nov 29, 2023, + *status*: N/A, + *requires*: pytest (>=7.4.2,<8.0.0) + + 基于allure-pytest进行自定义 + :pypi:`pytest-atomic` *last release*: Nov 24, 2018, *status*: 4 - Beta, @@ -1789,6 +2331,13 @@ This list contains 1267 plugins. Skip rest of tests if previous test failed. + :pypi:`pytest-atstack` + *last release*: Jan 02, 2025, + *status*: 4 - Beta, + *requires*: pytest>=6.2.0 + + A simple plugin to use with pytest + :pypi:`pytest-attrib` *last release*: May 24, 2016, *status*: 4 - Beta, @@ -1796,6 +2345,13 @@ This list contains 1267 plugins. pytest plugin to select tests based on attributes similar to the nose-attrib plugin + :pypi:`pytest-attributes` + *last release*: Jun 24, 2024, + *status*: 4 - Beta, + *requires*: pytest>=6.2.0 + + A plugin that allows users to add attributes to their tests. These attributes can then be referenced by fixtures or the test itself. + :pypi:`pytest-austin` *last release*: Oct 11, 2020, *status*: 4 - Beta, @@ -1817,10 +2373,17 @@ This list contains 1267 plugins. automatically check condition and log all the checks + :pypi:`pytest-autofixture` + *last release*: Aug 01, 2024, + *status*: N/A, + *requires*: pytest>=8 + + simplify pytest fixtures + :pypi:`pytest-automation` - *last release*: May 20, 2022, + *last release*: Apr 24, 2024, *status*: N/A, - *requires*: pytest (>=7.0.0) + *requires*: pytest>=7.0.0 pytest plugin for building a test suite, using YAML files to extend pytest parameterize functionality. @@ -1866,6 +2429,13 @@ This list contains 1267 plugins. pytest plugin for testing AWS resource configurations + :pypi:`pytest-aws-apigateway` + *last release*: May 24, 2024, + *status*: 4 - Beta, + *requires*: pytest + + pytest plugin for AWS ApiGateway + :pypi:`pytest-aws-config` *last release*: May 28, 2021, *status*: N/A, @@ -1873,6 +2443,13 @@ This list contains 1267 plugins. Protect your AWS credentials in unit tests + :pypi:`pytest-aws-fixtures` + *last release*: Apr 06, 2025, + *status*: N/A, + *requires*: pytest<9.0.0,>=8.0.0 + + A series of fixtures to use in integration tests involving actual AWS services. + :pypi:`pytest-axe` *last release*: Nov 12, 2018, *status*: N/A, @@ -1880,6 +2457,13 @@ This list contains 1267 plugins. pytest plugin for axe-selenium-python + :pypi:`pytest-axe-playwright-snapshot` + *last release*: Jul 25, 2023, + *status*: N/A, + *requires*: pytest + + A pytest plugin that runs Axe-core on Playwright pages and takes snapshots of the results. + :pypi:`pytest-azure` *last release*: Jan 18, 2023, *status*: 3 - Alpha, @@ -1895,7 +2479,7 @@ This list contains 1267 plugins. Simplifies using azure devops parallel strategy (https://docs.microsoft.com/en-us/azure/devops/pipelines/test/parallel-testing-any-test-runner) with pytest. :pypi:`pytest-azurepipelines` - *last release*: Oct 20, 2022, + *last release*: Oct 06, 2023, *status*: 5 - Production/Stable, *requires*: pytest (>=5.0.0) @@ -1916,16 +2500,30 @@ This list contains 1267 plugins. A bandit plugin for pytest :pypi:`pytest-base-url` - *last release*: Mar 27, 2022, + *last release*: Jan 31, 2024, *status*: 5 - Production/Stable, - *requires*: pytest (>=3.0.0,<8.0.0) + *requires*: pytest>=7.0.0 pytest plugin for URL based testing + :pypi:`pytest-batch-regression` + *last release*: May 08, 2024, + *status*: N/A, + *requires*: pytest>=6.0.0 + + A pytest plugin to repeat the entire test suite in batches. + + :pypi:`pytest-bazel` + *last release*: May 11, 2025, + *status*: 4 - Beta, + *requires*: pytest + + A pytest runner with bazel support + :pypi:`pytest-bdd` - *last release*: Nov 08, 2022, + *last release*: Dec 05, 2024, *status*: 6 - Mature, - *requires*: pytest (>=6.2.0) + *requires*: pytest>=7.0.0 BDD for pytest @@ -1937,12 +2535,19 @@ This list contains 1267 plugins. pytest plugin to display BDD info in HTML test report :pypi:`pytest-bdd-ng` - *last release*: Oct 06, 2022, + *last release*: Nov 26, 2024, *status*: 4 - Beta, - *requires*: pytest (>=5.0) + *requires*: pytest>=5.2 BDD for pytest + :pypi:`pytest-bdd-report` + *last release*: Nov 27, 2024, + *status*: N/A, + *requires*: pytest>=7.1.3 + + A pytest-bdd plugin for generating useful and informative BDD test reports + :pypi:`pytest-bdd-splinter` *last release*: Aug 12, 2019, *status*: 5 - Production/Stable, @@ -1971,6 +2576,20 @@ This list contains 1267 plugins. A pytest plugin that reports test results to the BeakerLib framework + :pypi:`pytest-beartype` + *last release*: Oct 31, 2024, + *status*: N/A, + *requires*: pytest + + Pytest plugin to run your tests with beartype checking enabled. + + :pypi:`pytest-bec-e2e` + *last release*: May 30, 2025, + *status*: 3 - Alpha, + *requires*: pytest + + BEC pytest plugin for end-to-end tests + :pypi:`pytest-beds` *last release*: Jun 07, 2016, *status*: 4 - Beta, @@ -1979,7 +2598,7 @@ This list contains 1267 plugins. Fixtures for testing Google Appengine (GAE) apps :pypi:`pytest-beeprint` - *last release*: Jun 09, 2023, + *last release*: Jul 04, 2023, *status*: 4 - Beta, *requires*: N/A @@ -1993,9 +2612,9 @@ This list contains 1267 plugins. Benchmark utility that plugs into pytest. :pypi:`pytest-benchmark` - *last release*: Oct 25, 2022, + *last release*: Oct 30, 2024, *status*: 5 - Production/Stable, - *requires*: pytest (>=3.8) + *requires*: pytest>=8.1 A \`\`pytest\`\` fixture for benchmarking code. It will group the tests into rounds that are calibrated to the chosen timer. @@ -2006,6 +2625,13 @@ This list contains 1267 plugins. A small example package + :pypi:`pytest-better-parametrize` + *last release*: Mar 05, 2024, + *status*: 4 - Beta, + *requires*: pytest >=6.2.0 + + Better description of parametrized test cases + :pypi:`pytest-bg-process` *last release*: Jan 24, 2022, *status*: 4 - Beta, @@ -2027,10 +2653,17 @@ This list contains 1267 plugins. Provides a mock fixture for python bigquery client + :pypi:`pytest-bisect-tests` + *last release*: Jun 09, 2024, + *status*: N/A, + *requires*: N/A + + Find tests leaking state and affecting other + :pypi:`pytest-black` - *last release*: Oct 05, 2020, + *last release*: Dec 15, 2024, *status*: 4 - Beta, - *requires*: N/A + *requires*: pytest>=7.0.0 A pytest plugin to enable format checking with black @@ -2056,9 +2689,9 @@ This list contains 1267 plugins. A pytest plugin helps developers to debug by providing useful commits history. :pypi:`pytest-blender` - *last release*: Jan 04, 2023, + *last release*: Aug 02, 2024, *status*: N/A, - *requires*: pytest ; extra == 'dev' + *requires*: pytest Blender Pytest plugin. @@ -2097,6 +2730,20 @@ This list contains 1267 plugins. Local continuous test runner with pytest and watchdog. + :pypi:`pytest-boilerplate` + *last release*: Sep 12, 2024, + *status*: 5 - Production/Stable, + *requires*: pytest>=4.0.0 + + The pytest plugin for your Django Boilerplate. + + :pypi:`pytest-bonsai` + *last release*: Apr 08, 2025, + *status*: N/A, + *requires*: pytest>=6 + + + :pypi:`pytest-boost-xml` *last release*: Nov 30, 2022, *status*: 4 - Beta, @@ -2111,6 +2758,13 @@ This list contains 1267 plugins. + :pypi:`pytest-boto-mock` + *last release*: Jul 16, 2024, + *status*: 5 - Production/Stable, + *requires*: pytest>=8.2.0 + + Thin-wrapper around the mock package for easier use with pytest + :pypi:`pytest-bpdb` *last release*: Jan 19, 2015, *status*: 2 - Pre-Alpha, @@ -2118,6 +2772,13 @@ This list contains 1267 plugins. A py.test plug-in to enable drop to bpdb debugger on test failure. + :pypi:`pytest-bq` + *last release*: May 08, 2024, + *status*: 5 - Production/Stable, + *requires*: pytest>=6.2 + + BigQuery fixtures and fixture factories for Pytest. + :pypi:`pytest-bravado` *last release*: Feb 15, 2022, *status*: N/A, @@ -2146,6 +2807,13 @@ This list contains 1267 plugins. A pytest plugin for running tests on a Briefcase project. + :pypi:`pytest-broadcaster` + *last release*: Mar 02, 2025, + *status*: 3 - Alpha, + *requires*: pytest + + Pytest plugin to broadcast pytest output to various destinations + :pypi:`pytest-browser` *last release*: Dec 10, 2016, *status*: 3 - Alpha, @@ -2160,6 +2828,13 @@ This list contains 1267 plugins. BrowserMob proxy plugin for py.test. + :pypi:`pytest_browserstack` + *last release*: Jan 27, 2016, + *status*: 4 - Beta, + *requires*: N/A + + Py.test plugin for BrowserStack + :pypi:`pytest-browserstack-local` *last release*: Feb 09, 2018, *status*: N/A, @@ -2175,9 +2850,9 @@ This list contains 1267 plugins. Budo Systems is a martial arts school management system. This module is the Budo Systems Pytest Plugin. :pypi:`pytest-bug` - *last release*: Jan 29, 2023, + *last release*: Jun 05, 2024, *status*: 5 - Production/Stable, - *requires*: pytest (>=6.2.0) + *requires*: pytest>=8.0.0 Pytest plugin for marking tests as a bug @@ -2217,7 +2892,7 @@ This list contains 1267 plugins. :pypi:`pytest-bwrap` - *last release*: Oct 26, 2018, + *last release*: Feb 25, 2024, *status*: 3 - Alpha, *requires*: N/A @@ -2231,9 +2906,9 @@ This list contains 1267 plugins. pytest plugin with mechanisms for caching across test runs :pypi:`pytest-cache-assert` - *last release*: Feb 26, 2023, + *last release*: Aug 14, 2023, *status*: 5 - Production/Stable, - *requires*: pytest (>=5.0.0) + *requires*: pytest (>=6.0.0) Cache assertion data to simplify regression testing of complex serializable data @@ -2279,6 +2954,13 @@ This list contains 1267 plugins. A plugin that replays pRNG state on failure. + :pypi:`pytest-capsqlalchemy` + *last release*: Mar 19, 2025, + *status*: 4 - Beta, + *requires*: N/A + + Pytest plugin to allow capturing SQLAlchemy queries. + :pypi:`pytest-capture-deprecatedwarnings` *last release*: Apr 30, 2019, *status*: N/A, @@ -2286,6 +2968,13 @@ This list contains 1267 plugins. pytest plugin to capture all deprecatedwarnings and put them in one file + :pypi:`pytest-capture-sysout` + *last release*: May 21, 2025, + *status*: 2 - Pre-Alpha, + *requires*: N/A + + An academic experiment package + :pypi:`pytest-capture-warnings` *last release*: May 03, 2022, *status*: N/A, @@ -2293,8 +2982,15 @@ This list contains 1267 plugins. pytest plugin to capture all warnings and put them in one file of your choice + :pypi:`pytest-case` + *last release*: Nov 25, 2024, + *status*: N/A, + *requires*: pytest<9.0.0,>=8.3.3 + + A clean, modern, wrapper for pytest.mark.parametrize + :pypi:`pytest-cases` - *last release*: Feb 23, 2023, + *last release*: Sep 26, 2024, *status*: 5 - Production/Stable, *requires*: N/A @@ -2321,12 +3017,33 @@ This list contains 1267 plugins. Pytest plugin with server for catching HTTP requests. + :pypi:`pytest-cdist` + *last release*: Jan 30, 2025, + *status*: N/A, + *requires*: pytest>=7 + + A pytest plugin to split your test suite into multiple parts + :pypi:`pytest-celery` - *last release*: May 06, 2021, + *last release*: Feb 21, 2025, + *status*: 5 - Production/Stable, + *requires*: N/A + + Pytest plugin for Celery + + :pypi:`pytest-celery-py37` + *last release*: May 23, 2025, + *status*: 5 - Production/Stable, + *requires*: N/A + + Pytest plugin for Celery (compatible with python 3.7) + + :pypi:`pytest-cfg-fetcher` + *last release*: Feb 26, 2024, *status*: N/A, *requires*: N/A - pytest-celery a shim pytest plugin to enable celery.contrib.pytest + Pass config options to your unit tests. :pypi:`pytest-chainmaker` *last release*: Oct 15, 2021, @@ -2378,23 +3095,23 @@ This list contains 1267 plugins. A pytest fixture for changing current working directory :pypi:`pytest-check` - *last release*: Jun 06, 2023, - *status*: N/A, - *requires*: pytest + *last release*: Apr 04, 2025, + *status*: 5 - Production/Stable, + *requires*: pytest>=7.0.0 A pytest plugin that allows multiple failures per test. :pypi:`pytest-checkdocs` - *last release*: Oct 09, 2022, + *last release*: Apr 30, 2024, *status*: 5 - Production/Stable, - *requires*: pytest (>=6) ; extra == 'testing' + *requires*: pytest!=8.1.*,>=6; extra == "testing" check the README when running tests :pypi:`pytest-checkipdb` - *last release*: Jul 22, 2020, + *last release*: Dec 04, 2023, *status*: 5 - Production/Stable, - *requires*: pytest (>=2.9.2) + *requires*: pytest >=2.9.2 plugin to check if there are ipdb debugs left @@ -2415,10 +3132,17 @@ This list contains 1267 plugins. :pypi:`pytest-check-links` *last release*: Jul 29, 2020, *status*: N/A, - *requires*: pytest>=7.0 + *requires*: pytest<9,>=7.0 Check links in files + :pypi:`pytest-checklist` + *last release*: May 23, 2025, + *status*: N/A, + *requires*: N/A + + Pytest plugin to track and report unit/function coverage. + :pypi:`pytest-check-mk` *last release*: Nov 19, 2015, *status*: 4 - Beta, @@ -2426,19 +3150,40 @@ This list contains 1267 plugins. pytest plugin to test Check_MK checks - :pypi:`pytest-check-requirements` - *last release*: Feb 10, 2023, + :pypi:`pytest-checkpoint` + *last release*: Mar 30, 2025, *status*: N/A, - *requires*: N/A + *requires*: pytest>=8.0.0 + + Restore a checkpoint in pytest + + :pypi:`pytest-ch-framework` + *last release*: Apr 17, 2024, + *status*: N/A, + *requires*: pytest==8.0.1 - A package to prevent Dependency Confusion attacks against Yandex. + My pytest framework :pypi:`pytest-chic-report` - *last release*: Jan 31, 2023, - *status*: 5 - Production/Stable, - *requires*: N/A + *last release*: Nov 01, 2024, + *status*: N/A, + *requires*: pytest>=6.0 + + Simple pytest plugin for generating and sending report to messengers. + + :pypi:`pytest-chinesereport` + *last release*: Apr 16, 2025, + *status*: 4 - Beta, + *requires*: pytest>=3.5.0 - A pytest plugin to send a report and printing summary of tests. + + + :pypi:`pytest-choose` + *last release*: Feb 04, 2024, + *status*: N/A, + *requires*: pytest >=7.0.0 + + Provide the pytest with the ability to collect use cases based on rules in text files :pypi:`pytest-chunks` *last release*: Jul 05, 2022, @@ -2447,6 +3192,13 @@ This list contains 1267 plugins. Run only a chunk of your test suite + :pypi:`pytest_cid` + *last release*: Sep 01, 2023, + *status*: 4 - Beta, + *requires*: pytest >= 5.0, < 7.0 + + Compare data structures containing matching CIDs of different versions and encoding + :pypi:`pytest-circleci` *last release*: May 03, 2019, *status*: N/A, @@ -2482,13 +3234,62 @@ This list contains 1267 plugins. A plugin providing an alternative, colourful diff output for failing assertions. - :pypi:`pytest-cldf` + :pypi:`pytest-class-fixtures` + *last release*: Nov 15, 2024, + *status*: N/A, + *requires*: pytest<9.0.0,>=8.3.3 + + Class as PyTest fixtures (and BDD steps) + + :pypi:`pytest-cldf` *last release*: Nov 07, 2022, *status*: N/A, *requires*: pytest (>=3.6) Easy quality control for CLDF datasets using pytest + :pypi:`pytest-clean-database` + *last release*: Mar 14, 2025, + *status*: 3 - Alpha, + *requires*: pytest<9,>=7.0 + + A pytest plugin that cleans your database up after every test. + + :pypi:`pytest-cleanslate` + *last release*: Apr 10, 2025, + *status*: N/A, + *requires*: pytest + + Collects and executes pytest tests separately + + :pypi:`pytest_cleanup` + *last release*: Jan 28, 2020, + *status*: N/A, + *requires*: N/A + + Automated, comprehensive and well-organised pytest test cases. + + :pypi:`pytest-cleanuptotal` + *last release*: Nov 08, 2024, + *status*: 5 - Production/Stable, + *requires*: N/A + + A cleanup plugin for pytest + + :pypi:`pytest-clerk` + *last release*: Jan 30, 2025, + *status*: N/A, + *requires*: pytest<9.0.0,>=8.0.0 + + A set of pytest fixtures to help with integration testing with Clerk. + + :pypi:`pytest-cli2-ansible` + *last release*: Mar 05, 2025, + *status*: N/A, + *requires*: N/A + + + :pypi:`pytest-click` *last release*: Feb 11, 2022, *status*: 5 - Production/Stable, @@ -2504,9 +3305,9 @@ This list contains 1267 plugins. Automatically register fixtures for custom CLI arguments :pypi:`pytest-clld` - *last release*: Jul 06, 2022, + *last release*: Oct 23, 2024, *status*: N/A, - *requires*: pytest (>=3.6) + *requires*: pytest>=3.9 @@ -2532,9 +3333,9 @@ This list contains 1267 plugins. Distribute tests to cloud machines without fuss :pypi:`pytest-cmake` - *last release*: Jan 21, 2023, + *last release*: Feb 17, 2025, *status*: N/A, - *requires*: pytest<8,>=4 + *requires*: pytest<9,>=4 Provide CMake module for Pytest @@ -2545,6 +3346,13 @@ This list contains 1267 plugins. Execute CMake Presets via pytest + :pypi:`pytest-cmdline-add-args` + *last release*: Sep 01, 2024, + *status*: N/A, + *requires*: N/A + + Pytest plugin for custom argument handling and Allure reporting. This plugin allows you to add arguments before running a test. + :pypi:`pytest-cobra` *last release*: Jun 29, 2019, *status*: 3 - Alpha, @@ -2552,6 +3360,27 @@ This list contains 1267 plugins. PyTest plugin for testing Smart Contracts for Ethereum blockchain. + :pypi:`pytest-cocotb` + *last release*: Mar 15, 2025, + *status*: 5 - Production/Stable, + *requires*: pytest; extra == "test" + + Pytest plugin to integrate Cocotb + + :pypi:`pytest-codeblock` + *last release*: May 10, 2025, + *status*: 4 - Beta, + *requires*: pytest + + Pytest plugin to collect and test code blocks in reStructuredText and Markdown files. + + :pypi:`pytest_codeblocks` + *last release*: Sep 17, 2023, + *status*: 5 - Production/Stable, + *requires*: pytest >= 7.0.0 + + Test code blocks in your READMEs + :pypi:`pytest-codecarbon` *last release*: Jun 15, 2022, *status*: N/A, @@ -2567,9 +3396,9 @@ This list contains 1267 plugins. pytest plugin to add source code sanity checks (pep8 and friends) :pypi:`pytest-codecov` - *last release*: Nov 29, 2022, + *last release*: Mar 25, 2025, *status*: 4 - Beta, - *requires*: pytest (>=4.6.0) + *requires*: pytest>=4.6.0 Pytest plugin for uploading pytest-cov results to codecov.io @@ -2595,12 +3424,19 @@ This list contains 1267 plugins. pytest plugin to run pycodestyle :pypi:`pytest-codspeed` - *last release*: Dec 02, 2022, + *last release*: May 27, 2025, *status*: 5 - Production/Stable, *requires*: pytest>=3.8 Pytest plugin to create CodSpeed benchmarks + :pypi:`pytest-collect-appoint-info` + *last release*: Aug 03, 2023, + *status*: N/A, + *requires*: pytest + + set your encoding + :pypi:`pytest-collect-formatter` *last release*: Mar 29, 2021, *status*: 5 - Production/Stable, @@ -2615,6 +3451,13 @@ This list contains 1267 plugins. Formatter for pytest collect output + :pypi:`pytest-collect-interface-info-plugin` + *last release*: Sep 25, 2023, + *status*: 4 - Beta, + *requires*: N/A + + Get executed interface information in pytest interface automation framework + :pypi:`pytest-collector` *last release*: Aug 02, 2022, *status*: N/A, @@ -2622,6 +3465,13 @@ This list contains 1267 plugins. Python package for collecting pytest. + :pypi:`pytest-collect-pytest-interinfo` + *last release*: Sep 26, 2023, + *status*: 4 - Beta, + *requires*: N/A + + A simple plugin to use with pytest + :pypi:`pytest-colordots` *last release*: Oct 06, 2017, *status*: 5 - Production/Stable, @@ -2637,14 +3487,14 @@ This list contains 1267 plugins. An interactive GUI test runner for PyTest :pypi:`pytest-common-subject` - *last release*: May 15, 2022, + *last release*: Jun 12, 2024, *status*: N/A, - *requires*: pytest (>=3.6,<8) + *requires*: pytest<9,>=3.6 pytest framework for testing different aspects of a common method :pypi:`pytest-compare` - *last release*: Mar 30, 2023, + *last release*: Jun 22, 2023, *status*: 5 - Production/Stable, *requires*: N/A @@ -2686,9 +3536,9 @@ This list contains 1267 plugins. pytest plugin with fixtures for testing consul aware apps :pypi:`pytest-container` - *last release*: Mar 21, 2023, + *last release*: Dec 04, 2024, *status*: 4 - Beta, - *requires*: pytest (>=3.10) + *requires*: pytest>=3.10 Pytest fixtures for writing container based tests @@ -2706,6 +3556,13 @@ This list contains 1267 plugins. A plugin to run tests written with the Contexts framework using pytest + :pypi:`pytest-continuous` + *last release*: Apr 23, 2024, + *status*: N/A, + *requires*: N/A + + A pytest plugin to run tests continuously until failure or interruption. + :pypi:`pytest-cookies` *last release*: Mar 22, 2023, *status*: 5 - Production/Stable, @@ -2713,6 +3570,20 @@ This list contains 1267 plugins. The pytest plugin for your Cookiecutter templates. 🍪 + :pypi:`pytest-copie` + *last release*: Apr 09, 2025, + *status*: 3 - Alpha, + *requires*: pytest + + The pytest plugin for your copier templates 📒 + + :pypi:`pytest-copier` + *last release*: Dec 11, 2023, + *status*: 4 - Beta, + *requires*: pytest>=7.3.2 + + A pytest plugin to help testing Copier templates + :pypi:`pytest-couchdbkit` *last release*: Apr 17, 2012, *status*: N/A, @@ -2728,9 +3599,9 @@ This list contains 1267 plugins. count erros and send email :pypi:`pytest-cov` - *last release*: May 24, 2023, + *last release*: Apr 05, 2025, *status*: 5 - Production/Stable, - *requires*: pytest (>=4.6) + *requires*: pytest>=4.6 Pytest plugin for measuring coverage. @@ -2749,16 +3620,16 @@ This list contains 1267 plugins. :pypi:`pytest-coverage-context` - *last release*: Jan 04, 2021, + *last release*: Jun 28, 2023, *status*: 4 - Beta, - *requires*: pytest (>=6.1.0) + *requires*: N/A Coverage dynamic context support for PyTest, including sub-processes :pypi:`pytest-coveragemarkers` - *last release*: Nov 29, 2022, + *last release*: May 15, 2025, *status*: N/A, - *requires*: pytest (>=7.1.2,<8.0.0) + *requires*: pytest<8.0.0,>=7.1.2 Using pytest markers to track functional coverage and filtering of tests @@ -2769,20 +3640,20 @@ This list contains 1267 plugins. Pytest plugin for excluding tests based on coverage data + :pypi:`pytest_covid` + *last release*: Jun 24, 2020, + *status*: N/A, + *requires*: N/A + + Too many faillure, less tests. + :pypi:`pytest-cpp` - *last release*: Jan 30, 2023, + *last release*: Sep 18, 2024, *status*: 5 - Production/Stable, - *requires*: pytest (>=7.0) + *requires*: pytest Use pytest's runner to discover and execute C++ tests - :pypi:`pytest-cppython` - *last release*: Jun 14, 2023, - *status*: N/A, - *requires*: N/A - - A pytest plugin that imports CPPython testing types - :pypi:`pytest-cqase` *last release*: Aug 22, 2022, *status*: N/A, @@ -2804,8 +3675,22 @@ This list contains 1267 plugins. Manages CrateDB instances during your integration tests + :pypi:`pytest-cratedb` + *last release*: Oct 08, 2024, + *status*: 4 - Beta, + *requires*: pytest<9 + + Manage CrateDB instances for integration tests + + :pypi:`pytest-cratedb-reporter` + *last release*: Mar 11, 2025, + *status*: N/A, + *requires*: pytest>=6.0.0 + + A pytest plugin for reporting test results to CrateDB + :pypi:`pytest-crayons` - *last release*: Mar 19, 2023, + *last release*: Oct 08, 2023, *status*: N/A, *requires*: pytest @@ -2840,17 +3725,24 @@ This list contains 1267 plugins. CSV output for pytest. :pypi:`pytest-csv-params` - *last release*: Aug 28, 2022, + *last release*: May 29, 2025, *status*: 5 - Production/Stable, - *requires*: pytest (>=7.1.2,<8.0.0) + *requires*: pytest<9,>=8.3 Pytest plugin for Test Case Parametrization with CSV files - :pypi:`pytest-curio` - *last release*: Oct 07, 2020, + :pypi:`pytest-culprit` + *last release*: May 15, 2025, *status*: N/A, *requires*: N/A + Find the last Git commit where a pytest test started failing + + :pypi:`pytest-curio` + *last release*: Oct 06, 2024, + *status*: N/A, + *requires*: pytest + Pytest support for curio. :pypi:`pytest-curl-report` @@ -2881,6 +3773,13 @@ This list contains 1267 plugins. Custom grouping for pytest-xdist, rename test cases name and test cases nodeid, support allure report + :pypi:`pytest-custom-outputs` + *last release*: Jul 10, 2024, + *status*: 4 - Beta, + *requires*: pytest>=6.2.0 + + A plugin that allows users to create and use custom outputs instead of the standard Pass and Fail. Also allows users to retrieve test results in fixtures. + :pypi:`pytest-custom-report` *last release*: Jan 30, 2019, *status*: N/A, @@ -2895,10 +3794,17 @@ This list contains 1267 plugins. Custom grouping for pytest-xdist, rename test cases name and test cases nodeid, support allure report + :pypi:`pytest-custom-timeout` + *last release*: Jan 08, 2025, + *status*: 4 - Beta, + *requires*: pytest>=8.0.0 + + Use custom logic when a test times out. Based on pytest-timeout. + :pypi:`pytest-cython` - *last release*: Feb 16, 2023, + *last release*: Apr 05, 2024, *status*: 5 - Production/Stable, - *requires*: pytest (>=4.6.0) + *requires*: pytest>=8 A plugin for testing Cython extension modules @@ -2910,9 +3816,9 @@ This list contains 1267 plugins. :pypi:`pytest-darker` - *last release*: Aug 16, 2020, + *last release*: Feb 25, 2024, *status*: N/A, - *requires*: pytest (>=6.0.1) ; extra == 'test' + *requires*: pytest <7,>=6.0.1 A pytest plugin for checking of modified code using Darker @@ -2923,6 +3829,13 @@ This list contains 1267 plugins. pytest fixtures to run dash applications. + :pypi:`pytest-dashboard` + *last release*: May 20, 2025, + *status*: N/A, + *requires*: pytest<8.0.0,>=7.4.3 + + + :pypi:`pytest-data` *last release*: Nov 01, 2016, *status*: 5 - Production/Stable, @@ -2930,6 +3843,13 @@ This list contains 1267 plugins. Useful functions for managing data for pytest fixtures + :pypi:`pytest-databases` + *last release*: May 25, 2025, + *status*: 4 - Beta, + *requires*: pytest + + Reusable database fixtures for any and all databases. + :pypi:`pytest-databricks` *last release*: Jul 29, 2020, *status*: N/A, @@ -2938,9 +3858,9 @@ This list contains 1267 plugins. Pytest plugin for remote Databricks notebooks testing :pypi:`pytest-datadir` - *last release*: Oct 25, 2022, + *last release*: May 30, 2025, *status*: 5 - Production/Stable, - *requires*: pytest (>=5.0) + *requires*: pytest>=7.0 pytest plugin for test data directories and files @@ -2987,11 +3907,11 @@ This list contains 1267 plugins. py.test plugin to create a 'tmp_path' containing predefined files/directories. :pypi:`pytest-datafixtures` - *last release*: Dec 05, 2020, + *last release*: May 15, 2025, *status*: 5 - Production/Stable, *requires*: N/A - Data fixtures for pytest made simple + Data fixtures for pytest made simple. :pypi:`pytest-data-from-files` *last release*: Oct 13, 2021, @@ -3008,23 +3928,23 @@ This list contains 1267 plugins. A pytest plugin for managing an archive of test data. :pypi:`pytest-datarecorder` - *last release*: Jan 08, 2023, + *last release*: Jul 31, 2024, *status*: 5 - Production/Stable, *requires*: pytest A py.test plugin recording and comparing test output. :pypi:`pytest-dataset` - *last release*: May 01, 2023, + *last release*: Sep 01, 2023, *status*: 5 - Production/Stable, *requires*: N/A Plugin for loading different datasets for pytest by prefix from json or yaml files :pypi:`pytest-data-suites` - *last release*: Jul 24, 2022, + *last release*: Apr 06, 2024, *status*: N/A, - *requires*: pytest (>=6.0,<8.0) + *requires*: pytest<9.0,>=6.0 Class-based pytest parametrization @@ -3036,9 +3956,9 @@ This list contains 1267 plugins. A pytest plugin for test driven data-wrangling (this is the development version of datatest's pytest integration). :pypi:`pytest-db` - *last release*: Dec 04, 2019, + *last release*: Aug 22, 2024, *status*: N/A, - *requires*: N/A + *requires*: pytest Session scope fixture "db" for mysql query or change @@ -3078,12 +3998,26 @@ This list contains 1267 plugins. A pytest plugin for linting a dbt project's conventions :pypi:`pytest-dbt-core` - *last release*: May 03, 2023, + *last release*: Jun 04, 2024, *status*: N/A, - *requires*: pytest (>=6.2.5) ; extra == 'test' + *requires*: pytest>=6.2.5; extra == "test" Pytest extension for dbt. + :pypi:`pytest-dbt-duckdb` + *last release*: Feb 09, 2025, + *status*: 4 - Beta, + *requires*: pytest>=8.3.4 + + Fearless testing for dbt models, powered by DuckDB. + + :pypi:`pytest-dbt-postgres` + *last release*: Sep 03, 2024, + *status*: N/A, + *requires*: pytest<9.0.0,>=8.3.2 + + Pytest tooling to unittest DBT & Postgres models + :pypi:`pytest-dbus-notification` *last release*: Mar 05, 2014, *status*: 5 - Production/Stable, @@ -3098,6 +4032,13 @@ This list contains 1267 plugins. Pytest plugin to run unit tests for dbx (Databricks CLI extensions) related code + :pypi:`pytest-dc` + *last release*: Aug 16, 2023, + *status*: 5 - Production/Stable, + *requires*: pytest >=3.3 + + Manages Docker containers during your integration tests + :pypi:`pytest-deadfixtures` *last release*: Jul 23, 2020, *status*: 5 - Production/Stable, @@ -3105,6 +4046,13 @@ This list contains 1267 plugins. A simple plugin to list unused fixtures in pytest + :pypi:`pytest-deduplicate` + *last release*: Aug 12, 2023, + *status*: 4 - Beta, + *requires*: pytest + + Identifies duplicate unit tests + :pypi:`pytest-deepcov` *last release*: Mar 30, 2021, *status*: N/A, @@ -3112,12 +4060,12 @@ This list contains 1267 plugins. deepcov - :pypi:`pytest-defer` - *last release*: Aug 24, 2021, + :pypi:`pytest_defer` + *last release*: Nov 13, 2024, *status*: N/A, - *requires*: N/A - + *requires*: pytest>=8.3 + A 'defer' fixture for pytest :pypi:`pytest-demo-plugin` *last release*: May 15, 2021, @@ -3127,7 +4075,7 @@ This list contains 1267 plugins. pytest示例插件 :pypi:`pytest-dependency` - *last release*: Feb 14, 2020, + *last release*: Dec 31, 2023, *status*: 4 - Beta, *requires*: N/A @@ -3147,10 +4095,17 @@ This list contains 1267 plugins. Mark tests as testing a deprecated feature with a warning note. + :pypi:`pytest-deprecator` + *last release*: Dec 02, 2024, + *status*: 4 - Beta, + *requires*: pytest>=6.2.0 + + A simple plugin to use with pytest + :pypi:`pytest-describe` - *last release*: Apr 09, 2023, + *last release*: Feb 10, 2024, *status*: 5 - Production/Stable, - *requires*: pytest (<8,>=4.6) + *requires*: pytest <9,>=4.6 Describe-style plugin for pytest @@ -3161,13 +4116,27 @@ This list contains 1267 plugins. plugin for rich text descriptions + :pypi:`pytest-deselect-if` + *last release*: Dec 26, 2024, + *status*: 4 - Beta, + *requires*: pytest>=6.2.0 + + A plugin to deselect pytests tests rather than using skipif + :pypi:`pytest-devpi-server` - *last release*: May 28, 2019, + *last release*: Oct 17, 2024, *status*: 5 - Production/Stable, *requires*: pytest DevPI server fixture for py.test + :pypi:`pytest-dfm` + *last release*: May 10, 2025, + *status*: N/A, + *requires*: pytest + + pytest-dfm provides a pytest integration for DV Flow Manager, a build system for silicon design + :pypi:`pytest-dhos` *last release*: Sep 07, 2022, *status*: N/A, @@ -3203,13 +4172,6 @@ This list contains 1267 plugins. A simple plugin to use with pytest - :pypi:`pytest-diffeo` - *last release*: Feb 10, 2023, - *status*: N/A, - *requires*: N/A - - A package to prevent Dependency Confusion attacks against Yandex. - :pypi:`pytest-diff-selector` *last release*: Feb 24, 2022, *status*: 4 - Beta, @@ -3224,6 +4186,20 @@ This list contains 1267 plugins. PyTest plugin for generating Difido reports + :pypi:`pytest-dir-equal` + *last release*: Dec 11, 2023, + *status*: 4 - Beta, + *requires*: pytest>=7.3.2 + + pytest-dir-equals is a pytest plugin providing helpers to assert directories equality allowing golden testing + + :pypi:`pytest-dirty` + *last release*: Jul 11, 2024, + *status*: 3 - Alpha, + *requires*: pytest>=8.2; extra == "dev" + + Static import analysis for thrifty testing. + :pypi:`pytest-disable` *last release*: Sep 10, 2015, *status*: 4 - Beta, @@ -3239,16 +4215,44 @@ This list contains 1267 plugins. Disable plugins per test :pypi:`pytest-discord` - *last release*: Feb 05, 2023, + *last release*: May 11, 2024, *status*: 4 - Beta, - *requires*: pytest (!=6.0.0,<8,>=3.3.2) + *requires*: pytest!=6.0.0,<9,>=3.3.2 A pytest plugin to notify test results to a Discord channel. + :pypi:`pytest-discover` + *last release*: Mar 26, 2024, + *status*: N/A, + *requires*: pytest + + Pytest plugin to record discovered tests in a file + + :pypi:`pytest-ditto` + *last release*: Jun 09, 2024, + *status*: 4 - Beta, + *requires*: pytest>=3.5.0 + + Snapshot testing pytest plugin with minimal ceremony and flexible persistence formats. + + :pypi:`pytest-ditto-pandas` + *last release*: May 29, 2024, + *status*: 4 - Beta, + *requires*: pytest>=3.5.0 + + pytest-ditto plugin for pandas snapshots. + + :pypi:`pytest-ditto-pyarrow` + *last release*: Jun 09, 2024, + *status*: 4 - Beta, + *requires*: pytest>=3.5.0 + + pytest-ditto plugin for pyarrow tables. + :pypi:`pytest-django` - *last release*: Dec 07, 2021, + *last release*: Apr 03, 2025, *status*: 5 - Production/Stable, - *requires*: pytest (>=5.4.0) + *requires*: pytest>=7.0.0 A Django plugin for pytest. @@ -3280,6 +4284,20 @@ This list contains 1267 plugins. Integrate CasperJS with your django tests as a pytest fixture. + :pypi:`pytest-django-class` + *last release*: Aug 08, 2023, + *status*: 4 - Beta, + *requires*: N/A + + A pytest plugin for running django in class-scoped fixtures + + :pypi:`pytest-django-docker-pg` + *last release*: Jun 13, 2024, + *status*: 5 - Production/Stable, + *requires*: pytest<9.0.0,>=7.0.0 + + + :pypi:`pytest-django-dotenv` *last release*: Nov 26, 2019, *status*: 4 - Beta, @@ -3316,7 +4334,7 @@ This list contains 1267 plugins. Cleanup your Haystack indexes between tests :pypi:`pytest-django-ifactory` - *last release*: Jun 06, 2023, + *last release*: Apr 30, 2025, *status*: 5 - Production/Stable, *requires*: N/A @@ -3330,7 +4348,7 @@ This list contains 1267 plugins. The bare minimum to integrate py.test with Django. :pypi:`pytest-django-liveserver-ssl` - *last release*: Jan 20, 2022, + *last release*: Jan 09, 2025, *status*: 3 - Alpha, *requires*: N/A @@ -3407,14 +4425,14 @@ This list contains 1267 plugins. An RST Documentation Generator for pytest-based test suites :pypi:`pytest-docker` - *last release*: Sep 14, 2022, + *last release*: May 26, 2025, *status*: N/A, - *requires*: pytest (<8.0,>=4.0) + *requires*: pytest<9.0,>=4.0 - Simple pytest fixtures for Docker and docker-compose based tests + Simple pytest fixtures for Docker and Docker Compose based tests :pypi:`pytest-docker-apache-fixtures` - *last release*: Feb 16, 2022, + *last release*: Aug 12, 2024, *status*: 4 - Beta, *requires*: pytest @@ -3441,6 +4459,13 @@ This list contains 1267 plugins. Manages Docker containers during your integration tests + :pypi:`pytest-docker-compose-v2` + *last release*: Dec 11, 2024, + *status*: 4 - Beta, + *requires*: pytest>=7.2.2 + + Manages Docker containers during your integration tests + :pypi:`pytest-docker-db` *last release*: Mar 20, 2021, *status*: 5 - Production/Stable, @@ -3449,21 +4474,21 @@ This list contains 1267 plugins. A plugin to use docker databases for pytests :pypi:`pytest-docker-fixtures` - *last release*: May 02, 2023, + *last release*: May 14, 2025, *status*: 3 - Alpha, *requires*: pytest pytest docker fixtures :pypi:`pytest-docker-git-fixtures` - *last release*: Feb 09, 2022, + *last release*: Aug 12, 2024, *status*: 4 - Beta, *requires*: pytest Pytest fixtures for testing with git scm. :pypi:`pytest-docker-haproxy-fixtures` - *last release*: Feb 09, 2022, + *last release*: Aug 12, 2024, *status*: 4 - Beta, *requires*: pytest @@ -3491,30 +4516,30 @@ This list contains 1267 plugins. Easy to use, simple to extend, pytest plugin that minimally leverages docker-py. :pypi:`pytest-docker-registry-fixtures` - *last release*: Apr 08, 2022, + *last release*: Aug 12, 2024, *status*: 4 - Beta, *requires*: pytest Pytest fixtures for testing with docker registries. :pypi:`pytest-docker-service` - *last release*: Feb 22, 2023, + *last release*: Jan 03, 2024, *status*: 3 - Alpha, *requires*: pytest (>=7.1.3) pytest plugin to start docker container :pypi:`pytest-docker-squid-fixtures` - *last release*: Feb 09, 2022, + *last release*: Aug 12, 2024, *status*: 4 - Beta, *requires*: pytest Pytest fixtures for testing with squid. :pypi:`pytest-docker-tools` - *last release*: Feb 17, 2022, + *last release*: Mar 16, 2025, *status*: 4 - Beta, - *requires*: pytest (>=6.0.1) + *requires*: pytest>=6.0.1 Docker integration tests for pytest @@ -3553,13 +4578,41 @@ This list contains 1267 plugins. A simple pytest plugin to import names and add them to the doctest namespace. + :pypi:`pytest-doctest-mkdocstrings` + *last release*: Mar 02, 2024, + *status*: N/A, + *requires*: pytest + + Run pytest --doctest-modules with markdown docstrings in code blocks (\`\`\`) + :pypi:`pytest-doctestplus` - *last release*: Jun 08, 2023, - *status*: 3 - Alpha, - *requires*: pytest (>=4.6) + *last release*: Jan 25, 2025, + *status*: 5 - Production/Stable, + *requires*: pytest>=4.6 Pytest plugin with advanced doctest features. + :pypi:`pytest-documentary` + *last release*: Jul 11, 2024, + *status*: N/A, + *requires*: pytest + + A simple pytest plugin to generate test documentation + + :pypi:`pytest-dogu-report` + *last release*: Jul 07, 2023, + *status*: N/A, + *requires*: N/A + + pytest plugin for dogu report + + :pypi:`pytest-dogu-sdk` + *last release*: Dec 14, 2023, + *status*: N/A, + *requires*: N/A + + pytest plugin for the Dogu + :pypi:`pytest-dolphin` *last release*: Nov 30, 2016, *status*: 4 - Beta, @@ -3567,6 +4620,13 @@ This list contains 1267 plugins. Some extra stuff that we use ininternally + :pypi:`pytest-donde` + *last release*: Oct 01, 2023, + *status*: 4 - Beta, + *requires*: pytest >=7.3.1 + + record pytest session characteristics per test item (coverage and duration) into a persistent file and use them in your own plugin or script. + :pypi:`pytest-doorstop` *last release*: Jun 09, 2020, *status*: 4 - Beta, @@ -3581,6 +4641,27 @@ This list contains 1267 plugins. A py.test plugin that parses environment files before running tests + :pypi:`pytest-dot-only-pkcopley` + *last release*: Oct 27, 2023, + *status*: N/A, + *requires*: N/A + + A Pytest marker for only running a single test + + :pypi:`pytest-dparam` + *last release*: Aug 27, 2024, + *status*: 6 - Mature, + *requires*: pytest + + A more readable alternative to @pytest.mark.parametrize. + + :pypi:`pytest-dpg` + *last release*: Aug 13, 2024, + *status*: N/A, + *requires*: N/A + + pytest-dpg is a pytest plugin for testing Dear PyGui (DPG) applications + :pypi:`pytest-draw` *last release*: Mar 21, 2023, *status*: 3 - Alpha, @@ -3603,18 +4684,39 @@ This list contains 1267 plugins. Tool to allow webdriver automation to be ran locally or remotely :pypi:`pytest-drop-dup-tests` - *last release*: May 23, 2020, - *status*: 4 - Beta, - *requires*: pytest (>=2.7) + *last release*: Mar 04, 2024, + *status*: 5 - Production/Stable, + *requires*: pytest >=7 A Pytest plugin to drop duplicated tests during collection - :pypi:`pytest-dummynet` - *last release*: Dec 15, 2021, + :pypi:`pytest-dryci` + *last release*: Sep 27, 2024, + *status*: 4 - Beta, + *requires*: N/A + + Test caching plugin for pytest + + :pypi:`pytest-dryrun` + *last release*: Jan 19, 2025, *status*: 5 - Production/Stable, - *requires*: pytest + *requires*: pytest<9,>=7.40 - A py.test plugin providing access to a dummynet. + A Pytest plugin to ignore tests during collection without reporting them in the test summary. + + :pypi:`pytest-dsl` + *last release*: May 29, 2025, + *status*: N/A, + *requires*: pytest>=7.0.0 + + A DSL testing framework based on pytest + + :pypi:`pytest-dummynet` + *last release*: Dec 15, 2021, + *status*: 5 - Production/Stable, + *requires*: pytest + + A py.test plugin providing access to a dummynet. :pypi:`pytest-dump2json` *last release*: Jun 29, 2015, @@ -3624,16 +4726,16 @@ This list contains 1267 plugins. A pytest plugin for dumping test results to json. :pypi:`pytest-duration-insights` - *last release*: Jun 25, 2021, + *last release*: Jul 15, 2024, *status*: N/A, *requires*: N/A :pypi:`pytest-durations` - *last release*: Apr 22, 2022, + *last release*: Apr 29, 2025, *status*: 5 - Production/Stable, - *requires*: pytest (>=4.6) + *requires*: pytest>=4.6 Pytest plugin reporting fixtures and test functions execution time. @@ -3645,7 +4747,7 @@ This list contains 1267 plugins. A pytest plugin to rerun tests dynamically based off of test outcome and output. :pypi:`pytest-dynamodb` - *last release*: Jun 12, 2023, + *last release*: Apr 04, 2025, *status*: 5 - Production/Stable, *requires*: pytest @@ -3658,13 +4760,6 @@ This list contains 1267 plugins. pytest-easy-addoption: Easy way to work with pytest addoption - :pypi:`pytest-easy-api` - *last release*: Mar 26, 2018, - *status*: N/A, - *requires*: N/A - - Simple API testing with pytest - :pypi:`pytest-easyMPI` *last release*: Oct 21, 2020, *status*: N/A, @@ -3701,11 +4796,18 @@ This list contains 1267 plugins. Pytest execution on EC2 instance :pypi:`pytest-echo` - *last release*: Jan 08, 2020, + *last release*: Apr 27, 2025, *status*: 5 - Production/Stable, - *requires*: N/A + *requires*: pytest>=8.3.3 + + pytest plugin that allows to dump environment variables, package version and generic attributes - pytest plugin with mechanisms for echoing environment variables, package version and generic attributes + :pypi:`pytest-edit` + *last release*: Nov 17, 2024, + *status*: N/A, + *requires*: pytest + + Edit the source code of a failed test with \`pytest --edit\`. :pypi:`pytest-ekstazi` *last release*: Sep 10, 2022, @@ -3715,12 +4817,26 @@ This list contains 1267 plugins. Pytest plugin to select test using Ekstazi algorithm :pypi:`pytest-elasticsearch` - *last release*: Mar 01, 2022, + *last release*: Dec 03, 2024, *status*: 5 - Production/Stable, - *requires*: pytest (>=6.2.0) + *requires*: pytest>=7.0 + + Elasticsearch fixtures and fixture factories for Pytest. + + :pypi:`pytest-elasticsearch-test` + *last release*: Apr 20, 2025, + *status*: 5 - Production/Stable, + *requires*: pytest>=7.0 Elasticsearch fixtures and fixture factories for Pytest. + :pypi:`pytest-elbase` + *last release*: Apr 15, 2025, + *status*: N/A, + *requires*: N/A + + Elbase pytest plugin + :pypi:`pytest-elements` *last release*: Jan 13, 2021, *status*: N/A, @@ -3736,9 +4852,9 @@ This list contains 1267 plugins. An eliot plugin for pytest. :pypi:`pytest-elk-reporter` - *last release*: Jan 24, 2021, + *last release*: Jul 25, 2024, *status*: 4 - Beta, - *requires*: pytest (>=3.5.0) + *requires*: pytest>=3.5.0 A simple plugin to use with pytest @@ -3750,54 +4866,68 @@ This list contains 1267 plugins. Send execution result email :pypi:`pytest-embedded` - *last release*: Jun 14, 2023, + *last release*: Apr 22, 2025, *status*: 5 - Production/Stable, *requires*: pytest>=7.0 A pytest plugin that designed for embedded testing. :pypi:`pytest-embedded-arduino` - *last release*: Jun 14, 2023, + *last release*: Apr 22, 2025, *status*: 5 - Production/Stable, *requires*: N/A Make pytest-embedded plugin work with Arduino. :pypi:`pytest-embedded-idf` - *last release*: Jun 14, 2023, + *last release*: Apr 22, 2025, *status*: 5 - Production/Stable, *requires*: N/A Make pytest-embedded plugin work with ESP-IDF. :pypi:`pytest-embedded-jtag` - *last release*: Jun 14, 2023, + *last release*: Apr 22, 2025, *status*: 5 - Production/Stable, *requires*: N/A Make pytest-embedded plugin work with JTAG. + :pypi:`pytest-embedded-nuttx` + *last release*: Apr 22, 2025, + *status*: 5 - Production/Stable, + *requires*: N/A + + Make pytest-embedded plugin work with NuttX. + :pypi:`pytest-embedded-qemu` - *last release*: Jun 14, 2023, + *last release*: Apr 22, 2025, *status*: 5 - Production/Stable, *requires*: N/A Make pytest-embedded plugin work with QEMU. :pypi:`pytest-embedded-serial` - *last release*: Jun 14, 2023, + *last release*: Apr 22, 2025, *status*: 5 - Production/Stable, *requires*: N/A Make pytest-embedded plugin work with Serial. :pypi:`pytest-embedded-serial-esp` - *last release*: Jun 14, 2023, + *last release*: Apr 22, 2025, *status*: 5 - Production/Stable, *requires*: N/A Make pytest-embedded plugin work with Espressif target boards. + :pypi:`pytest-embedded-wokwi` + *last release*: Apr 22, 2025, + *status*: 5 - Production/Stable, + *requires*: N/A + + Make pytest-embedded plugin work with the Wokwi CLI. + :pypi:`pytest-embrace` *last release*: Mar 25, 2023, *status*: N/A, @@ -3820,9 +4950,9 @@ This list contains 1267 plugins. Pytest plugin to represent test output with emoji support :pypi:`pytest-enabler` - *last release*: May 12, 2023, + *last release*: May 16, 2025, *status*: 5 - Production/Stable, - *requires*: pytest (>=6) ; extra == 'testing' + *requires*: pytest!=8.1.*,>=6; extra == "test" Enable installed pytest plugins @@ -3840,6 +4970,20 @@ This list contains 1267 plugins. set your encoding and logger + :pypi:`pytest-encoding` + *last release*: Aug 11, 2023, + *status*: N/A, + *requires*: pytest + + set your encoding and logger + + :pypi:`pytest_energy_reporter` + *last release*: Mar 28, 2024, + *status*: 3 - Alpha, + *requires*: pytest<9.0.0,>=8.1.1 + + An energy estimation reporter for pytest + :pypi:`pytest-enhanced-reports` *last release*: Dec 15, 2022, *status*: N/A, @@ -3855,11 +4999,11 @@ This list contains 1267 plugins. Improvements for pytest (rejected upstream) :pypi:`pytest-env` - *last release*: Jun 15, 2023, + *last release*: Sep 17, 2024, *status*: 5 - Production/Stable, - *requires*: pytest>=7.3.1 + *requires*: pytest>=8.3.3 - py.test plugin that allows you to add environment variables. + pytest plugin that allows you to add environment variables. :pypi:`pytest-envfiles` *last release*: Oct 08, 2015, @@ -3875,6 +5019,13 @@ This list contains 1267 plugins. Push information about the running pytest into envvars + :pypi:`pytest-environment` + *last release*: Mar 17, 2024, + *status*: 1 - Planning, + *requires*: N/A + + Pytest Environment + :pypi:`pytest-envraw` *last release*: Aug 27, 2020, *status*: 4 - Beta, @@ -3903,6 +5054,13 @@ This list contains 1267 plugins. pytest plugin to check for commented out code + :pypi:`pytest_erp` + *last release*: Jan 13, 2015, + *status*: N/A, + *requires*: N/A + + py.test plugin to send test info to report portal dynamically + :pypi:`pytest-error-for-skips` *last release*: Dec 19, 2019, *status*: 4 - Beta, @@ -3910,6 +5068,20 @@ This list contains 1267 plugins. Pytest plugin to treat skipped tests a test failure + :pypi:`pytest-errxfail` + *last release*: Jan 06, 2025, + *status*: 4 - Beta, + *requires*: pytest>=6.2.0 + + pytest plugin to mark a test as xfailed if it fails with the specified error message in the captured output + + :pypi:`pytest-essentials` + *last release*: May 19, 2025, + *status*: 3 - Alpha, + *requires*: pytest>=7.0 + + A Pytest plugin providing essential utilities like soft assertions. + :pypi:`pytest-eth` *last release*: Aug 14, 2020, *status*: 1 - Planning, @@ -3931,6 +5103,13 @@ This list contains 1267 plugins. Pytest Plugin for BDD + :pypi:`pytest-evals` + *last release*: Feb 02, 2025, + *status*: N/A, + *requires*: pytest>=7.0.0 + + A pytest plugin for running and analyzing LLM evaluation tests + :pypi:`pytest-eventlet` *last release*: Oct 04, 2021, *status*: N/A, @@ -3938,17 +5117,73 @@ This list contains 1267 plugins. Applies eventlet monkey-patch as a pytest plugin. - :pypi:`pytest-examples` - *last release*: May 05, 2023, + :pypi:`pytest-everyfunc` + *last release*: Apr 30, 2025, *status*: 4 - Beta, + *requires*: pytest + + A pytest plugin to detect completely untested functions using coverage + + :pypi:`pytest_evm` + *last release*: Sep 23, 2024, + *status*: 4 - Beta, + *requires*: pytest<9.0.0,>=8.1.1 + + The testing package containing tools to test Web3-based projects + + :pypi:`pytest_exact_fixtures` + *last release*: Feb 04, 2019, + *status*: N/A, + *requires*: N/A + + Parse queries in Lucene and Elasticsearch syntaxes + + :pypi:`pytest-examples` + *last release*: May 06, 2025, + *status*: N/A, *requires*: pytest>=7 Pytest plugin for testing examples in docstrings and markdown files. + :pypi:`pytest-exasol-backend` + *last release*: Feb 11, 2025, + *status*: N/A, + *requires*: pytest<9,>=7 + + + + :pypi:`pytest-exasol-extension` + *last release*: Feb 11, 2025, + *status*: N/A, + *requires*: pytest<9,>=7 + + + + :pypi:`pytest-exasol-itde` + *last release*: Nov 22, 2024, + *status*: N/A, + *requires*: pytest<9,>=7 + + + + :pypi:`pytest-exasol-saas` + *last release*: Nov 22, 2024, + *status*: N/A, + *requires*: pytest<9,>=7 + + + + :pypi:`pytest-exasol-slc` + *last release*: Feb 11, 2025, + *status*: N/A, + *requires*: pytest<9,>=7 + + + :pypi:`pytest-excel` - *last release*: Jan 31, 2022, + *last release*: Jun 18, 2024, *status*: 5 - Production/Stable, - *requires*: N/A + *requires*: pytest>3.6 pytest plugin for generating excel reports @@ -3967,9 +5202,9 @@ This list contains 1267 plugins. Walk your code through exception script to check it's resiliency to failures. :pypi:`pytest-executable` - *last release*: Mar 25, 2023, + *last release*: Oct 07, 2023, *status*: N/A, - *requires*: pytest (<8,>=4.3) + *requires*: pytest <8,>=5 pytest plugin for testing executables @@ -3980,6 +5215,20 @@ This list contains 1267 plugins. A timer for the phases of Pytest's execution. + :pypi:`pytest-exit-code` + *last release*: May 06, 2024, + *status*: 4 - Beta, + *requires*: pytest>=6.2.0 + + A pytest plugin that overrides the built-in exit codes to retain more information about the test results. + + :pypi:`pytest-exit-status` + *last release*: Jan 25, 2025, + *status*: N/A, + *requires*: pytest>=8.0.0 + + Enhance. + :pypi:`pytest-expect` *last release*: Apr 21, 2016, *status*: 4 - Beta, @@ -3994,6 +5243,13 @@ This list contains 1267 plugins. A pytest plugin to provide initial/expected directories, and check a test transforms the initial directory to the expected one + :pypi:`pytest-expected` + *last release*: Feb 26, 2025, + *status*: N/A, + *requires*: pytest + + Record and play back your expectations + :pypi:`pytest-expecter` *last release*: Sep 18, 2022, *status*: 5 - Production/Stable, @@ -4030,12 +5286,33 @@ This list contains 1267 plugins. A Pytest plugin to ignore certain marked tests by default :pypi:`pytest-exploratory` - *last release*: Feb 21, 2022, + *last release*: Sep 18, 2024, *status*: N/A, - *requires*: pytest (>=6.2) + *requires*: pytest>=6.2 Interactive console for pytest. + :pypi:`pytest-explorer` + *last release*: Aug 01, 2023, + *status*: N/A, + *requires*: N/A + + terminal ui for exploring and running tests + + :pypi:`pytest-ext` + *last release*: Mar 31, 2024, + *status*: N/A, + *requires*: pytest>=5.3 + + pytest plugin for automation test + + :pypi:`pytest-extended-mock` + *last release*: Mar 12, 2025, + *status*: N/A, + *requires*: pytest<9.0.0,>=8.3.5 + + a pytest extension for easy mock setup + :pypi:`pytest-extensions` *last release*: Aug 17, 2022, *status*: 4 - Beta, @@ -4050,6 +5327,13 @@ This list contains 1267 plugins. a special outcome for tests that are blocked for external reasons + :pypi:`pytest_extra` + *last release*: Aug 14, 2014, + *status*: N/A, + *requires*: N/A + + Some helpers for writing tests with pytest. + :pypi:`pytest-extra-durations` *last release*: Apr 21, 2020, *status*: 4 - Beta, @@ -4064,6 +5348,13 @@ This list contains 1267 plugins. Additional pytest markers to dynamically enable/disable tests viia CLI flags + :pypi:`pytest-f3ts` + *last release*: May 08, 2025, + *status*: N/A, + *requires*: pytest<8.0.0,>=7.2.1 + + Pytest Plugin for communicating test results and information to a FixturFab Test Runner GUI + :pypi:`pytest-fabric` *last release*: Sep 12, 2018, *status*: 5 - Production/Stable, @@ -4071,13 +5362,6 @@ This list contains 1267 plugins. Provides test utilities to run fabric task tests by using docker containers - :pypi:`pytest-factor` - *last release*: Feb 10, 2023, - *status*: N/A, - *requires*: N/A - - A package to prevent Dependency Confusion attacks against Yandex. - :pypi:`pytest-factory` *last release*: Sep 06, 2020, *status*: 3 - Alpha, @@ -4086,9 +5370,9 @@ This list contains 1267 plugins. Use factories for test setup with py.test :pypi:`pytest-factoryboy` - *last release*: Dec 01, 2022, + *last release*: Mar 05, 2024, *status*: 6 - Mature, - *requires*: pytest (>=5.0.0) + *requires*: pytest (>=6.2) Factory Boy support for pytest. @@ -4128,12 +5412,19 @@ This list contains 1267 plugins. A pytest plugin that helps better distinguishing real test failures from setup flakiness. :pypi:`pytest-fail-slow` - *last release*: Aug 13, 2022, - *status*: 4 - Beta, - *requires*: pytest (>=6.0) + *last release*: Jun 01, 2024, + *status*: N/A, + *requires*: pytest>=7.0 Fail tests that take too long to run + :pypi:`pytest-failure-tracker` + *last release*: Jul 17, 2024, + *status*: N/A, + *requires*: pytest>=6.0.0 + + A pytest plugin for tracking test failures over multiple runs + :pypi:`pytest-faker` *last release*: Dec 19, 2016, *status*: 6 - Mature, @@ -4148,13 +5439,6 @@ This list contains 1267 plugins. Pytest helpers for Falcon. - :pypi:`pytest-falcon-client` - *last release*: Mar 19, 2019, - *status*: N/A, - *requires*: N/A - - Pytest \`client\` fixture for the Falcon Framework - :pypi:`pytest-fantasy` *last release*: Mar 14, 2019, *status*: N/A, @@ -4177,7 +5461,7 @@ This list contains 1267 plugins. A fixture which allows easy replacement of fastapi dependencies for testing :pypi:`pytest-fastest` - *last release*: Jun 15, 2023, + *last release*: Oct 04, 2023, *status*: 4 - Beta, *requires*: pytest (>=4.4) @@ -4197,6 +5481,13 @@ This list contains 1267 plugins. py.test plugin that activates the fault handler module for tests (dummy package) + :pypi:`pytest-fauna` + *last release*: Jan 03, 2025, + *status*: N/A, + *requires*: N/A + + A collection of helpful test fixtures for Fauna DB. + :pypi:`pytest-fauxfactory` *last release*: Dec 06, 2017, *status*: 5 - Production/Stable, @@ -4211,6 +5502,13 @@ This list contains 1267 plugins. py.test figleaf coverage plugin + :pypi:`pytest-file` + *last release*: Mar 18, 2024, + *status*: 1 - Planning, + *requires*: N/A + + Pytest File + :pypi:`pytest-filecov` *last release*: Jun 27, 2021, *status*: 4 - Beta, @@ -4219,11 +5517,11 @@ This list contains 1267 plugins. A pytest plugin to detect unused files :pypi:`pytest-filedata` - *last release*: Jan 17, 2019, - *status*: 4 - Beta, + *last release*: Apr 29, 2024, + *status*: 5 - Production/Stable, *requires*: N/A - easily load data from files + easily load test data from files :pypi:`pytest-filemarker` *last release*: Dec 01, 2020, @@ -4247,16 +5545,16 @@ This list contains 1267 plugins. run test cases filter by mark :pypi:`pytest-filter-subpackage` - *last release*: Dec 12, 2022, - *status*: 3 - Alpha, - *requires*: pytest (>=3.0) + *last release*: Mar 04, 2024, + *status*: 5 - Production/Stable, + *requires*: pytest >=4.6 Pytest plugin for filtering based on sub-packages :pypi:`pytest-find-dependencies` - *last release*: Apr 09, 2022, + *last release*: Mar 16, 2024, *status*: 4 - Beta, - *requires*: pytest (>=4.3.0) + *requires*: pytest >=4.3.0 A pytest plugin to find dependencies between tests @@ -4268,26 +5566,47 @@ This list contains 1267 plugins. A pytest plugin to treat non-assertion failures as test errors. :pypi:`pytest-firefox` - *last release*: Aug 08, 2017, + *last release*: Feb 28, 2025, + *status*: N/A, + *requires*: N/A + + + + :pypi:`pytest-fixturecheck` + *last release*: May 17, 2025, *status*: 3 - Alpha, - *requires*: pytest (>=3.0.2) + *requires*: pytest>=6.0.0 - pytest plugin to manipulate firefox + A pytest plugin to check fixture validity before test execution :pypi:`pytest-fixture-classes` - *last release*: Jan 20, 2023, - *status*: 4 - Beta, + *last release*: Sep 02, 2023, + *status*: 5 - Production/Stable, *requires*: pytest Fixtures as classes that work well with dependency injection, autocompletetion, type checkers, and language servers + :pypi:`pytest-fixturecollection` + *last release*: Feb 22, 2024, + *status*: 4 - Beta, + *requires*: pytest >=3.5.0 + + A pytest plugin to collect tests based on fixtures being used by tests + :pypi:`pytest-fixture-config` - *last release*: May 28, 2019, + *last release*: Oct 17, 2024, *status*: 5 - Production/Stable, *requires*: pytest Fixture configuration utils for py.test + :pypi:`pytest-fixture-forms` + *last release*: Dec 06, 2024, + *status*: N/A, + *requires*: pytest<9.0.0,>=7.0.0 + + A pytest plugin for creating fixtures that holds different forms between tests. + :pypi:`pytest-fixture-maker` *last release*: Sep 21, 2021, *status*: N/A, @@ -4316,6 +5635,13 @@ This list contains 1267 plugins. Lets users reference fixtures without name matching magic. + :pypi:`pytest-fixture-remover` + *last release*: Feb 14, 2024, + *status*: 5 - Production/Stable, + *requires*: N/A + + A LibCST codemod to remove pytest fixtures applied via the usefixtures decorator, as well as its parametrizations. + :pypi:`pytest-fixture-rtttg` *last release*: Feb 23, 2022, *status*: N/A, @@ -4331,7 +5657,7 @@ This list contains 1267 plugins. Common fixtures for pytest :pypi:`pytest-fixture-tools` - *last release*: Aug 18, 2020, + *last release*: Apr 30, 2025, *status*: 6 - Mature, *requires*: pytest @@ -4345,14 +5671,14 @@ This list contains 1267 plugins. A pytest plugin to assert type annotations at runtime. :pypi:`pytest-flake8` - *last release*: Mar 18, 2022, - *status*: 4 - Beta, - *requires*: pytest (>=7.0) + *last release*: Nov 09, 2024, + *status*: 5 - Production/Stable, + *requires*: pytest>=7.0 pytest plugin to check FLAKE8 requirements :pypi:`pytest-flake8-path` - *last release*: Jun 16, 2023, + *last release*: Oct 25, 2024, *status*: 5 - Production/Stable, *requires*: pytest @@ -4365,6 +5691,13 @@ This list contains 1267 plugins. pytest plugin to check FLAKE8 requirements + :pypi:`pytest-flake-detection` + *last release*: Nov 29, 2024, + *status*: 4 - Beta, + *requires*: pytest>=6.2.0 + + Continuously runs your tests to detect flaky tests + :pypi:`pytest-flakefinder` *last release*: Oct 26, 2022, *status*: 4 - Beta, @@ -4387,9 +5720,9 @@ This list contains 1267 plugins. Flaptastic py.test plugin :pypi:`pytest-flask` - *last release*: Feb 27, 2021, + *last release*: Oct 23, 2023, *status*: 5 - Production/Stable, - *requires*: pytest (>=5.2) + *requires*: pytest >=5.2 A set of py.test fixtures to test Flask applications. @@ -4422,12 +5755,26 @@ This list contains 1267 plugins. :pypi:`pytest-fluent` - *last release*: Jul 12, 2022, + *last release*: Aug 14, 2024, *status*: 4 - Beta, - *requires*: pytest + *requires*: pytest>=7.0.0 A pytest plugin in order to provide logs via fluentd + :pypi:`pytest-fluentbit` + *last release*: Jun 16, 2023, + *status*: 4 - Beta, + *requires*: pytest (>=7.0.0) + + A pytest plugin in order to provide logs via fluentbit + + :pypi:`pytest-fly` + *last release*: May 19, 2025, + *status*: 3 - Alpha, + *requires*: pytest + + pytest runner and observer + :pypi:`pytest-flyte` *last release*: May 03, 2021, *status*: N/A, @@ -4471,12 +5818,26 @@ This list contains 1267 plugins. A pytest plugin to shim pytest commandline options for fowards compatibility :pypi:`pytest-frappe` - *last release*: May 03, 2023, + *last release*: Jul 30, 2024, *status*: 4 - Beta, *requires*: pytest>=7.0.0 Pytest Frappe Plugin - A set of pytest fixtures to test Frappe applications + :pypi:`pytest-freethreaded` + *last release*: Oct 03, 2024, + *status*: 5 - Production/Stable, + *requires*: pytest + + pytest plugin for running parallel tests + + :pypi:`pytest-freezeblaster` + *last release*: Feb 11, 2025, + *status*: N/A, + *requires*: pytest>=6.2.5 + + Wrap tests with fixtures in freeze_time + :pypi:`pytest-freezegun` *last release*: Jul 19, 2020, *status*: 4 - Beta, @@ -4485,7 +5846,7 @@ This list contains 1267 plugins. Wrap tests with fixtures in freeze_time :pypi:`pytest-freezer` - *last release*: Jun 17, 2023, + *last release*: Dec 12, 2024, *status*: N/A, *requires*: pytest>=3.6 @@ -4512,6 +5873,13 @@ This list contains 1267 plugins. Pytest plugin for measuring function coverage + :pypi:`pytest-funcnodes` + *last release*: Mar 19, 2025, + *status*: 4 - Beta, + *requires*: pytest>=6.2.0 + + Testing plugin for funcnodes + :pypi:`pytest-funparam` *last release*: Dec 02, 2021, *status*: 4 - Beta, @@ -4519,6 +5887,13 @@ This list contains 1267 plugins. An alternative way to parametrize test cases. + :pypi:`pytest-fv` + *last release*: Feb 27, 2025, + *status*: N/A, + *requires*: pytest + + pytest extensions to support running functional-verification jobs + :pypi:`pytest-fxa` *last release*: Aug 28, 2018, *status*: 5 - Production/Stable, @@ -4526,6 +5901,13 @@ This list contains 1267 plugins. pytest plugin for Firefox Accounts + :pypi:`pytest-fxa-mte` + *last release*: Oct 02, 2024, + *status*: 5 - Production/Stable, + *requires*: N/A + + pytest plugin for Firefox Accounts + :pypi:`pytest-fxtest` *last release*: Oct 27, 2020, *status*: N/A, @@ -4534,16 +5916,30 @@ This list contains 1267 plugins. :pypi:`pytest-fzf` - *last release*: Aug 17, 2022, - *status*: 1 - Planning, - *requires*: pytest (>=7.1.2) + *last release*: Jan 06, 2025, + *status*: 4 - Beta, + *requires*: pytest>=6.0.0 fzf-based test selector for pytest + :pypi:`pytest_gae` + *last release*: Aug 03, 2016, + *status*: 3 - Alpha, + *requires*: N/A + + pytest plugin for apps written with Google's AppEngine + + :pypi:`pytest-gak` + *last release*: Apr 10, 2025, + *status*: N/A, + *requires*: N/A + + A Pytest plugin and command line tool for interactive testing with Pytest + :pypi:`pytest-gather-fixtures` - *last release*: Apr 12, 2022, + *last release*: Aug 18, 2024, *status*: N/A, - *requires*: pytest (>=6.0.0) + *requires*: pytest>=7.0.0 set up asynchronous pytest fixtures concurrently @@ -4561,6 +5957,20 @@ This list contains 1267 plugins. Uses gcov to measure test coverage of a C library + :pypi:`pytest-gcs` + *last release*: Jan 24, 2025, + *status*: 5 - Production/Stable, + *requires*: pytest>=6.2 + + GCS fixtures and fixture factories for Pytest. + + :pypi:`pytest-gee` + *last release*: May 11, 2025, + *status*: 3 - Alpha, + *requires*: pytest + + The Python plugin for your GEE based packages. + :pypi:`pytest-gevent` *last release*: Feb 25, 2020, *status*: N/A, @@ -4590,19 +6000,26 @@ This list contains 1267 plugins. For finding/executing Ghost Inspector tests :pypi:`pytest-girder` - *last release*: Jun 14, 2023, + *last release*: May 29, 2025, *status*: N/A, - *requires*: N/A + *requires*: pytest>=3.6 A set of pytest fixtures for testing Girder applications. :pypi:`pytest-git` - *last release*: May 28, 2019, + *last release*: Oct 17, 2024, *status*: 5 - Production/Stable, *requires*: pytest Git repository fixture for py.test + :pypi:`pytest-gitconfig` + *last release*: Aug 11, 2024, + *status*: 4 - Beta, + *requires*: pytest>=7.1.2 + + Provide a Git config sandbox for testing + :pypi:`pytest-gitcov` *last release*: Jan 11, 2020, *status*: 2 - Pre-Alpha, @@ -4610,6 +6027,13 @@ This list contains 1267 plugins. Pytest plugin for reporting on coverage of the last git commit. + :pypi:`pytest-git-diff` + *last release*: Apr 02, 2024, + *status*: N/A, + *requires*: N/A + + Pytest plugin that allows the user to select the tests affected by a range of git commits + :pypi:`pytest-git-fixtures` *last release*: Mar 11, 2021, *status*: 4 - Beta, @@ -4625,9 +6049,9 @@ This list contains 1267 plugins. Plugin for py.test that associates tests with github issues using a marker. :pypi:`pytest-github-actions-annotate-failures` - *last release*: May 04, 2023, + *last release*: Jan 17, 2025, *status*: 5 - Production/Stable, - *requires*: pytest (>=4.0.0) + *requires*: pytest>=6.0.0 pytest plugin to annotate failed tests with a workflow command for GitHub Actions @@ -4645,6 +6069,13 @@ This list contains 1267 plugins. py.test plugin to ignore the same files as git + :pypi:`pytest-gitlab` + *last release*: Oct 16, 2024, + *status*: N/A, + *requires*: N/A + + Pytest Plugin for Gitlab + :pypi:`pytest-gitlabci-parallelized` *last release*: Mar 08, 2023, *status*: N/A, @@ -4652,6 +6083,20 @@ This list contains 1267 plugins. Parallelize pytest across GitLab CI workers. + :pypi:`pytest-gitlab-code-quality` + *last release*: Sep 09, 2024, + *status*: N/A, + *requires*: pytest>=8.1.1 + + Collects warnings while testing and generates a GitLab Code Quality Report. + + :pypi:`pytest-gitlab-fold` + *last release*: Dec 31, 2023, + *status*: 4 - Beta, + *requires*: pytest >=2.6.0 + + Folds output sections in GitLab CI build log + :pypi:`pytest-git-selector` *last release*: Nov 17, 2022, *status*: N/A, @@ -4660,9 +6105,9 @@ This list contains 1267 plugins. Utility to select tests that have had its dependencies modified (as identified by git diff) :pypi:`pytest-glamor-allure` - *last release*: Jul 22, 2022, + *last release*: Apr 30, 2024, *status*: 4 - Beta, - *requires*: pytest + *requires*: pytest<=8.2.0 Extends allure-pytest functionality @@ -4694,6 +6139,20 @@ This list contains 1267 plugins. Notify google chat channel for test results + :pypi:`pytest-google-cloud-storage` + *last release*: May 22, 2025, + *status*: N/A, + *requires*: pytest==8.3.5 + + Pytest custom features, e.g. fixtures and various tests. Aimed to emulate Google Cloud Storage service + + :pypi:`pytest-gradescope` + *last release*: Apr 29, 2025, + *status*: N/A, + *requires*: N/A + + A pytest plugin for Gradescope integration + :pypi:`pytest-graphql-schema` *last release*: Oct 18, 2019, *status*: N/A, @@ -4708,6 +6167,13 @@ This list contains 1267 plugins. Green progress dots + :pypi:`pytest-group-by-class` + *last release*: Jun 27, 2023, + *status*: 5 - Production/Stable, + *requires*: pytest (>=2.5) + + A Pytest plugin for running a subset of your tests by splitting them in to groups of classes. + :pypi:`pytest-growl` *last release*: Jan 13, 2014, *status*: 5 - Production/Stable, @@ -4723,12 +6189,19 @@ This list contains 1267 plugins. pytest plugin for grpc :pypi:`pytest-grunnur` - *last release*: Feb 05, 2023, + *last release*: Jul 26, 2024, *status*: N/A, - *requires*: N/A + *requires*: pytest>=6 Py.Test plugin for Grunnur-based packages. + :pypi:`pytest_gui_status` + *last release*: Jan 23, 2016, + *status*: N/A, + *requires*: pytest + + Show pytest status in gui + :pypi:`pytest-hammertime` *last release*: Jul 28, 2018, *status*: N/A, @@ -4736,6 +6209,13 @@ This list contains 1267 plugins. Display "🔨 " instead of "." for passed pytest tests. + :pypi:`pytest-hardware-test-report` + *last release*: Apr 01, 2024, + *status*: 4 - Beta, + *requires*: pytest<9.0.0,>=8.0.0 + + A simple plugin to use with pytest + :pypi:`pytest-harmony` *last release*: Jan 17, 2023, *status*: N/A, @@ -4744,25 +6224,25 @@ This list contains 1267 plugins. Chain tests and data with pytest :pypi:`pytest-harvest` - *last release*: Jun 10, 2022, + *last release*: Mar 16, 2024, *status*: 5 - Production/Stable, *requires*: N/A Store data created during your pytest tests execution, and retrieve it at the end of the session, e.g. for applicative benchmarking purposes. - :pypi:`pytest-helm-chart` - *last release*: Jun 15, 2020, + :pypi:`pytest-helm-charts` + *last release*: Oct 31, 2024, *status*: 4 - Beta, - *requires*: pytest (>=5.4.2,<6.0.0) + *requires*: pytest<9.0.0,>=8.0.0 A plugin to provide different types and configs of Kubernetes clusters that can be used for testing. - :pypi:`pytest-helm-charts` - *last release*: Mar 08, 2023, - *status*: 4 - Beta, - *requires*: pytest (>=7.1.2,<8.0.0) + :pypi:`pytest-helm-templates` + *last release*: Aug 07, 2024, + *status*: N/A, + *requires*: pytest~=7.4.0; extra == "dev" - A plugin to provide different types and configs of Kubernetes clusters that can be used for testing. + Pytest fixtures for unit testing the output of helm templates :pypi:`pytest-helper` *last release*: May 31, 2019, @@ -4785,6 +6265,13 @@ This list contains 1267 plugins. Pytest Helpers Namespace Plugin + :pypi:`pytest-henry` + *last release*: Aug 29, 2023, + *status*: N/A, + *requires*: N/A + + + :pypi:`pytest-hidecaptured` *last release*: May 04, 2018, *status*: 4 - Beta, @@ -4792,6 +6279,13 @@ This list contains 1267 plugins. Hide captured output + :pypi:`pytest-himark` + *last release*: Jun 05, 2024, + *status*: 4 - Beta, + *requires*: pytest>=6.2.0 + + This plugin aims to create markers automatically based on a json configuration. + :pypi:`pytest-historic` *last release*: Apr 08, 2020, *status*: N/A, @@ -4806,6 +6300,20 @@ This list contains 1267 plugins. Custom listener to store execution results into MYSQL DB, which is used for pytest-historic report + :pypi:`pytest-history` + *last release*: Jan 14, 2024, + *status*: N/A, + *requires*: pytest (>=7.4.3,<8.0.0) + + Pytest plugin to keep a history of your pytest runs + + :pypi:`pytest-home` + *last release*: Jul 28, 2024, + *status*: 5 - Production/Stable, + *requires*: pytest + + Home directory fixtures + :pypi:`pytest-homeassistant` *last release*: Aug 12, 2020, *status*: 4 - Beta, @@ -4814,9 +6322,9 @@ This list contains 1267 plugins. A pytest plugin for use with homeassistant custom components. :pypi:`pytest-homeassistant-custom-component` - *last release*: Jun 16, 2023, + *last release*: May 30, 2025, *status*: 3 - Alpha, - *requires*: pytest (==7.3.1) + *requires*: pytest==8.3.5 Experimental package to automatically extract test plugins for Home Assistant custom components @@ -4835,7 +6343,7 @@ This list contains 1267 plugins. Report on tests that honor constraints, and guard against regressions :pypi:`pytest-hot-reloading` - *last release*: Jun 16, 2023, + *last release*: Sep 23, 2024, *status*: N/A, *requires*: N/A @@ -4848,6 +6356,13 @@ This list contains 1267 plugins. A plugin that tracks test changes + :pypi:`pytest-houdini` + *last release*: Jul 15, 2024, + *status*: N/A, + *requires*: pytest + + pytest plugin for testing code in Houdini. + :pypi:`pytest-hoverfly` *last release*: Jan 30, 2023, *status*: N/A, @@ -4870,9 +6385,16 @@ This list contains 1267 plugins. Helpers for testing hpfeeds in your python project :pypi:`pytest-html` - *last release*: Apr 08, 2023, + *last release*: Nov 07, 2023, *status*: 5 - Production/Stable, - *requires*: pytest (!=6.0.0,>=5.0) + *requires*: pytest>=7.0.0 + + pytest plugin for generating HTML reports + + :pypi:`pytest-html-cn` + *last release*: Aug 19, 2024, + *status*: 5 - Production/Stable, + *requires*: pytest!=6.0.0,>=5.0 pytest plugin for generating HTML reports @@ -4884,14 +6406,14 @@ This list contains 1267 plugins. optimized pytest plugin for generating HTML reports :pypi:`pytest-html-merger` - *last release*: Apr 03, 2022, + *last release*: Jul 12, 2024, *status*: N/A, *requires*: N/A Pytest HTML reports merging utility :pypi:`pytest-html-object-storage` - *last release*: Mar 04, 2022, + *last release*: Jan 17, 2024, *status*: 5 - Production/Stable, *requires*: N/A @@ -4912,7 +6434,7 @@ This list contains 1267 plugins. Generates a static html report based on pytest framework :pypi:`pytest-html-report-merger` - *last release*: Aug 31, 2022, + *last release*: May 22, 2024, *status*: N/A, *requires*: N/A @@ -4926,25 +6448,25 @@ This list contains 1267 plugins. pytest plugin for generating HTML reports :pypi:`pytest-http` - *last release*: Dec 05, 2019, + *last release*: Aug 22, 2024, *status*: N/A, - *requires*: N/A + *requires*: pytest Fixture "http" for http requests :pypi:`pytest-httpbin` - *last release*: May 08, 2023, + *last release*: Sep 18, 2024, *status*: 5 - Production/Stable, - *requires*: pytest ; extra == 'test' + *requires*: pytest; extra == "test" Easily test your HTTP library against a local copy of httpbin :pypi:`pytest-httpdbg` - *last release*: May 09, 2023, - *status*: 3 - Alpha, - *requires*: pytest (>=7.0.0) + *last release*: May 08, 2025, + *status*: 4 - Beta, + *requires*: pytest>=7.0.0 - A pytest plugin to record HTTP(S) requests with stack trace + A pytest plugin to record HTTP(S) requests with stack trace. :pypi:`pytest-http-mocker` *last release*: Oct 20, 2019, @@ -4960,24 +6482,24 @@ This list contains 1267 plugins. A thin wrapper of HTTPretty for pytest - :pypi:`pytest-httpserver` - *last release*: May 22, 2023, + :pypi:`pytest_httpserver` + *last release*: Apr 10, 2025, *status*: 3 - Alpha, *requires*: N/A pytest-httpserver is a httpserver for pytest :pypi:`pytest-httptesting` - *last release*: Jun 03, 2023, + *last release*: Dec 19, 2024, *status*: N/A, - *requires*: pytest (>=7.2.0,<8.0.0) + *requires*: pytest>=8.2.0 http_testing framework on top of pytest :pypi:`pytest-httpx` - *last release*: Apr 12, 2023, + *last release*: Nov 28, 2024, *status*: 5 - Production/Stable, - *requires*: pytest (<8.0,>=6.0) + *requires*: pytest==8.* Send responses to httpx. @@ -4988,6 +6510,13 @@ This list contains 1267 plugins. Disable httpx requests during a test run + :pypi:`pytest-httpx-recorder` + *last release*: Jan 04, 2024, + *status*: 5 - Production/Stable, + *requires*: pytest + + Recorder feature based on pytest_httpx, like recorder feature in responses. + :pypi:`pytest-hue` *last release*: May 09, 2019, *status*: N/A, @@ -5009,17 +6538,24 @@ This list contains 1267 plugins. help hypo module for pytest + :pypi:`pytest-iam` + *last release*: Apr 24, 2025, + *status*: 4 - Beta, + *requires*: pytest>=7.0.0 + + A fully functional OAUTH2 / OpenID Connect (OIDC) / SCIM server to be used in your testsuite + :pypi:`pytest-ibutsu` - *last release*: Aug 05, 2022, + *last release*: Feb 06, 2025, *status*: 4 - Beta, *requires*: pytest>=7.1 A plugin to sent pytest results to an Ibutsu server :pypi:`pytest-icdiff` - *last release*: Aug 09, 2022, + *last release*: Dec 05, 2023, *status*: 4 - Beta, - *requires*: N/A + *requires*: pytest use icdiff for better error messages in pytest assertions @@ -5031,7 +6567,7 @@ This list contains 1267 plugins. A pytest plugin for idapython. Allows a pytest setup to run tests outside and inside IDA in an automated manner by runnig pytest inside IDA and by mocking idapython api :pypi:`pytest-idem` - *last release*: Sep 07, 2022, + *last release*: Dec 13, 2023, *status*: 5 - Production/Stable, *requires*: N/A @@ -5045,19 +6581,47 @@ This list contains 1267 plugins. Pytest plugin for testing function idempotence. :pypi:`pytest-ignore-flaky` - *last release*: Apr 23, 2021, + *last release*: Apr 20, 2024, *status*: 5 - Production/Stable, - *requires*: N/A + *requires*: pytest>=6.0 ignore failures from flaky tests (pytest plugin) + :pypi:`pytest-ignore-test-results` + *last release*: Feb 03, 2025, + *status*: 5 - Production/Stable, + *requires*: pytest>=7.0 + + A pytest plugin to ignore test results. + :pypi:`pytest-image-diff` - *last release*: Mar 09, 2023, + *last release*: Dec 31, 2024, *status*: 3 - Alpha, *requires*: pytest + :pypi:`pytest-image-snapshot` + *last release*: Jul 01, 2024, + *status*: 4 - Beta, + *requires*: pytest>=3.5.0 + + A pytest plugin for image snapshot management and comparison. + + :pypi:`pytest-impacted` + *last release*: May 31, 2025, + *status*: 4 - Beta, + *requires*: pytest>=8.0.0 + + A pytest plugin that selectively runs tests impacted by codechanges via git introspection, ASL parsing, and dependency graph analysis. + + :pypi:`pytest-import-check` + *last release*: Jul 19, 2024, + *status*: 3 - Alpha, + *requires*: pytest>=8.1 + + pytest plugin to check whether Python modules can be imported + :pypi:`pytest-incremental` *last release*: Apr 24, 2021, *status*: 5 - Production/Stable, @@ -5065,6 +6629,20 @@ This list contains 1267 plugins. an incremental test runner (pytest plugin) + :pypi:`pytest-infinity` + *last release*: Jun 09, 2024, + *status*: N/A, + *requires*: pytest<9.0.0,>=8.0.0 + + + + :pypi:`pytest-influx` + *last release*: Oct 16, 2024, + *status*: N/A, + *requires*: pytest<9.0.0,>=8.3.3 + + Pytest plugin for managing your influx instance between test runs + :pypi:`pytest-influxdb` *last release*: Apr 20, 2021, *status*: N/A, @@ -5079,6 +6657,13 @@ This list contains 1267 plugins. pytest plugin to collect information from tests + :pypi:`pytest-info-plugin` + *last release*: Sep 14, 2023, + *status*: N/A, + *requires*: N/A + + Get executed interface information in pytest interface automation framework + :pypi:`pytest-informative-node` *last release*: Apr 25, 2019, *status*: 4 - Beta, @@ -5086,6 +6671,13 @@ This list contains 1267 plugins. display more node ininformation. + :pypi:`pytest-infrahouse` + *last release*: Mar 18, 2025, + *status*: 4 - Beta, + *requires*: pytest~=8.3 + + A set of fixtures to use with pytest + :pypi:`pytest-infrastructure` *last release*: Apr 12, 2020, *status*: 4 - Beta, @@ -5100,38 +6692,52 @@ This list contains 1267 plugins. Reuse pytest.ini to store env variables + :pypi:`pytest-initry` + *last release*: Apr 30, 2024, + *status*: N/A, + *requires*: pytest<9.0.0,>=8.1.1 + + Plugin for sending automation test data from Pytest to the initry + :pypi:`pytest-inline` - *last release*: Feb 08, 2023, + *last release*: Oct 24, 2024, *status*: 4 - Beta, - *requires*: pytest (>=7.0.0) + *requires*: pytest<9.0,>=7.0 - A pytest plugin for writing inline tests. + A pytest plugin for writing inline tests :pypi:`pytest-inmanta` - *last release*: Feb 23, 2023, + *last release*: Apr 09, 2025, *status*: 5 - Production/Stable, - *requires*: N/A + *requires*: pytest A py.test plugin providing fixtures to simplify inmanta modules testing. :pypi:`pytest-inmanta-extensions` - *last release*: Apr 12, 2023, + *last release*: May 27, 2025, *status*: 5 - Production/Stable, *requires*: N/A Inmanta tests package :pypi:`pytest-inmanta-lsm` - *last release*: May 17, 2023, + *last release*: Apr 09, 2025, *status*: 5 - Production/Stable, *requires*: N/A Common fixtures for inmanta LSM related modules + :pypi:`pytest-inmanta-srlinux` + *last release*: Apr 22, 2025, + *status*: 3 - Alpha, + *requires*: N/A + + Pytest library to facilitate end to end testing of inmanta projects + :pypi:`pytest-inmanta-yang` - *last release*: Jun 16, 2022, + *last release*: Feb 22, 2024, *status*: 4 - Beta, - *requires*: N/A + *requires*: pytest Common fixtures used in inmanta yang related modules @@ -5142,10 +6748,24 @@ This list contains 1267 plugins. A simple image diff plugin for pytest + :pypi:`pytest-in-robotframework` + *last release*: Nov 23, 2024, + *status*: N/A, + *requires*: pytest + + The extension enables easy execution of pytest tests within the Robot Framework environment. + + :pypi:`pytest-insper` + *last release*: Mar 21, 2024, + *status*: N/A, + *requires*: pytest + + Pytest plugin for courses at Insper + :pypi:`pytest-insta` - *last release*: Nov 02, 2022, + *last release*: Feb 19, 2024, *status*: N/A, - *requires*: pytest (>=7.2.0,<8.0.0) + *requires*: pytest (>=7.2.0,<9.0.0) A practical snapshot testing plugin for pytest @@ -5192,16 +6812,16 @@ This list contains 1267 plugins. Pytest plugin for intercepting outgoing connection requests during pytest run. :pypi:`pytest-interface-tester` - *last release*: May 09, 2023, + *last release*: Feb 13, 2025, *status*: 4 - Beta, *requires*: pytest Pytest plugin for checking charm relation interface protocol compliance. :pypi:`pytest-invenio` - *last release*: Jun 02, 2023, + *last release*: May 08, 2025, *status*: 5 - Production/Stable, - *requires*: pytest (<7.2.0,>=6) + *requires*: pytest<9.0.0,>=6 Pytest fixtures for Invenio. @@ -5212,6 +6832,13 @@ This list contains 1267 plugins. Run tests covering a specific file or changeset + :pypi:`pytest-iovis` + *last release*: Nov 06, 2024, + *status*: 4 - Beta, + *requires*: pytest>=7.1.0 + + A Pytest plugin to enable Jupyter Notebook testing with Papermill + :pypi:`pytest-ipdb` *last release*: Mar 20, 2013, *status*: 2 - Pre-Alpha, @@ -5226,34 +6853,62 @@ This list contains 1267 plugins. THIS PROJECT IS ABANDONED + :pypi:`pytest-ipynb2` + *last release*: Mar 09, 2025, + *status*: N/A, + *requires*: pytest + + Pytest plugin to run tests in Jupyter Notebooks + + :pypi:`pytest-ipywidgets` + *last release*: May 30, 2025, + *status*: N/A, + *requires*: pytest + + + + :pypi:`pytest-iso` + *last release*: May 15, 2025, + *status*: 4 - Beta, + *requires*: pytest<9.0.0,>=7.4.0 + + Plugin for pytest to produce test documentation for code audits. + :pypi:`pytest-isolate` - *last release*: Feb 20, 2023, + *last release*: May 22, 2025, *status*: 4 - Beta, *requires*: pytest + Run pytest tests in isolated subprocesses + :pypi:`pytest-isolate-mpi` + *last release*: Feb 24, 2025, + *status*: 4 - Beta, + *requires*: pytest>=5 + + pytest-isolate-mpi allows for MPI-parallel tests being executed in a segfault and MPI_Abort safe manner :pypi:`pytest-isort` - *last release*: Oct 31, 2022, + *last release*: Mar 05, 2024, *status*: 5 - Production/Stable, *requires*: pytest (>=5.0) py.test plugin to check import ordering using isort - :pypi:`pytest-is-running` - *last release*: Jun 16, 2023, - *status*: 5 - Production/Stable, - *requires*: N/A - - pytest plugin providing a function to check if pytest is running. - :pypi:`pytest-it` - *last release*: Jan 22, 2020, + *last release*: Jan 29, 2024, *status*: 4 - Beta, *requires*: N/A Pytest plugin to display test reports as a plaintext spec, inspired by Rspec: https://github.com/mattduck/pytest-it. + :pypi:`pytest-item-dict` + *last release*: Nov 14, 2024, + *status*: 4 - Beta, + *requires*: pytest>=8.3.0 + + Get a hierarchical dict of session.items + :pypi:`pytest-iterassert` *last release*: May 11, 2020, *status*: 3 - Alpha, @@ -5261,6 +6916,13 @@ This list contains 1267 plugins. Nicer list and iterable assertion messages for pytest + :pypi:`pytest-iteration` + *last release*: Aug 22, 2024, + *status*: N/A, + *requires*: pytest + + Add iteration mark for tests + :pypi:`pytest-iters` *last release*: May 24, 2022, *status*: N/A, @@ -5268,6 +6930,13 @@ This list contains 1267 plugins. A contextmanager pytest fixture for handling multiple mock iters + :pypi:`pytest_jar_yuan` + *last release*: Dec 12, 2022, + *status*: N/A, + *requires*: N/A + + A allure and pytest used package + :pypi:`pytest-jasmine` *last release*: Nov 04, 2017, *status*: 1 - Planning, @@ -5297,23 +6966,23 @@ This list contains 1267 plugins. A plugin to generate customizable jinja-based HTML reports in pytest :pypi:`pytest-jira` - *last release*: Jun 12, 2023, + *last release*: Apr 15, 2025, *status*: 3 - Alpha, *requires*: N/A py.test JIRA integration plugin, using markers :pypi:`pytest-jira-xfail` - *last release*: Jun 14, 2023, + *last release*: Jul 09, 2024, *status*: N/A, - *requires*: pytest (>=7.2.0) + *requires*: pytest>=7.2.0 Plugin skips (xfail) tests if unresolved Jira issue(s) linked :pypi:`pytest-jira-xray` - *last release*: Jun 06, 2023, + *last release*: May 24, 2025, *status*: 4 - Beta, - *requires*: pytest + *requires*: pytest>=6.2.4 pytest plugin to integrate tests with JIRA XRAY @@ -5345,6 +7014,13 @@ This list contains 1267 plugins. Generate JSON test reports + :pypi:`pytest-json-ctrf` + *last release*: Oct 10, 2024, + *status*: N/A, + *requires*: pytest>6.0.0 + + Pytest plugin to generate json report in CTRF (Common Test Report Format) + :pypi:`pytest-json-fixtures` *last release*: Mar 14, 2023, *status*: 4 - Beta, @@ -5364,20 +7040,48 @@ This list contains 1267 plugins. *status*: 4 - Beta, *requires*: pytest (>=3.8.0) - A pytest plugin to report test results as JSON files + A pytest plugin to report test results as JSON files + + :pypi:`pytest-json-report-wip` + *last release*: Oct 28, 2023, + *status*: 4 - Beta, + *requires*: pytest >=3.8.0 + + A pytest plugin to report test results as JSON files + + :pypi:`pytest-jsonschema` + *last release*: Apr 20, 2025, + *status*: 4 - Beta, + *requires*: pytest>=6.2.0 + + A pytest plugin to perform JSONSchema validations :pypi:`pytest-jtr` - *last release*: Nov 29, 2022, + *last release*: Jul 21, 2024, *status*: N/A, - *requires*: pytest (>=7.1.2,<8.0.0) + *requires*: pytest<8.0.0,>=7.1.2 pytest plugin supporting json test report output - :pypi:`pytest-jupyter` - *last release*: Mar 30, 2023, + :pypi:`pytest-jubilant` + *last release*: May 14, 2025, + *status*: N/A, + *requires*: pytest>=8.3.5 + + Add your description here + + :pypi:`pytest-junit-xray-xml` + *last release*: Jan 01, 2025, *status*: 4 - Beta, *requires*: pytest + Export test results in an augmented JUnit format for usage with Xray () + + :pypi:`pytest-jupyter` + *last release*: Apr 04, 2024, + *status*: 4 - Beta, + *requires*: pytest>=7.0 + A pytest plugin for testing Jupyter libraries and extensions. :pypi:`pytest-jupyterhub` @@ -5388,7 +7092,7 @@ This list contains 1267 plugins. A reusable JupyterHub pytest plugin :pypi:`pytest-kafka` - *last release*: Jun 14, 2023, + *last release*: Aug 14, 2024, *status*: N/A, *requires*: pytest @@ -5401,6 +7105,13 @@ This list contains 1267 plugins. A plugin to send pytest events to Kafka + :pypi:`pytest-kairos` + *last release*: Aug 08, 2024, + *status*: 5 - Production/Stable, + *requires*: pytest>=5.0.0 + + Pytest plugin with random number generation, reproducibility, and test repetition + :pypi:`pytest-kasima` *last release*: Jan 26, 2023, *status*: 5 - Production/Stable, @@ -5422,6 +7133,13 @@ This list contains 1267 plugins. + :pypi:`pytest-keyring` + *last release*: Dec 08, 2024, + *status*: N/A, + *requires*: pytest>=8.0.2 + + A Pytest plugin to access the system's keyring to provide credentials for tests + :pypi:`pytest-kind` *last release*: Nov 30, 2022, *status*: 5 - Production/Stable, @@ -5450,6 +7168,13 @@ This list contains 1267 plugins. Run Konira DSL tests with py.test + :pypi:`pytest-kookit` + *last release*: Sep 10, 2024, + *status*: N/A, + *requires*: N/A + + Your simple but kooky integration testing with pytest + :pypi:`pytest-koopmans` *last release*: Nov 21, 2022, *status*: 4 - Beta, @@ -5465,11 +7190,18 @@ This list contains 1267 plugins. pytest krtech common library :pypi:`pytest-kubernetes` - *last release*: May 17, 2023, + *last release*: Feb 04, 2025, *status*: N/A, - *requires*: pytest (>=7.2.1,<8.0.0) + *requires*: pytest<9.0.0,>=8.3.0 + + + :pypi:`pytest-kuunda` + *last release*: Feb 25, 2024, + *status*: 4 - Beta, + *requires*: pytest >=6.2.0 + pytest plugin to help with test data setup for PySpark tests :pypi:`pytest-kwparametrize` *last release*: Jan 22, 2021, @@ -5479,9 +7211,9 @@ This list contains 1267 plugins. Alternate syntax for @pytest.mark.parametrize with test cases as dictionaries and default value fallbacks :pypi:`pytest-lambda` - *last release*: Aug 20, 2022, - *status*: 3 - Alpha, - *requires*: pytest (>=3.6,<8) + *last release*: May 27, 2024, + *status*: 5 - Production/Stable, + *requires*: pytest<9,>=3.6 Define pytest fixtures with lambda functions. @@ -5500,11 +7232,18 @@ This list contains 1267 plugins. Pytest-style test runner for langchain agents :pypi:`pytest-lark` - *last release*: Nov 20, 2022, + *last release*: Nov 05, 2023, *status*: N/A, *requires*: N/A - A package for enhancing pytest + Create fancy and clear HTML test reports. + + :pypi:`pytest-latin-hypercube` + *last release*: Feb 27, 2025, + *status*: N/A, + *requires*: pytest + + Implementation of Latin Hypercube Sampling for pytest. :pypi:`pytest-launchable` *last release*: Apr 05, 2023, @@ -5528,9 +7267,9 @@ This list contains 1267 plugins. It helps to use fixtures in pytest.mark.parametrize :pypi:`pytest-lazy-fixtures` - *last release*: May 28, 2023, + *last release*: May 27, 2025, *status*: N/A, - *requires*: pytest (>=7.2.1,<8.0.0) + *requires*: pytest>=7 Allows you to use fixtures in @pytest.mark.parametrize. @@ -5555,6 +7294,20 @@ This list contains 1267 plugins. A pytest plugin to trace resource leaks. + :pypi:`pytest-leaping` + *last release*: Mar 27, 2024, + *status*: 4 - Beta, + *requires*: pytest>=6.2.0 + + A simple plugin to use with pytest + + :pypi:`pytest-leo-interface` + *last release*: Mar 19, 2025, + *status*: N/A, + *requires*: N/A + + Pytest extension tool for leo projects. + :pypi:`pytest-level` *last release*: Oct 21, 2019, *status*: N/A, @@ -5562,15 +7315,22 @@ This list contains 1267 plugins. Select tests of a given level or lower + :pypi:`pytest-lf-skip` + *last release*: May 26, 2025, + *status*: 4 - Beta, + *requires*: pytest>=8.3.5 + + A pytest plugin which makes \`--last-failed\` skip instead of deselect tests. + :pypi:`pytest-libfaketime` - *last release*: Dec 22, 2018, + *last release*: Apr 12, 2024, *status*: 4 - Beta, - *requires*: pytest (>=3.0.0) + *requires*: pytest>=3.0.0 - A python-libfaketime plugin for pytest. + A python-libfaketime plugin for pytest :pypi:`pytest-libiio` - *last release*: Jul 11, 2022, + *last release*: Oct 01, 2024, *status*: 4 - Beta, *requires*: N/A @@ -5598,9 +7358,9 @@ This list contains 1267 plugins. A pytest plugin to show the line numbers of test functions :pypi:`pytest-line-profiler` - *last release*: May 03, 2021, + *last release*: Aug 10, 2023, *status*: 4 - Beta, - *requires*: pytest (>=3.5.0) + *requires*: pytest >=3.5.0 Profile code executed by pytest @@ -5619,7 +7379,7 @@ This list contains 1267 plugins. Pytest plugin for organizing tests. :pypi:`pytest-listener` - *last release*: May 28, 2019, + *last release*: Nov 29, 2024, *status*: 5 - Production/Stable, *requires*: pytest @@ -5632,6 +7392,13 @@ This list contains 1267 plugins. A pytest plugin that stream output in LITF format + :pypi:`pytest-litter` + *last release*: Nov 23, 2023, + *status*: 4 - Beta, + *requires*: pytest >=6.1 + + Pytest plugin which verifies that tests do not modify file trees. + :pypi:`pytest-live` *last release*: Mar 08, 2020, *status*: N/A, @@ -5639,6 +7406,13 @@ This list contains 1267 plugins. Live results for pytest + :pypi:`pytest-llmeval` + *last release*: Mar 19, 2025, + *status*: 4 - Beta, + *requires*: pytest>=6.2.0 + + A pytest plugin to evaluate/benchmark LLM prompts + :pypi:`pytest-local-badge` *last release*: Jan 15, 2023, *status*: N/A, @@ -5647,14 +7421,14 @@ This list contains 1267 plugins. Generate local badges (shields) reporting your test suite status. :pypi:`pytest-localftpserver` - *last release*: Oct 04, 2022, + *last release*: May 19, 2024, *status*: 5 - Production/Stable, *requires*: pytest A PyTest plugin which provides an FTP fixture for your tests :pypi:`pytest-localserver` - *last release*: Jan 30, 2023, + *last release*: Oct 06, 2024, *status*: 4 - Beta, *requires*: N/A @@ -5667,17 +7441,24 @@ This list contains 1267 plugins. Pytest plugin for AWS integration tests + :pypi:`pytest-lock` + *last release*: Feb 03, 2024, + *status*: N/A, + *requires*: pytest (>=7.4.3,<8.0.0) + + pytest-lock is a pytest plugin that allows you to "lock" the results of unit tests, storing them in a local cache. This is particularly useful for tests that are resource-intensive or don't need to be run every time. When the tests are run subsequently, pytest-lock will compare the current results with the locked results and issue a warning if there are any discrepancies. + :pypi:`pytest-lockable` - *last release*: Jul 20, 2022, + *last release*: Jan 24, 2024, *status*: 5 - Production/Stable, *requires*: pytest lockable resource plugin for pytest :pypi:`pytest-locker` - *last release*: Oct 29, 2021, + *last release*: Dec 20, 2024, *status*: N/A, - *requires*: pytest (>=5.4) + *requires*: pytest>=5.4 Used to lock object during testing. Essentially changing assertions from being hard coded to asserting that nothing changed @@ -5710,8 +7491,8 @@ This list contains 1267 plugins. Pytest plugin providing three logger fixtures with basic or full writing to log files :pypi:`pytest-logger` - *last release*: Jul 25, 2019, - *status*: 4 - Beta, + *last release*: Mar 10, 2024, + *status*: 5 - Production/Stable, *requires*: pytest (>=3.2) Plugin configuring handlers for loggers from Python logging module. @@ -5730,10 +7511,17 @@ This list contains 1267 plugins. + :pypi:`pytest-logging-strict` + *last release*: May 20, 2025, + *status*: 3 - Alpha, + *requires*: pytest + + pytest fixture logging configured from packaged YAML + :pypi:`pytest-logikal` - *last release*: Jun 04, 2023, + *last release*: Apr 30, 2025, *status*: 5 - Production/Stable, - *requires*: pytest (==7.3.1) + *requires*: pytest==8.3.5 Common testing environment @@ -5744,26 +7532,47 @@ This list contains 1267 plugins. Package for creating a pytest test run reprot + :pypi:`pytest-logscanner` + *last release*: Sep 30, 2024, + *status*: 4 - Beta, + *requires*: pytest>=8.2.2 + + Pytest plugin for logscanner (A logger for python logging outputting to easily viewable (and filterable) html files. Good for people not grep savey, and color higlighting and quickly changing filters might even bye useful for commandline wizards.) + :pypi:`pytest-loguru` - *last release*: Apr 12, 2022, + *last release*: Mar 20, 2024, *status*: 5 - Production/Stable, - *requires*: N/A + *requires*: pytest; extra == "test" Pytest Loguru :pypi:`pytest-loop` - *last release*: Jul 22, 2022, + *last release*: Oct 17, 2024, *status*: 5 - Production/Stable, - *requires*: pytest (>=6) + *requires*: pytest pytest plugin for looping tests :pypi:`pytest-lsp` - *last release*: May 19, 2023, + *last release*: Nov 23, 2024, *status*: 3 - Alpha, *requires*: pytest - pytest plugin for end-to-end testing of language servers + A pytest plugin for end-to-end testing of language servers + + :pypi:`pytest-lw-realtime-result` + *last release*: Mar 13, 2025, + *status*: N/A, + *requires*: pytest>=3.5.0 + + Pytest plugin to generate realtime test results to a file + + :pypi:`pytest-manifest` + *last release*: Apr 07, 2025, + *status*: N/A, + *requires*: pytest + + PyTest plugin for recording and asserting against a manifest file :pypi:`pytest-manual-marker` *last release*: Aug 04, 2022, @@ -5772,6 +7581,13 @@ This list contains 1267 plugins. pytest marker for marking manual tests + :pypi:`pytest-mark-count` + *last release*: Nov 13, 2024, + *status*: 4 - Beta, + *requires*: pytest>=8.0.0 + + Get a count of the number of tests marked, unmarked, and unique tests if tests have multiple markers + :pypi:`pytest-markdoctest` *last release*: Jul 22, 2022, *status*: 4 - Beta, @@ -5787,26 +7603,33 @@ This list contains 1267 plugins. Test your markdown docs with pytest :pypi:`pytest-markdown-docs` - *last release*: Mar 09, 2023, + *last release*: Apr 09, 2025, *status*: N/A, - *requires*: N/A + *requires*: pytest>=7.0.0 Run markdown code fences through pytest :pypi:`pytest-marker-bugzilla` - *last release*: Jan 09, 2020, - *status*: N/A, - *requires*: N/A + *last release*: Apr 02, 2025, + *status*: 5 - Production/Stable, + *requires*: pytest>=2.2.4 py.test bugzilla integration plugin, using markers :pypi:`pytest-markers-presence` - *last release*: Feb 04, 2021, + *last release*: Oct 30, 2024, *status*: 4 - Beta, - *requires*: pytest (>=6.0) + *requires*: pytest>=6.0 A simple plugin to detect missed pytest tags and markers" + :pypi:`pytest-mark-filter` + *last release*: May 11, 2025, + *status*: N/A, + *requires*: pytest>=8.3.0 + + Filter pytest marks by name using match kw + :pypi:`pytest-markfiltration` *last release*: Nov 08, 2011, *status*: 3 - Alpha, @@ -5814,6 +7637,13 @@ This list contains 1267 plugins. UNKNOWN + :pypi:`pytest-mark-manage` + *last release*: Aug 15, 2024, + *status*: N/A, + *requires*: pytest + + 用例标签化管理 + :pypi:`pytest-mark-no-py3` *last release*: May 17, 2019, *status*: N/A, @@ -5828,12 +7658,26 @@ This list contains 1267 plugins. UNKNOWN + :pypi:`pytest-mask-secrets` + *last release*: Jan 28, 2025, + *status*: N/A, + *requires*: N/A + + Pytest plugin to hide sensitive data in test reports + :pypi:`pytest-matcher` - *last release*: Dec 10, 2021, + *last release*: Aug 01, 2024, *status*: 5 - Production/Stable, - *requires*: N/A + *requires*: pytest + + Easy way to match captured \`pytest\` output against expectations stored in files + + :pypi:`pytest-matchers` + *last release*: Feb 11, 2025, + *status*: N/A, + *requires*: pytest<9.0,>=7.0 - Match test output against patterns stored in files + Matchers for pytest :pypi:`pytest-match-skip` *last release*: May 15, 2019, @@ -5856,6 +7700,20 @@ This list contains 1267 plugins. Provide tools for generating tests from combinations of fixtures. + :pypi:`pytest-maxcov` + *last release*: Sep 24, 2023, + *status*: N/A, + *requires*: pytest (>=7.4.0,<8.0.0) + + Compute the maximum coverage available through pytest with the minimum execution time cost + + :pypi:`pytest-max-warnings` + *last release*: Oct 23, 2024, + *status*: 4 - Beta, + *requires*: pytest>=8.3.3 + + A Pytest plugin to exit non-zero exit code when the configured maximum warnings has been exceeded. + :pypi:`pytest-maybe-context` *last release*: Apr 16, 2023, *status*: N/A, @@ -5885,11 +7743,18 @@ This list contains 1267 plugins. Plugin for generating Markdown reports for pytest results :pypi:`pytest-md-report` - *last release*: May 28, 2023, + *last release*: May 02, 2025, *status*: 4 - Beta, - *requires*: pytest (!=6.0.0,<8,>=3.3.2) + *requires*: pytest!=6.0.0,<9,>=3.3.2 + + A pytest plugin to generate test outcomes reports with markdown table format. - A pytest plugin to make a test results report with Markdown table format. + :pypi:`pytest-meilisearch` + *last release*: Oct 08, 2024, + *status*: N/A, + *requires*: pytest>=7.4.3 + + Pytest helpers for testing projects using Meilisearch :pypi:`pytest-memlog` *last release*: May 03, 2023, @@ -5906,7 +7771,7 @@ This list contains 1267 plugins. Estimates memory consumption of test functions :pypi:`pytest-memray` - *last release*: Jun 06, 2023, + *last release*: Jul 25, 2024, *status*: N/A, *requires*: pytest>=7.2 @@ -5926,6 +7791,13 @@ This list contains 1267 plugins. pytest plugin to write integration tests for projects using Mercurial Python internals + :pypi:`pytest-mergify` + *last release*: May 13, 2025, + *status*: N/A, + *requires*: pytest>=6.0.0 + + Pytest plugin for Mergify + :pypi:`pytest-mesh` *last release*: Aug 05, 2022, *status*: N/A, @@ -5948,7 +7820,7 @@ This list contains 1267 plugins. Pytest to Slack reporting plugin :pypi:`pytest-metadata` - *last release*: May 27, 2023, + *last release*: Feb 12, 2024, *status*: 5 - Production/Stable, *requires*: pytest>=7.0.0 @@ -5962,7 +7834,7 @@ This list contains 1267 plugins. Custom metrics report for pytest :pypi:`pytest-mh` - *last release*: Jun 08, 2023, + *last release*: May 15, 2025, *status*: N/A, *requires*: pytest @@ -5975,6 +7847,13 @@ This list contains 1267 plugins. Mimesis integration with the pytest test runner + :pypi:`pytest-mimic` + *last release*: Apr 24, 2025, + *status*: 4 - Beta, + *requires*: pytest>=6.2.0 + + Easily record function calls while testing + :pypi:`pytest-minecraft` *last release*: Apr 06, 2022, *status*: N/A, @@ -5989,6 +7868,13 @@ This list contains 1267 plugins. A plugin to test mp + :pypi:`pytest-minio-mock` + *last release*: May 06, 2025, + *status*: N/A, + *requires*: pytest>=5.0.0 + + A pytest plugin for mocking Minio S3 interactions + :pypi:`pytest-missing-fixtures` *last release*: Oct 14, 2020, *status*: 4 - Beta, @@ -5996,6 +7882,27 @@ This list contains 1267 plugins. Pytest plugin that creates missing fixtures + :pypi:`pytest-missing-modules` + *last release*: Sep 03, 2024, + *status*: N/A, + *requires*: pytest>=8.3.2 + + Pytest plugin to easily fake missing modules + + :pypi:`pytest-mitmproxy` + *last release*: Nov 13, 2024, + *status*: N/A, + *requires*: pytest>=7.0 + + pytest plugin for mitmproxy tests + + :pypi:`pytest-mitmproxy-plugin` + *last release*: Apr 10, 2025, + *status*: 4 - Beta, + *requires*: pytest>=7.2.0 + + Use MITM Proxy in autotests with full control from code + :pypi:`pytest-ml` *last release*: May 04, 2019, *status*: 4 - Beta, @@ -6011,9 +7918,9 @@ This list contains 1267 plugins. pytest plugin to display test execution output like a mochajs :pypi:`pytest-mock` - *last release*: Jun 15, 2023, + *last release*: May 26, 2025, *status*: 5 - Production/Stable, - *requires*: pytest (>=5.0) + *requires*: pytest>=6.2.5 Thin-wrapper around the mock package for easier use with pytest @@ -6053,9 +7960,9 @@ This list contains 1267 plugins. An in-memory mock of a Redis server that runs in a separate thread. This is to be used for unit-tests that require a Redis database. :pypi:`pytest-mock-resources` - *last release*: Jun 09, 2023, + *last release*: Mar 10, 2025, *status*: N/A, - *requires*: pytest (>=1.0) + *requires*: pytest>=1.0 A pytest plugin for easily instantiating reproducible mock resources. @@ -6080,6 +7987,13 @@ This list contains 1267 plugins. A pytest plugin for testing TCP clients + :pypi:`pytest-modalt` + *last release*: Feb 27, 2024, + *status*: 4 - Beta, + *requires*: pytest >=6.2.0 + + Massively distributed pytest runs using modal.com + :pypi:`pytest-modified-env` *last release*: Jan 29, 2022, *status*: 4 - Beta, @@ -6094,13 +8008,6 @@ This list contains 1267 plugins. Utility for adding additional properties to junit xml for IDM QE - :pypi:`pytest-modifyscope` - *last release*: Apr 12, 2020, - *status*: N/A, - *requires*: pytest - - pytest plugin to modify fixture scope - :pypi:`pytest-molecule` *last release*: Mar 29, 2022, *status*: 5 - Production/Stable, @@ -6108,10 +8015,17 @@ This list contains 1267 plugins. PyTest Molecule Plugin :: discover and run molecule tests + :pypi:`pytest-molecule-JC` + *last release*: Jul 18, 2023, + *status*: 5 - Production/Stable, + *requires*: pytest (>=7.0.0) + + PyTest Molecule Plugin :: discover and run molecule tests + :pypi:`pytest-mongo` - *last release*: Jun 07, 2021, + *last release*: Feb 28, 2025, *status*: 5 - Production/Stable, - *requires*: pytest + *requires*: pytest>=6.2 MongoDB process and client fixtures plugin for Pytest. @@ -6122,8 +8036,22 @@ This list contains 1267 plugins. pytest plugin for MongoDB fixtures + :pypi:`pytest-mongodb-nono` + *last release*: Jan 07, 2025, + *status*: N/A, + *requires*: N/A + + pytest plugin for MongoDB + + :pypi:`pytest-mongodb-ry` + *last release*: Jan 21, 2025, + *status*: N/A, + *requires*: N/A + + pytest plugin for MongoDB + :pypi:`pytest-monitor` - *last release*: Oct 22, 2022, + *last release*: Jun 25, 2023, *status*: 5 - Production/Stable, *requires*: pytest @@ -6150,6 +8078,13 @@ This list contains 1267 plugins. Fixtures for integration tests of AWS services,uses moto mocking library. + :pypi:`pytest-moto-fixtures` + *last release*: Feb 04, 2025, + *status*: 1 - Planning, + *requires*: pytest<9,>=8.3; extra == "pytest" + + Fixtures for testing code that interacts with AWS + :pypi:`pytest-motor` *last release*: Jul 21, 2021, *status*: 3 - Alpha, @@ -6172,14 +8107,14 @@ This list contains 1267 plugins. pytest plugin to collect information from tests :pypi:`pytest-mpiexec` - *last release*: Apr 13, 2023, + *last release*: Jul 29, 2024, *status*: 3 - Alpha, *requires*: pytest pytest plugin for running individual tests with mpiexec :pypi:`pytest-mpl` - *last release*: Jul 23, 2022, + *last release*: Feb 14, 2024, *status*: 4 - Beta, *requires*: pytest @@ -6193,9 +8128,9 @@ This list contains 1267 plugins. low-startup-overhead, scalable, distributed-testing pytest plugin :pypi:`pytest-mqtt` - *last release*: Mar 15, 2023, - *status*: 4 - Beta, - *requires*: pytest (<8) ; extra == 'test' + *last release*: Jan 07, 2025, + *status*: 5 - Production/Stable, + *requires*: pytest<9; extra == "test" pytest-mqtt supports testing systems based on MQTT @@ -6214,7 +8149,7 @@ This list contains 1267 plugins. Multi-process logs handling and other helpers for pytest :pypi:`pytest-multithreading` - *last release*: Dec 07, 2022, + *last release*: Aug 05, 2024, *status*: N/A, *requires*: N/A @@ -6234,12 +8169,26 @@ This list contains 1267 plugins. Add the mutation testing feature to pytest + :pypi:`pytest-my-cool-lib` + *last release*: Nov 02, 2023, + *status*: N/A, + *requires*: pytest (>=7.1.3,<8.0.0) + + + + :pypi:`pytest-my-plugin` + *last release*: Jan 27, 2025, + *status*: N/A, + *requires*: pytest>=6.0 + + A pytest plugin that does awesome things + :pypi:`pytest-mypy` - *last release*: Dec 18, 2022, - *status*: 4 - Beta, - *requires*: pytest (>=6.2) ; python_version >= "3.10" + *last release*: Apr 02, 2025, + *status*: 5 - Production/Stable, + *requires*: pytest>=7.0 - Mypy static type checker plugin for Pytest + A Pytest Plugin for Mypy :pypi:`pytest-mypyd` *last release*: Aug 20, 2019, @@ -6249,33 +8198,47 @@ This list contains 1267 plugins. Mypy static type checker plugin for Pytest :pypi:`pytest-mypy-plugins` - *last release*: May 05, 2023, + *last release*: Dec 21, 2024, *status*: 4 - Beta, - *requires*: pytest (>=6.2.0) + *requires*: pytest>=7.0.0 pytest plugin for writing tests for mypy plugins :pypi:`pytest-mypy-plugins-shim` - *last release*: Apr 12, 2021, + *last release*: Feb 14, 2025, *status*: N/A, *requires*: pytest>=6.0.0 Substitute for "pytest-mypy-plugins" for Python implementations which aren't supported by mypy. + :pypi:`pytest-mypy-runner` + *last release*: Apr 23, 2024, + *status*: N/A, + *requires*: pytest>=8.0 + + Run the mypy static type checker as a pytest test case + :pypi:`pytest-mypy-testing` - *last release*: Feb 25, 2023, + *last release*: Mar 04, 2024, *status*: N/A, - *requires*: pytest>=7,<8 + *requires*: pytest>=7,<9 Pytest plugin to check mypy output. :pypi:`pytest-mysql` - *last release*: Mar 27, 2023, + *last release*: Dec 10, 2024, *status*: 5 - Production/Stable, - *requires*: pytest (>=6.2) + *requires*: pytest>=6.2 MySQL process and client fixtures for pytest + :pypi:`pytest-ndb` + *last release*: Apr 28, 2024, + *status*: N/A, + *requires*: pytest + + pytest notebook debugger + :pypi:`pytest-needle` *last release*: Dec 10, 2018, *status*: 4 - Beta, @@ -6290,10 +8253,24 @@ This list contains 1267 plugins. pytest-neo is a plugin for pytest that shows tests like screen of Matrix. + :pypi:`pytest-neos` + *last release*: Sep 10, 2024, + *status*: 5 - Production/Stable, + *requires*: pytest<8.0,>=7.2; extra == "dev" + + Pytest plugin for neos + + :pypi:`pytest-netconf` + *last release*: Jan 06, 2025, + *status*: N/A, + *requires*: N/A + + A pytest plugin that provides a mock NETCONF (RFC6241/RFC6242) server for local testing. + :pypi:`pytest-netdut` - *last release*: Jun 05, 2023, + *last release*: Apr 11, 2025, *status*: N/A, - *requires*: pytest (>=3.5.0) + *requires*: pytest>=3.5.0 "Automated software testing for switches using pytest" @@ -6319,9 +8296,9 @@ This list contains 1267 plugins. pytest plugin helps to avoid adding tests without mock \`time.sleep\` :pypi:`pytest-nginx` - *last release*: Aug 12, 2017, + *last release*: May 03, 2025, *status*: 5 - Production/Stable, - *requires*: N/A + *requires*: pytest>=3.0.0 nginx fixture for pytest @@ -6347,9 +8324,9 @@ This list contains 1267 plugins. pytest ngs fixtures :pypi:`pytest-nhsd-apim` - *last release*: Jun 07, 2023, + *last release*: Apr 01, 2025, *status*: N/A, - *requires*: pytest (==6.2.5) + *requires*: pytest<9.0.0,>=8.2.0 Pytest plugin accessing NHSDigital's APIM proxies @@ -6367,20 +8344,27 @@ This list contains 1267 plugins. A small snippet for nicer PyTest's Parametrize - :pypi:`pytest-nlcov` - *last release*: Jul 07, 2021, + :pypi:`pytest_nlcov` + *last release*: Aug 05, 2024, *status*: N/A, *requires*: N/A Pytest plugin to get the coverage of the new lines (based on git diff) only :pypi:`pytest-nocustom` - *last release*: Jul 07, 2021, + *last release*: Aug 05, 2024, *status*: 5 - Production/Stable, *requires*: N/A Run all tests without custom markers + :pypi:`pytest-node-dependency` + *last release*: Apr 10, 2024, + *status*: 5 - Production/Stable, + *requires*: N/A + + pytest plugin for controlling execution flow + :pypi:`pytest-nodev` *last release*: Jul 21, 2016, *status*: 4 - Beta, @@ -6389,12 +8373,33 @@ This list contains 1267 plugins. Test-driven source code search for Python. :pypi:`pytest-nogarbage` - *last release*: Aug 29, 2021, + *last release*: Feb 24, 2025, *status*: 5 - Production/Stable, - *requires*: pytest (>=4.6.0) + *requires*: pytest>=4.6.0 Ensure a test produces no garbage + :pypi:`pytest-no-problem` + *last release*: Apr 05, 2025, + *status*: N/A, + *requires*: pytest>=7.0 + + Pytest plugin to tell you when there's no problem + + :pypi:`pytest-nose-attrib` + *last release*: Aug 13, 2023, + *status*: N/A, + *requires*: N/A + + pytest plugin to use nose @attrib marks decorators and pick tests based on attributes and partially uses nose-attrib plugin approach + + :pypi:`pytest_notebook` + *last release*: Nov 28, 2023, + *status*: 4 - Beta, + *requires*: pytest>=3.5.0 + + A pytest plugin for testing Jupyter Notebooks. + :pypi:`pytest-notice` *last release*: Nov 05, 2020, *status*: N/A, @@ -6416,6 +8421,13 @@ This list contains 1267 plugins. A pytest plugin to notify test result + :pypi:`pytest_notify` + *last release*: Jul 05, 2017, + *status*: N/A, + *requires*: pytest>=3.0.0 + + Get notifications when your tests ends + :pypi:`pytest-notimplemented` *last release*: Aug 27, 2019, *status*: N/A, @@ -6431,19 +8443,26 @@ This list contains 1267 plugins. A PyTest Reporter to send test runs to Notion.so :pypi:`pytest-nunit` - *last release*: Oct 20, 2022, + *last release*: Feb 26, 2024, *status*: 5 - Production/Stable, - *requires*: pytest (>=4.6.0) + *requires*: N/A A pytest plugin for generating NUnit3 test result XML output :pypi:`pytest-oar` - *last release*: May 02, 2023, + *last release*: May 12, 2025, *status*: N/A, *requires*: pytest>=6.0.1 PyTest plugin for the OAR testing framework + :pypi:`pytest-oarepo` + *last release*: Feb 14, 2025, + *status*: N/A, + *requires*: pytest>=7.1.2; extra == "base" + + + :pypi:`pytest-object-getter` *last release*: Jul 31, 2022, *status*: 5 - Production/Stable, @@ -6458,10 +8477,17 @@ This list contains 1267 plugins. pytest results data-base and HTML reporter - :pypi:`pytest-odoo` - *last release*: Nov 17, 2022, + :pypi:`pytest-odc` + *last release*: Aug 04, 2023, *status*: 4 - Beta, - *requires*: pytest (>=7.2.0) + *requires*: pytest (>=3.5.0) + + A pytest plugin for simplifying ODC database tests + + :pypi:`pytest-odoo` + *last release*: May 20, 2025, + *status*: 5 - Production/Stable, + *requires*: pytest>=8 py.test plugin to run Odoo tests @@ -6501,12 +8527,19 @@ This list contains 1267 plugins. The ultimate pytest output plugin :pypi:`pytest-only` - *last release*: Jun 14, 2022, + *last release*: May 27, 2024, *status*: 5 - Production/Stable, - *requires*: pytest (<7.1); python_version <= "3.6" + *requires*: pytest<9,>=3.6.0 Use @pytest.mark.only to run a single test + :pypi:`pytest-oof` + *last release*: Dec 11, 2023, + *status*: 4 - Beta, + *requires*: N/A + + A Pytest plugin providing structured, programmatic access to a test run's results + :pypi:`pytest-oot` *last release*: Sep 18, 2016, *status*: 4 - Beta, @@ -6515,23 +8548,30 @@ This list contains 1267 plugins. Run object-oriented tests in a simple format :pypi:`pytest-openfiles` - *last release*: Apr 16, 2020, + *last release*: Jun 05, 2024, *status*: 3 - Alpha, - *requires*: pytest (>=4.6) + *requires*: pytest>=4.6 Pytest plugin for detecting inadvertent open file handles + :pypi:`pytest-open-html` + *last release*: Mar 31, 2025, + *status*: N/A, + *requires*: pytest>=6.0 + + Auto-open HTML reports after pytest runs + :pypi:`pytest-opentelemetry` - *last release*: Mar 15, 2023, + *last release*: Apr 25, 2025, *status*: N/A, *requires*: pytest A pytest plugin for instrumenting test runs via OpenTelemetry :pypi:`pytest-opentmi` - *last release*: Jun 02, 2022, + *last release*: Mar 22, 2025, *status*: 5 - Production/Stable, - *requires*: pytest (>=5.0) + *requires*: pytest>=5.0 pytest plugin for publish results to opentmi @@ -6550,9 +8590,9 @@ This list contains 1267 plugins. include/exclude values of fixtures in pytest :pypi:`pytest-optional-tests` - *last release*: Jul 09, 2019, + *last release*: Apr 15, 2025, *status*: 4 - Beta, - *requires*: pytest (>=4.5.0) + *requires*: pytest; extra == "dev" Easy declaration of optional tests (i.e., that are not run by default) @@ -6564,12 +8604,19 @@ This list contains 1267 plugins. A pytest plugin for orchestrating tests :pypi:`pytest-order` - *last release*: Mar 10, 2023, - *status*: 4 - Beta, - *requires*: pytest (>=5.0) ; python_version < "3.10" + *last release*: Aug 22, 2024, + *status*: 5 - Production/Stable, + *requires*: pytest>=5.0; python_version < "3.10" pytest plugin to run your tests in a specific order + :pypi:`pytest-ordered` + *last release*: Oct 07, 2024, + *status*: N/A, + *requires*: pytest>=6.2.0 + + Declare the order in which tests should run in your pytest.ini + :pypi:`pytest-ordering` *last release*: Nov 14, 2018, *status*: 4 - Beta, @@ -6591,12 +8638,19 @@ This list contains 1267 plugins. OS X notifications for py.test results. + :pypi:`pytest-ot` + *last release*: Mar 21, 2024, + *status*: N/A, + *requires*: pytest; extra == "dev" + + A pytest plugin for instrumenting test runs via OpenTelemetry + :pypi:`pytest-otel` - *last release*: Jan 18, 2023, + *last release*: Apr 24, 2025, *status*: N/A, - *requires*: N/A + *requires*: pytest==8.3.5 - pytest-otel report OpenTelemetry traces about test executed + OpenTelemetry plugin for Pytest :pypi:`pytest-override-env-var` *last release*: Feb 25, 2023, @@ -6606,9 +8660,9 @@ This list contains 1267 plugins. Pytest mark to override a value of an environment variable. :pypi:`pytest-owner` - *last release*: Apr 25, 2022, + *last release*: Aug 19, 2024, *status*: N/A, - *requires*: N/A + *requires*: pytest Add owner mark for tests @@ -6619,6 +8673,13 @@ This list contains 1267 plugins. A simple plugin to use with pytest + :pypi:`pytest-pagerduty` + *last release*: Mar 22, 2025, + *status*: N/A, + *requires*: pytest<9.0.0,>=7.4.0 + + Pytest plugin for PagerDuty integration via automation testing. + :pypi:`pytest-pahrametahrize` *last release*: Nov 24, 2021, *status*: 4 - Beta, @@ -6654,13 +8715,6 @@ This list contains 1267 plugins. pytest plugin to test all, first, last or random params - :pypi:`pytest-paramark` - *last release*: Jan 10, 2020, - *status*: 4 - Beta, - *requires*: pytest (>=4.5.0) - - Configure pytest fixtures using a combination of"parametrize" and markers - :pypi:`pytest-parametrization` *last release*: May 22, 2022, *status*: 5 - Production/Stable, @@ -6668,6 +8722,20 @@ This list contains 1267 plugins. Simpler PyTest parametrization + :pypi:`pytest-parametrization-annotation` + *last release*: Dec 10, 2024, + *status*: 5 - Production/Stable, + *requires*: pytest>=7 + + A pytest library for parametrizing tests using type hints. + + :pypi:`pytest-parametrize` + *last release*: Nov 10, 2024, + *status*: 5 - Production/Stable, + *requires*: pytest<9.0.0,>=8.3.0 + + pytest decorator for parametrizing test cases in a dict-way + :pypi:`pytest-parametrize-cases` *last release*: Mar 13, 2022, *status*: N/A, @@ -6676,7 +8744,7 @@ This list contains 1267 plugins. A more user-friendly way to write parametrized tests. :pypi:`pytest-parametrized` - *last release*: Sep 13, 2022, + *last release*: Dec 21, 2024, *status*: 5 - Production/Stable, *requires*: pytest @@ -6689,6 +8757,27 @@ This list contains 1267 plugins. A simple pytest extension for creating a named test suite. + :pypi:`pytest_param_files` + *last release*: Jul 29, 2023, + *status*: N/A, + *requires*: pytest + + Create pytest parametrize decorators from external files. + + :pypi:`pytest-params` + *last release*: Apr 27, 2025, + *status*: 5 - Production/Stable, + *requires*: pytest>=7.0.0 + + Simplified pytest test case parameters. + + :pypi:`pytest-param-scope` + *last release*: Oct 18, 2023, + *status*: N/A, + *requires*: pytest + + pytest parametrize scope fixture workaround + :pypi:`pytest-parawtf` *last release*: Dec 03, 2018, *status*: 4 - Beta, @@ -6731,6 +8820,13 @@ This list contains 1267 plugins. A contextmanager pytest fixture for handling multiple mock patches + :pypi:`pytest-patterns` + *last release*: Oct 22, 2024, + *status*: 4 - Beta, + *requires*: pytest>=6 + + pytest plugin to make testing complicated long string output easy to write and easy to debug + :pypi:`pytest-pdb` *last release*: Jul 31, 2018, *status*: N/A, @@ -6766,10 +8862,17 @@ This list contains 1267 plugins. Change the exit code of pytest test sessions when a required percent of tests pass. + :pypi:`pytest-percents` + *last release*: Mar 16, 2024, + *status*: N/A, + *requires*: N/A + + + :pypi:`pytest-perf` - *last release*: Jun 02, 2023, + *last release*: May 20, 2024, *status*: 5 - Production/Stable, - *requires*: pytest (>=6) ; extra == 'testing' + *requires*: pytest!=8.1.*,>=6; extra == "testing" Run performance tests against the mainline code. @@ -6780,17 +8883,31 @@ This list contains 1267 plugins. A simple plugin to ensure the execution of critical sections of code has not been impacted + :pypi:`pytest-performancetotal` + *last release*: Feb 01, 2025, + *status*: 5 - Production/Stable, + *requires*: N/A + + A performance plugin for pytest + :pypi:`pytest-persistence` - *last release*: Jun 14, 2023, + *last release*: Aug 21, 2024, *status*: N/A, *requires*: N/A Pytest tool for persistent objects + :pypi:`pytest-pexpect` + *last release*: Aug 13, 2024, + *status*: 4 - Beta, + *requires*: pytest>=6.2.0 + + Pytest pexpect plugin. + :pypi:`pytest-pg` - *last release*: May 04, 2023, + *last release*: May 18, 2025, *status*: 5 - Production/Stable, - *requires*: pytest (>=6.0.0) + *requires*: pytest>=7.4 A tiny plugin for pytest which runs PostgreSQL in Docker @@ -6808,13 +8925,27 @@ This list contains 1267 plugins. pytest plugin to test Python examples in Markdown using phmdoctest. + :pypi:`pytest-phoenix-interface` + *last release*: Mar 19, 2025, + *status*: N/A, + *requires*: N/A + + Pytest extension tool for phoenix projects. + :pypi:`pytest-picked` - *last release*: Dec 23, 2020, + *last release*: Nov 06, 2024, *status*: N/A, - *requires*: pytest (>=3.5.0) + *requires*: pytest>=3.7.0 Run the tests related to the changed files + :pypi:`pytest-pickle-cache` + *last release*: Feb 17, 2025, + *status*: N/A, + *requires*: pytest>=7 + + A pytest plugin for caching test results using pickle. + :pypi:`pytest-pigeonhole` *last release*: Jun 25, 2018, *status*: 5 - Production/Stable, @@ -6871,6 +9002,20 @@ This list contains 1267 plugins. Pytest plugin for functional testing of data analysispipelines + :pypi:`pytest-pitch` + *last release*: Nov 02, 2023, + *status*: 4 - Beta, + *requires*: pytest >=7.3.1 + + runs tests in an order such that coverage increases as fast as possible + + :pypi:`pytest-platform-adapter` + *last release*: Feb 18, 2025, + *status*: 5 - Production/Stable, + *requires*: pytest>=6.2.5 + + Pytest集成自动化平台插件 + :pypi:`pytest-platform-markers` *last release*: Sep 09, 2019, *status*: 4 - Beta, @@ -6893,12 +9038,40 @@ This list contains 1267 plugins. Pytest plugin for reading playbooks. :pypi:`pytest-playwright` - *last release*: Apr 24, 2023, + *last release*: Jan 31, 2025, *status*: N/A, - *requires*: pytest (<8.0.0,>=6.2.4) + *requires*: pytest<9.0.0,>=6.2.4 A pytest wrapper with fixtures for Playwright to automate web browsers + :pypi:`pytest_playwright_async` + *last release*: Sep 28, 2024, + *status*: N/A, + *requires*: N/A + + ASYNC Pytest plugin for Playwright + + :pypi:`pytest-playwright-asyncio` + *last release*: Jan 31, 2025, + *status*: N/A, + *requires*: pytest<9.0.0,>=6.2.4 + + A pytest wrapper with async fixtures for Playwright to automate web browsers + + :pypi:`pytest-playwright-axe` + *last release*: Mar 27, 2025, + *status*: 4 - Beta, + *requires*: N/A + + An axe-core integration for accessibility testing using Playwright Python. + + :pypi:`pytest-playwright-enhanced` + *last release*: Mar 24, 2024, + *status*: N/A, + *requires*: pytest<9.0.0,>=8.0.0 + + A pytest plugin for playwright python + :pypi:`pytest-playwrights` *last release*: Dec 02, 2021, *status*: N/A, @@ -6920,15 +9093,22 @@ This list contains 1267 plugins. A pytest fixture for visual testing with Playwright + :pypi:`pytest-playwright-visual-snapshot` + *last release*: Apr 15, 2025, + *status*: N/A, + *requires*: N/A + + Easy pytest visual regression testing using playwright + :pypi:`pytest-plone` - *last release*: Jan 05, 2023, + *last release*: Mar 27, 2025, *status*: 3 - Alpha, - *requires*: pytest + *requires*: pytest<8.0.0 Pytest plugin to test Plone addons :pypi:`pytest-plt` - *last release*: Aug 17, 2020, + *last release*: Jan 17, 2024, *status*: 5 - Production/Stable, *requires*: pytest @@ -6942,9 +9122,9 @@ This list contains 1267 plugins. A plugin to help developing and testing other plugins :pypi:`pytest-plus` - *last release*: Dec 24, 2022, + *last release*: Feb 02, 2025, *status*: 5 - Production/Stable, - *requires*: pytest (>=6.0.1) + *requires*: pytest>=7.4.2 PyTest Plus Plugin :: extends pytest functionality @@ -6955,6 +9135,13 @@ This list contains 1267 plugins. + :pypi:`pytest-pogo` + *last release*: May 05, 2025, + *status*: 4 - Beta, + *requires*: pytest<9,>=7 + + Pytest plugin for pogo-migrate + :pypi:`pytest-pointers` *last release*: Dec 26, 2022, *status*: N/A, @@ -6963,7 +9150,7 @@ This list contains 1267 plugins. Pytest plugin to define functions you test with special marks for better navigation and reports :pypi:`pytest-pokie` - *last release*: May 22, 2023, + *last release*: Oct 19, 2023, *status*: 5 - Production/Stable, *requires*: N/A @@ -7011,6 +9198,13 @@ This list contains 1267 plugins. Visualize your failed tests with poo + :pypi:`pytest-pook` + *last release*: Feb 15, 2024, + *status*: 4 - Beta, + *requires*: pytest + + Pytest plugin for pook + :pypi:`pytest-pop` *last release*: May 09, 2023, *status*: 5 - Production/Stable, @@ -7018,6 +9212,13 @@ This list contains 1267 plugins. A pytest plugin to help with testing pop projects + :pypi:`pytest-porcochu` + *last release*: Nov 28, 2024, + *status*: 5 - Production/Stable, + *requires*: N/A + + Show surprise when tests are passing + :pypi:`pytest-portion` *last release*: Jan 28, 2021, *status*: 4 - Beta, @@ -7033,19 +9234,12 @@ This list contains 1267 plugins. Run PostgreSQL in Docker container in Pytest. :pypi:`pytest-postgresql` - *last release*: May 20, 2023, + *last release*: May 17, 2025, *status*: 5 - Production/Stable, - *requires*: pytest (>=6.2) + *requires*: pytest>=7.2 Postgresql fixtures and fixture factories for Pytest. - :pypi:`pytest-pot` - *last release*: Nov 20, 2022, - *status*: N/A, - *requires*: N/A - - A package for enhancing pytest - :pypi:`pytest-power` *last release*: Dec 31, 2020, *status*: N/A, @@ -7053,6 +9247,13 @@ This list contains 1267 plugins. pytest plugin with powerful fixtures + :pypi:`pytest-powerpack` + *last release*: Jan 04, 2025, + *status*: N/A, + *requires*: pytest<9.0.0,>=8.1.1 + + A plugin containing extra batteries for pytest + :pypi:`pytest-prefer-nested-dup-tests` *last release*: Apr 27, 2022, *status*: 4 - Beta, @@ -7082,12 +9283,26 @@ This list contains 1267 plugins. Minitest-style test colors :pypi:`pytest-print` - *last release*: Jun 16, 2023, + *last release*: Feb 25, 2025, *status*: 5 - Production/Stable, - *requires*: pytest>=7.3.2 + *requires*: pytest>=8.3.2 pytest-print adds the printer fixture you can use to print messages to the user (directly to the pytest runner, not stdout) + :pypi:`pytest-priority` + *last release*: Aug 19, 2024, + *status*: N/A, + *requires*: pytest + + pytest plugin for add priority for tests + + :pypi:`pytest-proceed` + *last release*: Oct 01, 2024, + *status*: N/A, + *requires*: pytest + + + :pypi:`pytest-profiles` *last release*: Dec 09, 2021, *status*: 4 - Beta, @@ -7096,16 +9311,16 @@ This list contains 1267 plugins. pytest plugin for configuration profiles :pypi:`pytest-profiling` - *last release*: May 28, 2019, + *last release*: Nov 29, 2024, *status*: 5 - Production/Stable, *requires*: pytest Profiling plugin for py.test :pypi:`pytest-progress` - *last release*: Jan 31, 2022, + *last release*: Jun 18, 2024, *status*: 5 - Production/Stable, - *requires*: N/A + *requires*: pytest>=2.7 pytest plugin for instant test progress status @@ -7123,6 +9338,13 @@ This list contains 1267 plugins. Pytest report plugin for Zulip + :pypi:`pytest-prometheus-pushgw` + *last release*: May 19, 2025, + *status*: N/A, + *requires*: pytest>=6.0.0 + + Pytest plugin to export test metrics to Prometheus Pushgateway + :pypi:`pytest-prosper` *last release*: Sep 24, 2018, *status*: N/A, @@ -7130,6 +9352,13 @@ This list contains 1267 plugins. Test helpers for Prosper projects + :pypi:`pytest-prysk` + *last release*: Dec 10, 2024, + *status*: 4 - Beta, + *requires*: pytest>=7.3.2 + + Pytest plugin for prysk + :pypi:`pytest-pspec` *last release*: Jun 02, 2020, *status*: 4 - Beta, @@ -7144,6 +9373,13 @@ This list contains 1267 plugins. pytest plugin for testing applications that use psqlgraph + :pypi:`pytest-pt` + *last release*: Sep 22, 2024, + *status*: 5 - Production/Stable, + *requires*: pytest + + pytest plugin to use \*.pt files as tests + :pypi:`pytest-ptera` *last release*: Mar 01, 2022, *status*: N/A, @@ -7151,6 +9387,13 @@ This list contains 1267 plugins. Use ptera probes in tests + :pypi:`pytest-publish` + *last release*: Jun 04, 2024, + *status*: N/A, + *requires*: pytest<9.0.0,>=8.0.0 + + + :pypi:`pytest-pudb` *last release*: Oct 25, 2018, *status*: 3 - Alpha, @@ -7194,12 +9437,19 @@ This list contains 1267 plugins. Plugin for py.test to enter PyCharm debugger on uncaught exceptions :pypi:`pytest-pycodestyle` - *last release*: Oct 28, 2022, + *last release*: Oct 10, 2024, *status*: 3 - Alpha, - *requires*: N/A + *requires*: pytest>=7.0 pytest plugin to run pycodestyle + :pypi:`pytest-pydantic-schema-sync` + *last release*: Aug 29, 2024, + *status*: N/A, + *requires*: pytest>=6 + + Pytest plugin to synchronise Pydantic model schemas with JSONSchema files + :pypi:`pytest-pydev` *last release*: Nov 15, 2017, *status*: 3 - Alpha, @@ -7208,19 +9458,26 @@ This list contains 1267 plugins. py.test plugin to connect to a remote debug server with PyDev or PyCharm. :pypi:`pytest-pydocstyle` - *last release*: Jan 05, 2023, + *last release*: Oct 09, 2024, *status*: 3 - Alpha, - *requires*: N/A + *requires*: pytest>=7.0 pytest plugin to run pydocstyle :pypi:`pytest-pylint` - *last release*: Sep 10, 2022, + *last release*: Oct 06, 2023, *status*: 5 - Production/Stable, - *requires*: pytest (>=5.4) + *requires*: pytest >=7.0 pytest plugin to check source code with pylint + :pypi:`pytest-pylyzer` + *last release*: Feb 15, 2025, + *status*: 4 - Beta, + *requires*: N/A + + A pytest plugin for pylyzer + :pypi:`pytest-pymysql-autorecord` *last release*: Sep 02, 2022, *status*: N/A, @@ -7229,11 +9486,11 @@ This list contains 1267 plugins. Record PyMySQL queries and mock with the stored data. :pypi:`pytest-pyodide` - *last release*: Jun 11, 2023, + *last release*: Nov 23, 2024, *status*: N/A, *requires*: pytest - "Pytest plugin for testing applications that use Pyodide" + Pytest plugin for testing applications that use Pyodide :pypi:`pytest-pypi` *last release*: Mar 04, 2018, @@ -7264,44 +9521,44 @@ This list contains 1267 plugins. Pytest fixture "q" for pyq :pypi:`pytest-pyramid` - *last release*: Dec 13, 2022, + *last release*: Oct 24, 2024, *status*: 5 - Production/Stable, *requires*: pytest pytest_pyramid - provides fixtures for testing pyramid applications with pytest test suite :pypi:`pytest-pyramid-server` - *last release*: May 28, 2019, + *last release*: Oct 17, 2024, *status*: 5 - Production/Stable, *requires*: pytest Pyramid server fixture for py.test :pypi:`pytest-pyreport` - *last release*: May 08, 2023, + *last release*: May 05, 2024, *status*: N/A, - *requires*: pytest (>=7.3.1) + *requires*: pytest PyReport is a lightweight reporting plugin for Pytest that provides concise HTML report :pypi:`pytest-pyright` - *last release*: Nov 20, 2022, + *last release*: Jan 26, 2024, *status*: 4 - Beta, - *requires*: pytest (>=7.0.0) + *requires*: pytest >=7.0.0 Pytest plugin for type checking code with Pyright :pypi:`pytest-pyspec` - *last release*: Mar 12, 2023, - *status*: 5 - Production/Stable, - *requires*: pytest (>=7.2.1,<8.0.0) + *last release*: Aug 17, 2024, + *status*: N/A, + *requires*: pytest<9.0.0,>=8.3.2 A plugin that transforms the pytest output into a result similar to the RSpec. It enables the use of docstrings to display results and also enables the use of the prefixes "describe", "with" and "it". :pypi:`pytest-pystack` - *last release*: May 07, 2023, + *last release*: Nov 16, 2024, *status*: N/A, - *requires*: pytest (>=3.5.0) + *requires*: pytest>=3.5.0 Plugin to run pystack after a timeout for a test suite. @@ -7312,6 +9569,13 @@ This list contains 1267 plugins. Pytest plugin for interaction with TestRail + :pypi:`pytest-pythonhashseed` + *last release*: Feb 25, 2024, + *status*: 4 - Beta, + *requires*: pytest>=3.0.0 + + Pytest plugin to set PYTHONHASHSEED env var. + :pypi:`pytest-pythonpath` *last release*: Feb 10, 2022, *status*: 5 - Production/Stable, @@ -7319,6 +9583,13 @@ This list contains 1267 plugins. pytest plugin for adding to the PYTHONPATH from command line or configs. + :pypi:`pytest-python-test-engineer-sort` + *last release*: May 13, 2024, + *status*: N/A, + *requires*: pytest>=6.2.0 + + Sort plugin for Pytest + :pypi:`pytest-pytorch` *last release*: May 25, 2021, *status*: 4 - Beta, @@ -7326,17 +9597,31 @@ This list contains 1267 plugins. pytest plugin for a better developer experience when working with the PyTorch test suite + :pypi:`pytest-pyvenv` + *last release*: Feb 27, 2024, + *status*: N/A, + *requires*: pytest ; extra == 'test' + + A package for create venv in tests + :pypi:`pytest-pyvista` - *last release*: Mar 19, 2023, + *last release*: Sep 29, 2023, *status*: 4 - Beta, *requires*: pytest>=3.5.0 Pytest-pyvista package + :pypi:`pytest-qanova` + *last release*: Sep 05, 2024, + *status*: 3 - Alpha, + *requires*: pytest + + A pytest plugin to collect test information + :pypi:`pytest-qaseio` - *last release*: May 11, 2023, - *status*: 4 - Beta, - *requires*: pytest (>=7.2.2,<8.0.0) + *last release*: Mar 18, 2025, + *status*: 5 - Production/Stable, + *requires*: pytest<9.0.0,>=7.2.2 Pytest plugin for Qase.io integration @@ -7355,9 +9640,9 @@ This list contains 1267 plugins. Pytest plugin for uploading test results to your QA Touch Testrun. :pypi:`pytest-qgis` - *last release*: Jun 09, 2023, + *last release*: Jun 14, 2024, *status*: 5 - Production/Stable, - *requires*: pytest (>=6.2.5) + *requires*: pytest>=6.0 A pytest plugin for testing QGIS python plugins @@ -7376,16 +9661,16 @@ This list contains 1267 plugins. pytest plugin to generate test result QR codes :pypi:`pytest-qt` - *last release*: Oct 25, 2022, + *last release*: Feb 07, 2024, *status*: 5 - Production/Stable, - *requires*: pytest (>=3.0.0) + *requires*: pytest pytest support for PyQt and PySide applications :pypi:`pytest-qt-app` - *last release*: Dec 23, 2015, + *last release*: Oct 17, 2024, *status*: 5 - Production/Stable, - *requires*: N/A + *requires*: pytest QT app fixture for py.test @@ -7403,10 +9688,17 @@ This list contains 1267 plugins. pytest plugin to generate random data inspired by QuickCheck + :pypi:`pytest_quickify` + *last release*: Jun 14, 2019, + *status*: N/A, + *requires*: pytest + + Run test suites with pytest-quickify. + :pypi:`pytest-rabbitmq` - *last release*: Jun 16, 2023, + *last release*: Oct 15, 2024, *status*: 5 - Production/Stable, - *requires*: pytest (>=6.2) + *requires*: pytest>=6.2 RabbitMQ process and client fixtures for pytest @@ -7467,7 +9759,7 @@ This list contains 1267 plugins. py.test plugin to randomize tests :pypi:`pytest-randomly` - *last release*: May 11, 2022, + *last release*: Oct 25, 2024, *status*: 5 - Production/Stable, *requires*: pytest @@ -7488,12 +9780,19 @@ This list contains 1267 plugins. Randomise the order in which pytest tests are run with some control over the randomness :pypi:`pytest-random-order` - *last release*: Dec 03, 2022, + *last release*: Jan 20, 2024, *status*: 5 - Production/Stable, - *requires*: pytest (>=3.0.0) + *requires*: pytest >=3.0.0 Randomise the order in which pytest tests are run with some control over the randomness + :pypi:`pytest-ranking` + *last release*: Apr 08, 2025, + *status*: 4 - Beta, + *requires*: pytest>=7.4.3 + + A Pytest plugin for faster fault detection via regression test prioritization + :pypi:`pytest-readme` *last release*: Sep 02, 2022, *status*: 5 - Production/Stable, @@ -7502,25 +9801,25 @@ This list contains 1267 plugins. Test your README.md file :pypi:`pytest-reana` - *last release*: Dec 13, 2022, + *last release*: Sep 04, 2024, *status*: 3 - Alpha, *requires*: N/A Pytest fixtures for REANA. :pypi:`pytest-recorder` - *last release*: Mar 30, 2023, + *last release*: Mar 31, 2025, *status*: N/A, *requires*: N/A Pytest plugin, meant to facilitate unit tests writing for tools consumming Web APIs. :pypi:`pytest-recording` - *last release*: Feb 16, 2023, + *last release*: May 08, 2025, *status*: 4 - Beta, - *requires*: pytest (>=3.5.0) + *requires*: pytest>=3.5.0 - A pytest plugin that allows you recording of network interactions via VCR.py + A pytest plugin powered by VCR.py to record and replay HTTP traffic :pypi:`pytest-recordings` *last release*: Aug 13, 2020, @@ -7529,10 +9828,17 @@ This list contains 1267 plugins. Provides pytest plugins for reporting request/response traffic, screenshots, and more to ReportPortal + :pypi:`pytest-record-video` + *last release*: Oct 31, 2024, + *status*: N/A, + *requires*: N/A + + 用例执行过程中录制视频 + :pypi:`pytest-redis` - *last release*: Apr 19, 2023, + *last release*: Nov 27, 2024, *status*: 5 - Production/Stable, - *requires*: pytest (>=6.2) + *requires*: pytest>=6.2 Redis fixtures and fixture factories for Pytest. @@ -7579,18 +9885,18 @@ This list contains 1267 plugins. Management of Pytest dependencies via regex patterns :pypi:`pytest-regressions` - *last release*: Jan 13, 2023, + *last release*: May 30, 2025, *status*: 5 - Production/Stable, - *requires*: pytest (>=6.2.0) + *requires*: pytest>=6.2.0 Easy to use fixtures to write regression tests. :pypi:`pytest-regtest` - *last release*: Jul 08, 2022, + *last release*: Nov 12, 2024, *status*: N/A, - *requires*: N/A + *requires*: pytest>7.2 - pytest plugin for regression tests + pytest plugin for snapshot regression testing :pypi:`pytest-relative-order` *last release*: May 17, 2021, @@ -7599,10 +9905,17 @@ This list contains 1267 plugins. a pytest plugin that sorts tests using "before" and "after" markers + :pypi:`pytest-relative-path` + *last release*: Aug 30, 2024, + *status*: N/A, + *requires*: pytest + + Handle relative path in pytest options or ini configs + :pypi:`pytest-relaxed` - *last release*: May 23, 2023, + *last release*: Mar 29, 2024, *status*: 5 - Production/Stable, - *requires*: pytest (>=7) + *requires*: pytest>=7 Relaxed test discovery/organization for pytest @@ -7614,9 +9927,9 @@ This list contains 1267 plugins. Pytest plugin to create a temporary directory with remote files :pypi:`pytest-remotedata` - *last release*: Dec 12, 2022, - *status*: 3 - Alpha, - *requires*: pytest (>=4.6) + *last release*: Sep 26, 2023, + *status*: 5 - Production/Stable, + *requires*: pytest >=4.6 Pytest plugin for controlling remote data access. @@ -7628,7 +9941,7 @@ This list contains 1267 plugins. Pytest plugin for capturing and mocking connection requests. :pypi:`pytest-remove-stale-bytecode` - *last release*: Mar 04, 2020, + *last release*: Jul 07, 2023, *status*: 4 - Beta, *requires*: pytest @@ -7642,21 +9955,28 @@ This list contains 1267 plugins. Reorder tests depending on their paths and names. :pypi:`pytest-repeat` - *last release*: Oct 31, 2020, + *last release*: Apr 07, 2025, *status*: 5 - Production/Stable, - *requires*: pytest (>=3.6) + *requires*: pytest pytest plugin for repeating tests + :pypi:`pytest_repeater` + *last release*: Feb 09, 2018, + *status*: 1 - Planning, + *requires*: N/A + + py.test plugin for repeating single test multiple times. + :pypi:`pytest-replay` - *last release*: Jun 09, 2021, - *status*: 4 - Beta, - *requires*: pytest (>=3.0.0) + *last release*: Feb 05, 2025, + *status*: 5 - Production/Stable, + *requires*: pytest Saves previous test runs and allow re-execute previous pytest runs to reproduce crashes or flaky tests :pypi:`pytest-repo-health` - *last release*: Apr 17, 2023, + *last release*: May 05, 2025, *status*: 3 - Alpha, *requires*: pytest @@ -7670,26 +9990,40 @@ This list contains 1267 plugins. Creates json report that is compatible with atom.io's linter message format :pypi:`pytest-reporter` - *last release*: Jul 22, 2021, + *last release*: Feb 28, 2024, *status*: 4 - Beta, *requires*: pytest Generate Pytest reports with templates :pypi:`pytest-reporter-html1` - *last release*: Jun 05, 2023, + *last release*: May 06, 2025, *status*: 4 - Beta, *requires*: N/A A basic HTML report template for Pytest :pypi:`pytest-reporter-html-dots` - *last release*: Jan 22, 2023, + *last release*: Apr 26, 2025, *status*: N/A, *requires*: N/A A basic HTML report for pytest using Jinja2 template engine. + :pypi:`pytest-reporter-plus` + *last release*: May 31, 2025, + *status*: N/A, + *requires*: N/A + + Lightweight enhanced HTML reporter for Pytest + + :pypi:`pytest-report-extras` + *last release*: Apr 04, 2025, + *status*: N/A, + *requires*: pytest>=8.0.0 + + Pytest plugin to enhance pytest-html and allure reports by adding comments, screenshots, webpage sources and attachments. + :pypi:`pytest-reportinfra` *last release*: Aug 11, 2019, *status*: 3 - Alpha, @@ -7726,18 +10060,32 @@ This list contains 1267 plugins. pytest plugin for adding tests' parameters to junit report :pypi:`pytest-reportportal` - *last release*: Jun 08, 2023, + *last release*: Feb 28, 2025, *status*: N/A, - *requires*: pytest (>=3.8.0) + *requires*: pytest>=4.6.10 Agent for Reporting results of tests to the Report Portal - :pypi:`pytest-reports` - *last release*: Jun 07, 2023, - *status*: N/A, + :pypi:`pytest-report-stream` + *last release*: Oct 22, 2023, + *status*: 4 - Beta, + *requires*: N/A + + A pytest plugin which allows to stream test reports at runtime + + :pypi:`pytest-repo-structure` + *last release*: Mar 18, 2024, + *status*: 1 - Planning, *requires*: N/A - An interesting python package + Pytest Repo Structure + + :pypi:`pytest-req` + *last release*: Aug 31, 2024, + *status*: 5 - Production/Stable, + *requires*: pytest<9.0.0,>=8.3.2 + + pytest requests plugin :pypi:`pytest-reqs` *last release*: May 12, 2019, @@ -7767,6 +10115,13 @@ This list contains 1267 plugins. Pytest Plugin to Mock Requests Futures + :pypi:`pytest-requirements` + *last release*: Feb 28, 2025, + *status*: N/A, + *requires*: pytest + + pytest plugin for using custom markers to relate tests to requirements and usecases + :pypi:`pytest-requires` *last release*: Dec 21, 2021, *status*: 4 - Beta, @@ -7788,10 +10143,24 @@ This list contains 1267 plugins. Re-run only changed files in specified branch + :pypi:`pytest-rerun-all` + *last release*: Nov 16, 2023, + *status*: 3 - Alpha, + *requires*: pytest (>=7.0.0) + + Rerun testsuite for a certain time or iterations + + :pypi:`pytest-rerunclassfailures` + *last release*: Apr 24, 2024, + *status*: 5 - Production/Stable, + *requires*: pytest>=7.2 + + pytest rerun class failures plugin + :pypi:`pytest-rerunfailures` - *last release*: Mar 09, 2023, + *last release*: May 08, 2025, *status*: 5 - Production/Stable, - *requires*: pytest (>=5.3) + *requires*: pytest!=8.2.2,>=7.4 pytest plugin to re-run tests to eliminate flaky failures @@ -7803,16 +10172,16 @@ This list contains 1267 plugins. pytest plugin to re-run tests to eliminate flaky failures :pypi:`pytest-reserial` - *last release*: Apr 26, 2023, + *last release*: Dec 22, 2024, *status*: 4 - Beta, *requires*: pytest Pytest fixture for recording and replaying serial port traffic. :pypi:`pytest-resilient-circuits` - *last release*: Jun 01, 2023, + *last release*: Feb 28, 2025, *status*: N/A, - *requires*: pytest (~=4.6) ; python_version == "2.7" + *requires*: pytest~=7.0 Resilient Circuits fixtures for PyTest @@ -7824,9 +10193,9 @@ This list contains 1267 plugins. Load resource fixture plugin to use with pytest :pypi:`pytest-resource-path` - *last release*: May 01, 2021, + *last release*: May 15, 2025, *status*: 5 - Production/Stable, - *requires*: pytest (>=3.5.0) + *requires*: pytest>=3.5.0 Provides path for uniform access to test resources in isolated directory @@ -7859,18 +10228,32 @@ This list contains 1267 plugins. :pypi:`pytest-restrict` - *last release*: Jun 16, 2023, + *last release*: Oct 24, 2024, *status*: 5 - Production/Stable, *requires*: pytest Pytest plugin to restrict the test types allowed :pypi:`pytest-result-log` - *last release*: Apr 17, 2023, + *last release*: Jan 10, 2024, *status*: N/A, *requires*: pytest>=7.2.0 - Write the execution result of the case to the log + A pytest plugin that records the start, end, and result information of each use case in a log file + + :pypi:`pytest-result-notify` + *last release*: Apr 27, 2025, + *status*: N/A, + *requires*: pytest>=8.3.5 + + Default template for PDM package + + :pypi:`pytest-results` + *last release*: May 06, 2025, + *status*: 4 - Beta, + *requires*: pytest + + Easily spot regressions in your tests. :pypi:`pytest-result-sender` *last release*: Apr 20, 2023, @@ -7879,6 +10262,34 @@ This list contains 1267 plugins. + :pypi:`pytest-result-sender-jms` + *last release*: May 22, 2025, + *status*: N/A, + *requires*: pytest>=8.3.5 + + Default template for PDM package + + :pypi:`pytest-result-sender-lj` + *last release*: Dec 17, 2024, + *status*: N/A, + *requires*: pytest>=8.3.4 + + Default template for PDM package + + :pypi:`pytest-result-sender-lyt` + *last release*: Mar 14, 2025, + *status*: N/A, + *requires*: pytest>=8.3.5 + + Default template for PDM package + + :pypi:`pytest-result-sender-misszhang` + *last release*: Mar 21, 2025, + *status*: N/A, + *requires*: pytest>=8.3.5 + + Default template for PDM package + :pypi:`pytest-resume` *last release*: Apr 22, 2023, *status*: 4 - Beta, @@ -7894,16 +10305,16 @@ This list contains 1267 plugins. A RethinkDB plugin for pytest. :pypi:`pytest-retry` - *last release*: Aug 16, 2022, + *last release*: Jan 19, 2025, *status*: N/A, - *requires*: pytest (>=7.0.0) + *requires*: pytest>=7.0.0 Adds the ability to retry flaky tests in CI environments :pypi:`pytest-retry-class` - *last release*: Mar 25, 2023, + *last release*: Nov 24, 2024, *status*: N/A, - *requires*: pytest (>=5.3) + *requires*: pytest>=5.3 A pytest plugin to rerun entire class on failure @@ -7914,20 +10325,34 @@ This list contains 1267 plugins. + :pypi:`pytest-revealtype-injector` + *last release*: Mar 18, 2025, + *status*: 4 - Beta, + *requires*: pytest<9,>=7.0 + + Pytest plugin for replacing reveal_type() calls inside test functions with static and runtime type checking result comparison, for confirming type annotation validity. + :pypi:`pytest-reverse` - *last release*: Jun 16, 2023, + *last release*: Oct 25, 2024, *status*: 5 - Production/Stable, *requires*: pytest Pytest plugin to reverse test order. :pypi:`pytest-rich` - *last release*: Mar 03, 2022, + *last release*: Dec 12, 2024, *status*: 4 - Beta, - *requires*: pytest (>=7.0) + *requires*: pytest>=7.0 Leverage rich for richer test session output + :pypi:`pytest-richer` + *last release*: Oct 27, 2023, + *status*: 3 - Alpha, + *requires*: pytest + + Pytest plugin providing a Rich based reporter. + :pypi:`pytest-rich-reporter` *last release*: Feb 17, 2022, *status*: 1 - Planning, @@ -7936,11 +10361,11 @@ This list contains 1267 plugins. A pytest plugin using Rich for beautiful test result formatting. :pypi:`pytest-richtrace` - *last release*: Nov 05, 2022, + *last release*: Jun 20, 2023, *status*: N/A, - *requires*: pytest (>=7.2.0,<8.0.0) - + *requires*: N/A + A pytest plugin that displays the names and information of the pytest hook functions as they are executed. :pypi:`pytest-ringo` *last release*: Sep 27, 2017, @@ -7970,6 +10395,13 @@ This list contains 1267 plugins. pytest plugin for ROAST configuration override and fixtures + :pypi:`pytest_robotframework` + *last release*: Apr 13, 2025, + *status*: N/A, + *requires*: pytest<9,>=7 + + a pytest plugin that can run both python and robotframework tests while generating robot reports for them + :pypi:`pytest-rocketchat` *last release*: Apr 18, 2021, *status*: 5 - Production/Stable, @@ -8013,9 +10445,9 @@ This list contains 1267 plugins. Coverage-based regression test selection (RTS) plugin for pytest :pypi:`pytest-ruff` - *last release*: Jun 08, 2023, + *last release*: Jul 21, 2024, *status*: 4 - Beta, - *requires*: N/A + *requires*: pytest>=5 pytest plugin to check ruff requirements. @@ -8033,12 +10465,12 @@ This list contains 1267 plugins. implement a --failed option for pytest - :pypi:`pytest-runner` - *last release*: Feb 25, 2022, - *status*: 5 - Production/Stable, - *requires*: pytest (>=6) ; extra == 'testing' + :pypi:`pytest-run-parallel` + *last release*: May 27, 2025, + *status*: 4 - Beta, + *requires*: pytest>=6.2.0 - Invoke py.test as distutils command with dependency resolution + A simple pytest plugin to run tests concurrently :pypi:`pytest-run-subprocess` *last release*: Nov 12, 2022, @@ -8068,13 +10500,6 @@ This list contains 1267 plugins. run case mark timeout - :pypi:`pytest-ry-demo1` - *last release*: Mar 26, 2023, - *status*: N/A, - *requires*: N/A - - 测试 - :pypi:`pytest-saccharin` *last release*: Oct 31, 2022, *status*: 3 - Alpha, @@ -8097,9 +10522,9 @@ This list contains 1267 plugins. A Pytest plugin that builds and creates docker containers :pypi:`pytest-salt-factories` - *last release*: Dec 15, 2022, - *status*: 4 - Beta, - *requires*: pytest (>=6.0.0) + *last release*: Oct 22, 2024, + *status*: 5 - Production/Stable, + *requires*: pytest>=7.4.0 Pytest Salt Plugin @@ -8117,6 +10542,13 @@ This list contains 1267 plugins. Simple PyTest Plugin For Salt's Test Suite Specifically + :pypi:`pytest-sample-argvalues` + *last release*: May 07, 2024, + *status*: N/A, + *requires*: pytest + + A utility function to help choose a random sample from your argvalues in pytest. + :pypi:`pytest-sanic` *last release*: Oct 25, 2021, *status*: N/A, @@ -8124,6 +10556,13 @@ This list contains 1267 plugins. a pytest plugin for Sanic + :pypi:`pytest-sanitizer` + *last release*: Mar 16, 2025, + *status*: 3 - Alpha, + *requires*: pytest>=6.0.0 + + A pytest plugin to sanitize output for LLMs (personal tool, no warranty or liability) + :pypi:`pytest-sanity` *last release*: Dec 07, 2020, *status*: N/A, @@ -8138,8 +10577,15 @@ This list contains 1267 plugins. + :pypi:`pytest_sauce` + *last release*: Jul 14, 2014, + *status*: 3 - Alpha, + *requires*: N/A + + pytest_sauce provides sane and helpful methods worked out in clearcode to run py.test tests with selenium/saucelabs + :pypi:`pytest-sbase` - *last release*: Jun 13, 2023, + *last release*: May 27, 2025, *status*: 5 - Production/Stable, *requires*: N/A @@ -8152,20 +10598,48 @@ This list contains 1267 plugins. pytest plugin for test scenarios - :pypi:`pytest-schedule` - *last release*: Jan 07, 2023, + :pypi:`pytest-scenario-files` + *last release*: May 21, 2025, *status*: 5 - Production/Stable, + *requires*: pytest>=7.0 + + A pytest plugin that generates unit test scenarios from data files. + + :pypi:`pytest-schedule` + *last release*: Oct 31, 2024, + *status*: N/A, *requires*: N/A - The job of test scheduling for humans. + Automate and customize test scheduling effortlessly on local machines. :pypi:`pytest-schema` - *last release*: Mar 14, 2022, + *last release*: Feb 16, 2024, *status*: 5 - Production/Stable, - *requires*: pytest (>=3.5.0) + *requires*: pytest >=3.5.0 👍 Validate return values against a schema-like object in testing + :pypi:`pytest-scim2-server` + *last release*: May 14, 2025, + *status*: 4 - Beta, + *requires*: pytest>=8.3.4 + + SCIM2 server fixture for Pytest + + :pypi:`pytest-screenshot-on-failure` + *last release*: Jul 21, 2023, + *status*: 4 - Beta, + *requires*: N/A + + Saves a screenshot when a test case from a pytest execution fails + + :pypi:`pytest-scrutinize` + *last release*: Aug 19, 2024, + *status*: 4 - Beta, + *requires*: pytest>=6 + + Scrutinize your pytest test suites for slow fixtures, tests and more. + :pypi:`pytest-securestore` *last release*: Nov 08, 2021, *status*: 4 - Beta, @@ -8181,14 +10655,21 @@ This list contains 1267 plugins. A pytest plugin which allows to (de-)select tests from a file. :pypi:`pytest-selenium` - *last release*: May 28, 2023, + *last release*: Feb 01, 2024, *status*: 5 - Production/Stable, *requires*: pytest>=6.0.0 pytest plugin for Selenium + :pypi:`pytest-selenium-auto` + *last release*: Nov 07, 2023, + *status*: N/A, + *requires*: pytest >= 7.0.0 + + pytest plugin to automatically capture screenshots upon selenium webdriver events + :pypi:`pytest-seleniumbase` - *last release*: Jun 13, 2023, + *last release*: May 27, 2025, *status*: 5 - Production/Stable, *requires*: N/A @@ -8208,17 +10689,24 @@ This list contains 1267 plugins. A pytest package implementing perceptualdiff for Selenium tests. + :pypi:`pytest-selfie` + *last release*: Dec 16, 2024, + *status*: N/A, + *requires*: pytest>=8.0.0 + + A pytest plugin for selfie snapshot testing. + :pypi:`pytest-send-email` - *last release*: Dec 04, 2019, + *last release*: Sep 02, 2024, *status*: N/A, - *requires*: N/A + *requires*: pytest Send pytest execution result email :pypi:`pytest-sentry` - *last release*: Jan 05, 2023, + *last release*: May 23, 2025, *status*: N/A, - *requires*: N/A + *requires*: pytest A pytest plugin to send testrun information to Sentry.io @@ -8229,12 +10717,19 @@ This list contains 1267 plugins. Pytest plugin for sequencing markers for execution of tests + :pypi:`pytest-server` + *last release*: Sep 09, 2024, + *status*: N/A, + *requires*: N/A + + test server exec cmd + :pypi:`pytest-server-fixtures` - *last release*: May 28, 2019, + *last release*: Nov 29, 2024, *status*: 5 - Production/Stable, *requires*: pytest - Extensible server fixures for py.test + Extensible server fixtures for py.test :pypi:`pytest-serverless` *last release*: May 09, 2022, @@ -8244,12 +10739,19 @@ This list contains 1267 plugins. Automatically mocks resources from serverless.yml in pytest using moto. :pypi:`pytest-servers` - *last release*: Apr 15, 2023, + *last release*: Mar 12, 2025, *status*: 3 - Alpha, - *requires*: pytest (>=6.2) + *requires*: pytest>=6.2 pytest servers + :pypi:`pytest-service` + *last release*: Aug 06, 2024, + *status*: 5 - Production/Stable, + *requires*: pytest>=6.0.0 + + + :pypi:`pytest-services` *last release*: Oct 30, 2020, *status*: 6 - Mature, @@ -8299,6 +10801,20 @@ This list contains 1267 plugins. + :pypi:`pytest-shard-fork` + *last release*: May 17, 2025, + *status*: 4 - Beta, + *requires*: pytest + + Shard tests to support parallelism across multiple machines + + :pypi:`pytest-shared-session-scope` + *last release*: Sep 22, 2024, + *status*: N/A, + *requires*: pytest>=7.0.0 + + Pytest session-scoped fixture that works with xdist + :pypi:`pytest-share-hdf` *last release*: Sep 21, 2022, *status*: 4 - Beta, @@ -8321,9 +10837,9 @@ This list contains 1267 plugins. A pytest plugin to help with testing shell scripts / black box commands :pypi:`pytest-shell-utilities` - *last release*: Sep 23, 2022, - *status*: 4 - Beta, - *requires*: pytest (>=6.0.0) + *last release*: Oct 22, 2024, + *status*: 5 - Production/Stable, + *requires*: pytest>=7.4.0 Pytest plugin to simplify running shell commands against the system @@ -8335,9 +10851,9 @@ This list contains 1267 plugins. Versatile ZODB abstraction layer - pytest fixtures :pypi:`pytest-sherlock` - *last release*: Jan 16, 2023, + *last release*: Aug 14, 2023, *status*: 5 - Production/Stable, - *requires*: pytest (>=3.5.1) + *requires*: pytest >=3.5.1 pytest plugin help to find coupled tests @@ -8349,12 +10865,19 @@ This list contains 1267 plugins. Expand command-line shortcuts listed in pytest configuration :pypi:`pytest-shutil` - *last release*: May 28, 2019, + *last release*: Nov 29, 2024, *status*: 5 - Production/Stable, *requires*: pytest A goodie-bag of unix shell and environment tools for py.test + :pypi:`pytest-simbind` + *last release*: Mar 28, 2024, + *status*: N/A, + *requires*: pytest>=7.0.0 + + Pytest plugin to operate with objects generated by Simbind tool. + :pypi:`pytest-simplehttpserver` *last release*: Jun 24, 2021, *status*: 4 - Beta, @@ -8383,10 +10906,17 @@ This list contains 1267 plugins. Allow for multiple processes to log to a single file + :pypi:`pytest-skip` + *last release*: Apr 04, 2025, + *status*: 3 - Alpha, + *requires*: pytest + + A pytest plugin which allows to (de-)select or skip tests from a file. + :pypi:`pytest-skip-markers` - *last release*: Dec 20, 2022, + *last release*: Aug 09, 2024, *status*: 5 - Production/Stable, - *requires*: pytest (>=7.1.0) + *requires*: pytest>=7.1.0 Pytest Salt Plugin @@ -8411,6 +10941,13 @@ This list contains 1267 plugins. A pytest plugin to skip \`@pytest.mark.slow\` tests by default. + :pypi:`pytest-skipuntil` + *last release*: Nov 25, 2023, + *status*: 4 - Beta, + *requires*: pytest >=3.8.0 + + A simple pytest plugin to skip flapping test with deadline + :pypi:`pytest-slack` *last release*: Dec 15, 2020, *status*: 5 - Production/Stable, @@ -8432,10 +10969,17 @@ This list contains 1267 plugins. Sort tests by their last duration, slowest first + :pypi:`pytest-slow-first` + *last release*: Jan 30, 2024, + *status*: 4 - Beta, + *requires*: pytest >=3.5.0 + + Prioritize running the slowest tests first. + :pypi:`pytest-slow-last` - *last release*: Dec 10, 2022, + *last release*: Mar 16, 2025, *status*: 4 - Beta, - *requires*: pytest (>=3.5.0) + *requires*: pytest>=3.5.0 Run tests in order of execution time (faster tests first) @@ -8460,6 +11004,13 @@ This list contains 1267 plugins. Automated bad smell detection tool for Pytest + :pypi:`pytest-smoke` + *last release*: May 23, 2025, + *status*: 4 - Beta, + *requires*: pytest<9,>=7.0.0 + + Pytest plugin for smoke testing + :pypi:`pytest-smtp` *last release*: Feb 20, 2021, *status*: N/A, @@ -8467,6 +11018,13 @@ This list contains 1267 plugins. Send email with pytest execution result + :pypi:`pytest-smtp4dev` + *last release*: Jun 27, 2023, + *status*: 5 - Production/Stable, + *requires*: N/A + + Plugin for smtp4dev API + :pypi:`pytest-smtpd` *last release*: May 15, 2023, *status*: N/A, @@ -8474,6 +11032,13 @@ This list contains 1267 plugins. An SMTP server for testing built on aiosmtpd + :pypi:`pytest-smtp-test-server` + *last release*: Dec 03, 2023, + *status*: 2 - Pre-Alpha, + *requires*: pytest (>=7.4.3,<8.0.0) + + pytest plugin for using \`smtp-test-server\` as a fixture + :pypi:`pytest-snail` *last release*: Nov 04, 2019, *status*: 3 - Alpha, @@ -8488,6 +11053,13 @@ This list contains 1267 plugins. py.test plugin for Snap-CI + :pypi:`pytest-snapmock` + *last release*: Nov 15, 2024, + *status*: N/A, + *requires*: N/A + + Snapshots for your mocks. + :pypi:`pytest-snapshot` *last release*: Apr 23, 2022, *status*: 4 - Beta, @@ -8495,6 +11067,13 @@ This list contains 1267 plugins. A plugin for snapshot testing with pytest. + :pypi:`pytest-snapshot-with-message-generator` + *last release*: Jul 25, 2023, + *status*: 4 - Beta, + *requires*: pytest (>=3.0.0) + + A plugin for snapshot testing with pytest. + :pypi:`pytest-snmpserver` *last release*: May 12, 2021, *status*: N/A, @@ -8502,6 +11081,13 @@ This list contains 1267 plugins. + :pypi:`pytest-snob` + *last release*: Jan 12, 2025, + *status*: N/A, + *requires*: pytest + + A pytest plugin that only selects meaningful python tests to run. + :pypi:`pytest-snowflake-bdd` *last release*: Jan 05, 2022, *status*: 4 - Beta, @@ -8510,9 +11096,9 @@ This list contains 1267 plugins. Setup test data and run tests on snowflake in BDD style! :pypi:`pytest-socket` - *last release*: Feb 03, 2023, + *last release*: Jan 28, 2024, *status*: 4 - Beta, - *requires*: pytest (>=3.6.3) + *requires*: pytest (>=6.2.5) Pytest Plugin to disable socket calls during tests @@ -8544,6 +11130,13 @@ This list contains 1267 plugins. Solr process and client fixtures for py.test. + :pypi:`pytest-sort` + *last release*: Mar 22, 2025, + *status*: N/A, + *requires*: pytest>=7.4.0 + + Tools for sorting test cases + :pypi:`pytest-sorter` *last release*: Apr 20, 2021, *status*: 4 - Beta, @@ -8552,7 +11145,7 @@ This list contains 1267 plugins. A simple plugin to first execute tests that historically failed more :pypi:`pytest-sosu` - *last release*: Feb 14, 2023, + *last release*: Aug 04, 2023, *status*: 2 - Pre-Alpha, *requires*: pytest @@ -8566,7 +11159,7 @@ This list contains 1267 plugins. Test-ordering plugin for pytest :pypi:`pytest-spark` - *last release*: Feb 23, 2020, + *last release*: May 21, 2025, *status*: 4 - Beta, *requires*: pytest @@ -8580,16 +11173,16 @@ This list contains 1267 plugins. py.test plugin to spawn process and communicate with them. :pypi:`pytest-spec` - *last release*: May 04, 2021, + *last release*: Aug 04, 2024, *status*: N/A, - *requires*: N/A + *requires*: pytest; extra == "test" Library pytest-spec is a pytest plugin to display test execution output like a SPECIFICATION. :pypi:`pytest-spec2md` - *last release*: Jun 26, 2022, + *last release*: Apr 10, 2024, *status*: N/A, - *requires*: pytest (>7.0) + *requires*: pytest>7.0 Library pytest-spec2md is a pytest plugin to create a markdown specification while running pytest. @@ -8601,18 +11194,18 @@ This list contains 1267 plugins. Modern benchmarking library for python with pytest integration. :pypi:`pytest-sphinx` - *last release*: Sep 06, 2022, + *last release*: Apr 13, 2024, *status*: 4 - Beta, - *requires*: pytest (>=7.0.0) + *requires*: pytest>=8.1.1 Doctest plugin for pytest with support for Sphinx-specific doctest-directives :pypi:`pytest-spiratest` - *last release*: Feb 08, 2022, + *last release*: Jan 01, 2024, *status*: N/A, *requires*: N/A - Exports unit tests as test runs in SpiraTest/Team/Plan + Exports unit tests as test runs in Spira (SpiraTest/Team/Plan) :pypi:`pytest-splinter` *last release*: Sep 09, 2022, @@ -8622,14 +11215,21 @@ This list contains 1267 plugins. Splinter plugin for pytest testing framework :pypi:`pytest-splinter4` - *last release*: Jun 11, 2022, + *last release*: Feb 01, 2024, *status*: 6 - Mature, - *requires*: pytest (<8.0,>=7.1.2) + *requires*: pytest >=8.0.0 Pytest plugin for the splinter automation library :pypi:`pytest-split` - *last release*: Apr 12, 2023, + *last release*: Oct 16, 2024, + *status*: 4 - Beta, + *requires*: pytest<9,>=5 + + Pytest plugin which splits the test suite to equally sized sub suites based on test execution time. + + :pypi:`pytest-split-ext` + *last release*: Sep 23, 2023, *status*: 4 - Beta, *requires*: pytest (>=5,<8) @@ -8657,14 +11257,14 @@ This list contains 1267 plugins. :pypi:`pytest-splunk-addon` - *last release*: Feb 22, 2023, + *last release*: May 14, 2025, *status*: N/A, - *requires*: pytest (>5.4.0,<7.3) + *requires*: pytest<8,>5.4.0 A Dynamic test tool for Splunk Apps and Add-ons :pypi:`pytest-splunk-addon-ui-smartx` - *last release*: Mar 07, 2023, + *last release*: Mar 19, 2025, *status*: N/A, *requires*: N/A @@ -8685,16 +11285,16 @@ This list contains 1267 plugins. sqitch for pytest :pypi:`pytest-sqlalchemy` - *last release*: Mar 13, 2018, + *last release*: Apr 19, 2025, *status*: 3 - Alpha, - *requires*: N/A + *requires*: pytest>=8.0 pytest plugin with sqlalchemy related fixtures :pypi:`pytest-sqlalchemy-mock` - *last release*: Mar 15, 2023, + *last release*: Aug 10, 2024, *status*: 3 - Alpha, - *requires*: pytest (>=2.0) + *requires*: pytest>=7.0.0 pytest sqlalchemy plugin for mock @@ -8719,6 +11319,13 @@ This list contains 1267 plugins. A pytest plugin to use sqlfluff to enable format checking of sql files. + :pypi:`pytest-sqlguard` + *last release*: Mar 11, 2025, + *status*: 4 - Beta, + *requires*: pytest>=7 + + Pytest fixture to record and check SQL Queries made by SQLAlchemy + :pypi:`pytest-squadcast` *last release*: Feb 22, 2022, *status*: 5 - Production/Stable, @@ -8747,20 +11354,20 @@ This list contains 1267 plugins. Start pytest run from a given point - :pypi:`pytest-star-track-issue` - *last release*: Feb 10, 2023, - *status*: N/A, - *requires*: N/A - - A package to prevent Dependency Confusion attacks against Yandex. - :pypi:`pytest-static` - *last release*: May 07, 2023, - *status*: 1 - Planning, - *requires*: N/A + *last release*: May 25, 2025, + *status*: 3 - Alpha, + *requires*: pytest<8.0.0,>=7.4.3 pytest-static + :pypi:`pytest-stats` + *last release*: Jul 18, 2024, + *status*: N/A, + *requires*: pytest>=8.0.0 + + Collects tests metadata for future analysis, easy to extend for any data store + :pypi:`pytest-statsd` *last release*: Nov 30, 2018, *status*: 5 - Production/Stable, @@ -8768,6 +11375,13 @@ This list contains 1267 plugins. pytest plugin for reporting to graphite + :pypi:`pytest-status` + *last release*: Aug 22, 2024, + *status*: N/A, + *requires*: pytest + + Add status mark for tests + :pypi:`pytest-stepfunctions` *last release*: May 08, 2021, *status*: 4 - Beta, @@ -8790,12 +11404,19 @@ This list contains 1267 plugins. Run a test suite one failing test at a time. :pypi:`pytest-stf` - *last release*: Dec 04, 2022, + *last release*: Sep 24, 2024, *status*: N/A, - *requires*: pytest (>=5.0) + *requires*: pytest>=5.0 pytest plugin for openSTF + :pypi:`pytest-stochastics` + *last release*: Dec 01, 2024, + *status*: N/A, + *requires*: pytest<9.0.0,>=8.0.0 + + pytest plugin that allows selectively running tests several times and accepting \*some\* failures. + :pypi:`pytest-stoq` *last release*: Feb 09, 2021, *status*: 4 - Beta, @@ -8803,6 +11424,20 @@ This list contains 1267 plugins. A plugin to pytest stoq + :pypi:`pytest-store` + *last release*: Sep 04, 2024, + *status*: 3 - Alpha, + *requires*: pytest>=7.0.0 + + Pytest plugin to store values from test runs + + :pypi:`pytest-streaming` + *last release*: May 28, 2025, + *status*: 5 - Production/Stable, + *requires*: pytest>=8.3.5 + + Plugin for testing pubsub, pulsar, and kafka systems with pytest locally and in ci/cd + :pypi:`pytest-stress` *last release*: Dec 07, 2019, *status*: 4 - Beta, @@ -8811,7 +11446,7 @@ This list contains 1267 plugins. A Pytest plugin that allows you to loop tests for a user defined amount of time. :pypi:`pytest-structlog` - *last release*: Dec 18, 2022, + *last release*: Jul 25, 2024, *status*: N/A, *requires*: pytest @@ -8845,10 +11480,17 @@ This list contains 1267 plugins. A pytest plugin to organize long run tests (named studies) without interfering the regular tests + :pypi:`pytest-subinterpreter` + *last release*: Nov 25, 2023, + *status*: N/A, + *requires*: pytest>=7.0.0 + + Run pytest in a subinterpreter + :pypi:`pytest-subprocess` - *last release*: Jan 28, 2023, + *last release*: Jan 04, 2025, *status*: 5 - Production/Stable, - *requires*: pytest (>=4.0.0) + *requires*: pytest>=4.0.0 A plugin to fake subprocess for pytest @@ -8860,23 +11502,23 @@ This list contains 1267 plugins. A hack to explicitly set up and tear down fixtures. :pypi:`pytest-subtests` - *last release*: May 15, 2023, + *last release*: Dec 10, 2024, *status*: 4 - Beta, - *requires*: pytest (>=7.0) + *requires*: pytest>=7.4 unittest subTest() support and subtests fixture :pypi:`pytest-subunit` - *last release*: Aug 29, 2017, + *last release*: Sep 17, 2023, *status*: N/A, - *requires*: N/A + *requires*: pytest (>=2.3) pytest-subunit is a plugin for py.test which outputs testsresult in subunit format. :pypi:`pytest-sugar` - *last release*: Apr 10, 2023, + *last release*: Feb 01, 2024, *status*: 4 - Beta, - *requires*: pytest (>=6.2.0) + *requires*: pytest >=6.2.0 pytest-sugar is a plugin for pytest that changes the default look and feel of pytest (e.g. progressbar, show tests that fail instantly). @@ -8887,8 +11529,22 @@ This list contains 1267 plugins. A simple plugin to use with pytest + :pypi:`pytest-suite-timeout` + *last release*: Jan 26, 2024, + *status*: N/A, + *requires*: pytest>=7.0.0 + + A pytest plugin for ensuring max suite time + + :pypi:`pytest-supercov` + *last release*: Jul 02, 2023, + *status*: N/A, + *requires*: N/A + + Pytest plugin for measuring explicit test-file to source-file coverage + :pypi:`pytest-svn` - *last release*: May 28, 2019, + *last release*: Oct 17, 2024, *status*: 5 - Production/Stable, *requires*: pytest @@ -8915,15 +11571,15 @@ This list contains 1267 plugins. Pyst - Pytest System-Test Plugin - :pypi:`pytest-tagging` - *last release*: Apr 01, 2023, + :pypi:`pytest_tagging` + *last release*: Nov 08, 2024, *status*: N/A, - *requires*: pytest (>=7.1.3,<8.0.0) + *requires*: pytest>=7.1.3 a pytest plugin to tag tests :pypi:`pytest-takeltest` - *last release*: Feb 15, 2023, + *last release*: Sep 07, 2024, *status*: N/A, *requires*: N/A @@ -8944,9 +11600,9 @@ This list contains 1267 plugins. A Pytest plugin to generate realtime summary stats, and display them in-console using a text-based dashboard. :pypi:`pytest-tap` - *last release*: Oct 27, 2021, + *last release*: Jan 30, 2025, *status*: 5 - Production/Stable, - *requires*: pytest (>=3.0) + *requires*: pytest>=3.0 Test Anything Protocol (TAP) reporting plugin for pytest @@ -8964,6 +11620,13 @@ This list contains 1267 plugins. Pytest plugin for remote target orchestration. + :pypi:`pytest-taskgraph` + *last release*: Apr 09, 2025, + *status*: N/A, + *requires*: pytest + + Add your description here + :pypi:`pytest-tblineinfo` *last release*: Dec 01, 2015, *status*: 3 - Alpha, @@ -8978,6 +11641,13 @@ This list contains 1267 plugins. A pytest plugin for testing TCP clients + :pypi:`pytest-tdd` + *last release*: Aug 18, 2023, + *status*: 4 - Beta, + *requires*: N/A + + run pytest on a python module + :pypi:`pytest-teamcity-logblock` *last release*: May 15, 2018, *status*: 4 - Beta, @@ -8985,15 +11655,22 @@ This list contains 1267 plugins. py.test plugin to introduce block structure in teamcity build log, if output is not captured + :pypi:`pytest-teardown` + *last release*: Apr 15, 2025, + *status*: N/A, + *requires*: pytest<9.0.0,>=7.4.1 + + + :pypi:`pytest-telegram` - *last release*: Dec 10, 2020, + *last release*: Apr 25, 2024, *status*: 5 - Production/Stable, *requires*: N/A Pytest to Telegram reporting plugin :pypi:`pytest-telegram-notifier` - *last release*: Mar 17, 2023, + *last release*: Jun 27, 2023, *status*: 5 - Production/Stable, *requires*: N/A @@ -9014,9 +11691,9 @@ This list contains 1267 plugins. Terraform and Terragrunt fixtures for pytest :pypi:`pytest-terraform` - *last release*: Sep 01, 2022, + *last release*: May 21, 2024, *status*: N/A, - *requires*: pytest (>=6.0) + *requires*: pytest>=6.0 A pytest plugin for using terraform fixtures @@ -9041,6 +11718,13 @@ This list contains 1267 plugins. Test configuration plugin for pytest. + :pypi:`pytest-testdata` + *last release*: Aug 30, 2024, + *status*: N/A, + *requires*: pytest + + Get and load testdata in pytest projects + :pypi:`pytest-testdirectory` *last release*: May 02, 2023, *status*: 5 - Production/Stable, @@ -9049,7 +11733,7 @@ This list contains 1267 plugins. A py.test plugin providing temporary directories in unit tests. :pypi:`pytest-testdox` - *last release*: Apr 19, 2022, + *last release*: Jul 22, 2023, *status*: 5 - Production/Stable, *requires*: pytest (>=4.6.0) @@ -9063,19 +11747,40 @@ This list contains 1267 plugins. A Pytest plugin for running a subset of your tests by splitting them in to equally sized groups. :pypi:`pytest-test-groups` - *last release*: Oct 25, 2016, + *last release*: May 08, 2025, *status*: 5 - Production/Stable, - *requires*: N/A + *requires*: pytest>=7.0.0 A Pytest plugin for running a subset of your tests by splitting them in to equally sized groups. :pypi:`pytest-testinfra` - *last release*: May 21, 2023, + *last release*: Mar 30, 2025, + *status*: 5 - Production/Stable, + *requires*: pytest>=6 + + Test infrastructures + + :pypi:`pytest-testinfra-jpic` + *last release*: Sep 21, 2023, + *status*: 5 - Production/Stable, + *requires*: N/A + + Test infrastructures + + :pypi:`pytest-testinfra-winrm-transport` + *last release*: Sep 21, 2023, *status*: 5 - Production/Stable, - *requires*: pytest (!=3.0.2) + *requires*: N/A Test infrastructures + :pypi:`pytest-testit-parametrize` + *last release*: Dec 04, 2024, + *status*: 4 - Beta, + *requires*: pytest>=8.3.3 + + A pytest plugin for uploading parameterized tests parameters into TMS TestIT + :pypi:`pytest-testlink-adaptor` *last release*: Dec 20, 2018, *status*: 4 - Beta, @@ -9084,9 +11789,9 @@ This list contains 1267 plugins. pytest reporting plugin for testlink :pypi:`pytest-testmon` - *last release*: Jun 16, 2023, + *last release*: Dec 22, 2024, *status*: 4 - Beta, - *requires*: N/A + *requires*: pytest<9,>=5 selects tests affected by changed files and methods @@ -9139,6 +11844,13 @@ This list contains 1267 plugins. A pytest plugin to upload results to TestRail. + :pypi:`pytest-testrail-api` + *last release*: Mar 17, 2025, + *status*: N/A, + *requires*: pytest + + TestRail Api Python Client + :pypi:`pytest-testrail-api-client` *last release*: Dec 14, 2021, *status*: N/A, @@ -9195,6 +11907,13 @@ This list contains 1267 plugins. + :pypi:`pytest-testrail-results` + *last release*: Mar 04, 2024, + *status*: N/A, + *requires*: pytest >=7.2.0 + + A pytest plugin to upload results to TestRail. + :pypi:`pytest-testreport` *last release*: Dec 01, 2022, *status*: 4 - Beta, @@ -9203,9 +11922,9 @@ This list contains 1267 plugins. :pypi:`pytest-testreport-new` - *last release*: Aug 15, 2022, + *last release*: Oct 07, 2023, *status*: 4 - Beta, - *requires*: pytest (>=3.5.0) + *requires*: pytest >=3.5.0 @@ -9223,20 +11942,41 @@ This list contains 1267 plugins. Plugin for py.test to run relevant tests, based on naively checking if a test contains a reference to the symbol you supply + :pypi:`pytest-test-tracer-for-pytest` + *last release*: Jun 28, 2024, + *status*: 4 - Beta, + *requires*: pytest>=6.2.0 + + A plugin that allows coll test data for use on Test Tracer + + :pypi:`pytest-test-tracer-for-pytest-bdd` + *last release*: Aug 20, 2024, + *status*: 4 - Beta, + *requires*: pytest>=6.2.0 + + A plugin that allows coll test data for use on Test Tracer + :pypi:`pytest-test-utils` - *last release*: Jul 14, 2022, + *last release*: Feb 08, 2024, *status*: N/A, - *requires*: pytest (>=5) + *requires*: pytest >=3.9 :pypi:`pytest-tesults` - *last release*: Dec 23, 2022, + *last release*: Nov 12, 2024, *status*: 5 - Production/Stable, - *requires*: pytest (>=3.5.0) + *requires*: pytest>=3.5.0 Tesults plugin for pytest + :pypi:`pytest-textual-snapshot` + *last release*: Jan 23, 2025, + *status*: 5 - Production/Stable, + *requires*: pytest>=8.0.0 + + Snapshot testing for Textual apps + :pypi:`pytest-tezos` *last release*: Jan 16, 2020, *status*: 4 - Beta, @@ -9244,6 +11984,13 @@ This list contains 1267 plugins. pytest-ligo + :pypi:`pytest-tf` + *last release*: May 29, 2024, + *status*: N/A, + *requires*: pytest<9.0.0,>=8.2.1 + + Test your OpenTofu and Terraform config using a PyTest plugin + :pypi:`pytest-th2-bdd` *last release*: May 13, 2022, *status*: N/A, @@ -9258,6 +12005,13 @@ This list contains 1267 plugins. Pytest plugin for time travel + :pypi:`pytest-thread` + *last release*: Jul 07, 2023, + *status*: N/A, + *requires*: N/A + + + :pypi:`pytest-threadleak` *last release*: Jul 03, 2022, *status*: 4 - Beta, @@ -9273,12 +12027,19 @@ This list contains 1267 plugins. Ticking on tests :pypi:`pytest-time` - *last release*: Jun 16, 2023, + *last release*: Jan 20, 2025, *status*: 3 - Alpha, *requires*: pytest + :pypi:`pytest-timeassert-ethan` + *last release*: Dec 25, 2023, + *status*: N/A, + *requires*: pytest + + execution duration + :pypi:`pytest-timeit` *last release*: Oct 13, 2016, *status*: 4 - Beta, @@ -9287,9 +12048,9 @@ This list contains 1267 plugins. A pytest plugin to time test function runs :pypi:`pytest-timeout` - *last release*: Jan 18, 2022, + *last release*: May 05, 2025, *status*: 5 - Production/Stable, - *requires*: pytest (>=5.0.0) + *requires*: pytest>=7.0.0 pytest plugin to abort hanging tests @@ -9301,37 +12062,51 @@ This list contains 1267 plugins. Linux-only Pytest plugin to control durations of various test case execution phases :pypi:`pytest-timer` - *last release*: Jun 02, 2021, + *last release*: Dec 26, 2023, *status*: N/A, - *requires*: N/A + *requires*: pytest A timer plugin for pytest :pypi:`pytest-timestamper` - *last release*: Jun 06, 2021, + *last release*: Mar 27, 2024, *status*: N/A, *requires*: N/A Pytest plugin to add a timestamp prefix to the pytest output :pypi:`pytest-timestamps` - *last release*: Apr 01, 2023, + *last release*: Sep 11, 2023, *status*: N/A, - *requires*: pytest (>=5.2) + *requires*: pytest (>=7.3,<8.0) A simple plugin to view timestamps for each test - :pypi:`pytest-tipsi-django` - *last release*: Nov 17, 2021, + :pypi:`pytest-tiny-api-client` + *last release*: Jan 04, 2024, + *status*: 5 - Production/Stable, + *requires*: pytest + + The companion pytest plugin for tiny-api-client + + :pypi:`pytest-tinybird` + *last release*: May 07, 2025, *status*: 4 - Beta, - *requires*: pytest (>=6.0.0) + *requires*: pytest>=3.8.0 + A pytest plugin to report test results to tinybird + :pypi:`pytest-tipsi-django` + *last release*: Feb 05, 2024, + *status*: 5 - Production/Stable, + *requires*: pytest>=6.0.0 + + Better fixtures for django :pypi:`pytest-tipsi-testing` - *last release*: Nov 04, 2020, - *status*: 4 - Beta, - *requires*: pytest (>=3.3.0) + *last release*: Feb 04, 2024, + *status*: 5 - Production/Stable, + *requires*: pytest>=3.3.0 Better fixtures management. Various helpers @@ -9357,7 +12132,7 @@ This list contains 1267 plugins. A small example package :pypi:`pytest-tmp-files` - *last release*: Apr 03, 2022, + *last release*: Dec 08, 2023, *status*: N/A, *requires*: pytest @@ -9412,6 +12187,13 @@ This list contains 1267 plugins. Numerous useful plugins for pytest. + :pypi:`pytest-toolkit` + *last release*: Jun 07, 2024, + *status*: N/A, + *requires*: N/A + + Useful utils for testing + :pypi:`pytest-tools` *last release*: Oct 21, 2022, *status*: 4 - Beta, @@ -9419,6 +12201,13 @@ This list contains 1267 plugins. Pytest tools + :pypi:`pytest-topo` + *last release*: Jun 05, 2024, + *status*: N/A, + *requires*: pytest>=7.0.0 + + Topological sorting for pytest + :pypi:`pytest-tornado` *last release*: Jun 17, 2020, *status*: 5 - Production/Stable, @@ -9462,9 +12251,9 @@ This list contains 1267 plugins. :pypi:`pytest-translations` - *last release*: Nov 05, 2021, + *last release*: Sep 11, 2023, *status*: 5 - Production/Stable, - *requires*: N/A + *requires*: pytest (>=7) Test your translation files. @@ -9532,12 +12321,19 @@ This list contains 1267 plugins. Test Class Base :pypi:`pytest-tui` - *last release*: Jun 12, 2023, + *last release*: Dec 08, 2023, *status*: 4 - Beta, *requires*: N/A Text User Interface (TUI) and HTML report for Pytest test runs + :pypi:`pytest-tuitest` + *last release*: Apr 11, 2025, + *status*: N/A, + *requires*: pytest>=7.4.0 + + pytest plugin for testing TUI and regular command-line applications. + :pypi:`pytest-tutorials` *last release*: Mar 11, 2023, *status*: N/A, @@ -9553,9 +12349,9 @@ This list contains 1267 plugins. :pypi:`pytest-twisted` - *last release*: Oct 16, 2022, + *last release*: Sep 10, 2024, *status*: 5 - Production/Stable, - *requires*: pytest (>=2.3) + *requires*: pytest>=2.3 A twisted plugin for pytest. @@ -9566,6 +12362,13 @@ This list contains 1267 plugins. Run type checkers on specified test files + :pypi:`pytest-typed-schema-shot` + *last release*: May 24, 2025, + *status*: N/A, + *requires*: pytest + + Pytest plugin for automatic JSON Schema generation and validation from examples + :pypi:`pytest-typhoon-config` *last release*: Apr 07, 2022, *status*: 5 - Production/Stable, @@ -9573,13 +12376,27 @@ This list contains 1267 plugins. A Typhoon HIL plugin that facilitates test parameter configuration at runtime + :pypi:`pytest-typhoon-polarion` + *last release*: Feb 01, 2024, + *status*: 4 - Beta, + *requires*: N/A + + Typhoontest plugin for Siemens Polarion + :pypi:`pytest-typhoon-xray` - *last release*: Jun 10, 2023, + *last release*: Aug 15, 2023, *status*: 4 - Beta, *requires*: N/A Typhoon HIL plugin for pytest + :pypi:`pytest-typing-runner` + *last release*: May 31, 2025, + *status*: N/A, + *requires*: N/A + + Pytest plugin to make it easier to run and check python code against static type checkers + :pypi:`pytest-tytest` *last release*: May 25, 2020, *status*: 4 - Beta, @@ -9615,10 +12432,17 @@ This list contains 1267 plugins. UI自动测试失败时自动截图,并将截图加入到Allure测试报告中 + :pypi:`pytest-uncollect-if` + *last release*: Dec 26, 2024, + *status*: 4 - Beta, + *requires*: pytest>=6.2.0 + + A plugin to uncollect pytests tests rather than using skipif + :pypi:`pytest-unflakable` - *last release*: Mar 24, 2023, + *last release*: Apr 30, 2024, *status*: 4 - Beta, - *requires*: pytest (>=6.2.0) + *requires*: pytest>=6.2.0 Unflakable plugin for PyTest @@ -9629,6 +12453,13 @@ This list contains 1267 plugins. Plugin for py.test set a different exit code on uncaught exceptions + :pypi:`pytest-unique` + *last release*: Mar 23, 2025, + *status*: N/A, + *requires*: pytest<8.0.0,>=7.4.2 + + Pytest fixture to generate unique values. + :pypi:`pytest-unittest-filter` *last release*: Jan 12, 2019, *status*: 4 - Beta, @@ -9636,6 +12467,20 @@ This list contains 1267 plugins. A pytest plugin for filtering unittest-based test classes + :pypi:`pytest-unittest-id-runner` + *last release*: Feb 09, 2025, + *status*: N/A, + *requires*: pytest>=6.0.0 + + A pytest plugin to run tests using unittest-style test IDs + + :pypi:`pytest-unmagic` + *last release*: Oct 22, 2024, + *status*: 5 - Production/Stable, + *requires*: pytest + + Pytest fixtures with conventional import semantics + :pypi:`pytest-unmarked` *last release*: Aug 27, 2019, *status*: 5 - Production/Stable, @@ -9644,9 +12489,9 @@ This list contains 1267 plugins. Run only unmarked tests :pypi:`pytest-unordered` - *last release*: Nov 28, 2022, + *last release*: Jul 05, 2024, *status*: 4 - Beta, - *requires*: pytest (>=6.0.0) + *requires*: pytest>=7.0.0 Test equality of unordered collections in pytest @@ -9658,9 +12503,9 @@ This list contains 1267 plugins. Set a test as unstable to return 0 even if it failed :pypi:`pytest-unused-fixtures` - *last release*: Jun 15, 2023, + *last release*: Mar 15, 2025, *status*: 4 - Beta, - *requires*: pytest (>=7.3.2,<8.0.0) + *requires*: pytest>7.3.2 A pytest plugin to list unused fixtures after a test run. @@ -9693,7 +12538,7 @@ This list contains 1267 plugins. :pypi:`pytest-variables` - *last release*: May 27, 2023, + *last release*: Feb 01, 2024, *status*: 5 - Production/Stable, *requires*: pytest>=7.0.0 @@ -9714,9 +12559,9 @@ This list contains 1267 plugins. Plugin for managing VCR.py cassettes :pypi:`pytest-vcr-delete-on-fail` - *last release*: Jun 20, 2022, + *last release*: Feb 16, 2024, *status*: 5 - Production/Stable, - *requires*: pytest (>=6.2.2) + *requires*: pytest (>=8.0.0,<9.0.0) A pytest plugin that automates vcrpy cassettes deletion on test failure. @@ -9735,21 +12580,14 @@ This list contains 1267 plugins. :pypi:`pytest-venv` - *last release*: Aug 04, 2020, + *last release*: Nov 23, 2023, *status*: 4 - Beta, *requires*: pytest py.test fixture for creating a virtual environment - :pypi:`pytest-ver` - *last release*: Mar 22, 2023, - *status*: 4 - Beta, - *requires*: N/A - - Pytest module with Verification Protocol, Verification Report and Trace Matrix - :pypi:`pytest-verbose-parametrize` - *last release*: May 28, 2019, + *last release*: Nov 29, 2024, *status*: 5 - Production/Stable, *requires*: pytest @@ -9763,14 +12601,21 @@ This list contains 1267 plugins. A simple pytest plugin that will shrink pytest output when specified, to fit vim quickfix window. :pypi:`pytest-virtualenv` - *last release*: May 28, 2019, + *last release*: Nov 29, 2024, *status*: 5 - Production/Stable, *requires*: pytest Virtualenv fixture for py.test + :pypi:`pytest-visual` + *last release*: Nov 28, 2024, + *status*: 4 - Beta, + *requires*: pytest>=7.0.0 + + + :pypi:`pytest-vnc` - *last release*: Feb 25, 2023, + *last release*: Nov 06, 2023, *status*: N/A, *requires*: pytest @@ -9797,6 +12642,13 @@ This list contains 1267 plugins. A PyTest helper to enable start remote debugger on test start or failure or when pytest.set_trace is used. + :pypi:`pytest-vtestify` + *last release*: Feb 04, 2025, + *status*: N/A, + *requires*: pytest + + A pytest plugin for visual assertion using SSIM and image comparison. + :pypi:`pytest-vts` *last release*: Jun 05, 2019, *status*: N/A, @@ -9805,9 +12657,9 @@ This list contains 1267 plugins. pytest plugin for automatic recording of http stubbed tests :pypi:`pytest-vulture` - *last release*: Jun 01, 2023, + *last release*: Nov 25, 2024, *status*: N/A, - *requires*: pytest (>=7.0.0) + *requires*: pytest>=7.0.0 A pytest plugin to checks dead code with vulture @@ -9833,7 +12685,7 @@ This list contains 1267 plugins. Pytest plugin for testing whatsapp bots with end to end tests :pypi:`pytest-wake` - *last release*: May 11, 2023, + *last release*: Nov 19, 2024, *status*: N/A, *requires*: pytest @@ -9847,12 +12699,26 @@ This list contains 1267 plugins. Local continuous test runner with pytest and watchdog. :pypi:`pytest-watcher` - *last release*: Jun 11, 2023, + *last release*: Aug 28, 2024, *status*: 4 - Beta, *requires*: N/A Automatically rerun your tests on file modifications + :pypi:`pytest-watch-plugin` + *last release*: Sep 12, 2024, + *status*: N/A, + *requires*: N/A + + Placeholder for internal package + + :pypi:`pytest_wdb` + *last release*: Jul 04, 2016, + *status*: N/A, + *requires*: N/A + + Trace pytest tests with wdb to halt on error with --wdb. + :pypi:`pytest-wdl` *last release*: Nov 17, 2020, *status*: 5 - Production/Stable, @@ -9861,19 +12727,33 @@ This list contains 1267 plugins. Pytest plugin for testing WDL workflows. :pypi:`pytest-web3-data` - *last release*: Sep 15, 2022, + *last release*: Oct 04, 2023, *status*: 4 - Beta, *requires*: pytest - + A pytest plugin to fetch test data from IPFS HTTP gateways during pytest execution. :pypi:`pytest-webdriver` - *last release*: May 28, 2019, + *last release*: Oct 17, 2024, *status*: 5 - Production/Stable, *requires*: pytest Selenium webdriver fixture for py.test + :pypi:`pytest-webstage` + *last release*: Sep 20, 2024, + *status*: N/A, + *requires*: pytest<9.0,>=7.0 + + Test web apps with pytest + + :pypi:`pytest-webtest-extras` + *last release*: Dec 28, 2024, + *status*: N/A, + *requires*: pytest>=7.0.0 + + Pytest plugin to enhance pytest-html and allure reports of webtest projects by adding screenshots, comments and webpage sources. + :pypi:`pytest-wetest` *last release*: Nov 10, 2018, *status*: 4 - Beta, @@ -9882,7 +12762,7 @@ This list contains 1267 plugins. Welian API Automation test framework pytest plugin :pypi:`pytest-when` - *last release*: Jun 05, 2023, + *last release*: Nov 29, 2024, *status*: N/A, *requires*: pytest>=7.3.1 @@ -9923,6 +12803,13 @@ This list contains 1267 plugins. A pytest plugin for programmatically using wiremock in integration tests + :pypi:`pytest-wiretap` + *last release*: Mar 18, 2025, + *status*: N/A, + *requires*: pytest + + \`pytest\` plugin for recording call stacks + :pypi:`pytest-with-docker` *last release*: Nov 09, 2021, *status*: N/A, @@ -9930,17 +12817,24 @@ This list contains 1267 plugins. pytest with docker helpers. + :pypi:`pytest-workaround-12888` + *last release*: Jan 15, 2025, + *status*: N/A, + *requires*: N/A + + forces an import of readline early in the process to work around pytest bug #12888 + :pypi:`pytest-workflow` - *last release*: Jan 13, 2023, + *last release*: Mar 18, 2024, *status*: 5 - Production/Stable, - *requires*: pytest (>=7.0.0) + *requires*: pytest >=7.0.0 A pytest plugin for configuring workflow/pipeline tests using YAML files :pypi:`pytest-xdist` - *last release*: May 19, 2023, + *last release*: May 26, 2025, *status*: 5 - Production/Stable, - *requires*: pytest (>=6.2.0) + *requires*: pytest>=7.0.0 pytest xdist plugin for distributed testing, most importantly across multiple CPUs @@ -9958,6 +12852,13 @@ This list contains 1267 plugins. forked from pytest-xdist + :pypi:`pytest-xdist-lock` + *last release*: Apr 26, 2025, + *status*: N/A, + *requires*: pytest>=6.0 + + Extension for pytest-xdist adding test and resource group locks for local and distributed runs + :pypi:`pytest-xdist-tracker` *last release*: Nov 18, 2021, *status*: 3 - Alpha, @@ -9966,12 +12867,19 @@ This list contains 1267 plugins. pytest plugin helps to reproduce failures for particular xdist node :pypi:`pytest-xdist-worker-stats` - *last release*: Jun 15, 2023, + *last release*: Mar 15, 2025, *status*: 4 - Beta, - *requires*: pytest (>=7.3.2,<8.0.0) + *requires*: pytest>=7.0.0 A pytest plugin to list worker statistics after a xdist run. + :pypi:`pytest-xdocker` + *last release*: Mar 23, 2025, + *status*: N/A, + *requires*: pytest<8.0.0,>=7.4.2 + + Pytest fixture to run docker across test runs. + :pypi:`pytest-xfaillist` *last release*: Sep 17, 2021, *status*: N/A, @@ -9986,6 +12894,20 @@ This list contains 1267 plugins. Pytest fixtures providing data read from function, module or package related (x)files. + :pypi:`pytest-xflaky` + *last release*: Oct 14, 2024, + *status*: 4 - Beta, + *requires*: pytest>=8.2.1 + + A simple plugin to use with pytest + + :pypi:`pytest-xiuyu` + *last release*: Jul 25, 2023, + *status*: 5 - Production/Stable, + *requires*: N/A + + This is a pytest plugin + :pypi:`pytest-xlog` *last release*: May 31, 2020, *status*: 4 - Beta, @@ -9994,23 +12916,30 @@ This list contains 1267 plugins. Extended logging for test and decorators :pypi:`pytest-xlsx` - *last release*: Mar 01, 2023, + *last release*: Aug 07, 2024, *status*: N/A, - *requires*: pytest>=7.2.0 + *requires*: pytest~=8.2.2 pytest plugin for generating test cases by xlsx(excel) + :pypi:`pytest-xml` + *last release*: Nov 14, 2024, + *status*: 4 - Beta, + *requires*: pytest>=8.0.0 + + Create simple XML results for parsing + :pypi:`pytest-xpara` - *last release*: Oct 30, 2017, + *last release*: Aug 07, 2024, *status*: 3 - Alpha, *requires*: pytest An extended parametrizing plugin of pytest. :pypi:`pytest-xprocess` - *last release*: Jan 05, 2023, + *last release*: May 19, 2024, *status*: 4 - Beta, - *requires*: pytest (>=2.8) + *requires*: pytest>=2.8 A pytest plugin for managing processes across test runs. @@ -10028,6 +12957,13 @@ This list contains 1267 plugins. + :pypi:`pytest-xray-reporter` + *last release*: May 21, 2025, + *status*: 4 - Beta, + *requires*: pytest>=7.0.0 + + Pytest plugin for generating Xray JSON reports + :pypi:`pytest-xray-server` *last release*: May 03, 2022, *status*: 3 - Alpha, @@ -10035,20 +12971,27 @@ This list contains 1267 plugins. - :pypi:`pytest-xskynet` - *last release*: Feb 10, 2023, + :pypi:`pytest-xstress` + *last release*: Jun 01, 2024, *status*: N/A, - *requires*: N/A + *requires*: pytest<9.0.0,>=8.0.0 + - A package to prevent Dependency Confusion attacks against Yandex. :pypi:`pytest-xvfb` - *last release*: May 29, 2023, + *last release*: Mar 12, 2025, *status*: 4 - Beta, - *requires*: pytest (>=2.8.1) + *requires*: pytest>=2.8.1 A pytest plugin to run Xvfb (or Xephyr/Xvnc) for tests. + :pypi:`pytest-xvirt` + *last release*: Dec 15, 2024, + *status*: 4 - Beta, + *requires*: pytest>=7.2.2 + + A pytest plugin to virtualize test. For example to transparently running them on a remote box. + :pypi:`pytest-yaml` *last release*: Oct 05, 2018, *status*: N/A, @@ -10056,12 +12999,19 @@ This list contains 1267 plugins. This plugin is used to load yaml output to your test using pytest framework. + :pypi:`pytest-yaml-fei` + *last release*: Feb 09, 2025, + *status*: N/A, + *requires*: pytest + + a pytest yaml allure package + :pypi:`pytest-yaml-sanmu` - *last release*: May 28, 2023, + *last release*: Jan 03, 2025, *status*: N/A, - *requires*: pytest>=7.2.0 + *requires*: pytest>=8.2.2 - pytest plugin for generating test cases by yaml + Pytest plugin for generating test cases with YAML. In test cases, you can use markers, fixtures, variables, and even call Python functions. :pypi:`pytest-yamltree` *last release*: Mar 02, 2020, @@ -10078,7 +13028,7 @@ This list contains 1267 plugins. Run tests against wsgi apps defined in yaml :pypi:`pytest-yaml-yoyo` - *last release*: Jun 08, 2023, + *last release*: Jun 19, 2023, *status*: N/A, *requires*: pytest (>=7.2.0) @@ -10106,12 +13056,19 @@ This list contains 1267 plugins. PyTest plugin to run tests concurrently, each \`yield\` switch context to other one :pypi:`pytest-yls` - *last release*: Mar 29, 2023, + *last release*: Apr 09, 2025, *status*: N/A, - *requires*: pytest (>=7.2.2,<8.0.0) + *requires*: pytest<9.0.0,>=8.3.3 Pytest plugin to test the YLS as a whole. + :pypi:`pytest-youqu-playwright` + *last release*: Jun 12, 2024, + *status*: N/A, + *requires*: pytest + + pytest-youqu-playwright + :pypi:`pytest-yuk` *last release*: Mar 26, 2021, *status*: N/A, @@ -10133,13 +13090,41 @@ This list contains 1267 plugins. OWASP ZAP plugin for py.test. + :pypi:`pytest-zcc` + *last release*: Jun 02, 2024, + *status*: N/A, + *requires*: N/A + + eee + :pypi:`pytest-zebrunner` - *last release*: Dec 12, 2022, + *last release*: Jul 04, 2024, *status*: 5 - Production/Stable, - *requires*: pytest (>=4.5.0) + *requires*: pytest>=4.5.0 Pytest connector for Zebrunner reporting + :pypi:`pytest-zeebe` + *last release*: Feb 01, 2024, + *status*: N/A, + *requires*: pytest (>=7.4.2,<8.0.0) + + Pytest fixtures for testing Camunda 8 processes using a Zeebe test engine. + + :pypi:`pytest-zephyr-scale-integration` + *last release*: May 15, 2025, + *status*: N/A, + *requires*: pytest + + A library for integrating Jira Zephyr Scale (Adaptavist\TM4J) with pytest + + :pypi:`pytest-zephyr-telegram` + *last release*: Sep 30, 2024, + *status*: N/A, + *requires*: pytest==8.3.2 + + Плагин для отправки данных автотестов в Телеграм и Зефир + :pypi:`pytest-zest` *last release*: Nov 17, 2022, *status*: N/A, @@ -10147,6 +13132,13 @@ This list contains 1267 plugins. Zesty additions to pytest. + :pypi:`pytest-zhongwen-wendang` + *last release*: Mar 04, 2024, + *status*: 4 - Beta, + *requires*: N/A + + PyTest 中文文档 + :pypi:`pytest-zigzag` *last release*: Feb 27, 2019, *status*: 4 - Beta, @@ -10160,3 +13152,17 @@ This list contains 1267 plugins. *requires*: pytest Pytest report plugin for Zulip + + :pypi:`pytest-zy` + *last release*: Mar 24, 2024, + *status*: N/A, + *requires*: pytest~=7.2.0 + + 接口自动化测试框架 + + :pypi:`tursu` + *last release*: May 05, 2025, + *status*: 4 - Beta, + *requires*: pytest>=8.3.5 + + 🎬 A pytest plugin that transpiles Gherkin feature files to Python using AST, enforcing typing for ease of use and debugging. diff --git a/doc/en/reference/reference.rst b/doc/en/reference/reference.rst index c6629db84fa..cfdc3eb3421 100644 --- a/doc/en/reference/reference.rst +++ b/doc/en/reference/reference.rst @@ -1,3 +1,5 @@ +:tocdepth: 3 + .. _`api-reference`: API Reference @@ -5,9 +7,6 @@ API Reference This page contains the full reference to pytest's API. -.. contents:: - :depth: 3 - :local: Constants --------- @@ -21,6 +20,16 @@ The current pytest version, as a string:: >>> pytest.__version__ '7.0.0' +.. _`hidden-param`: + +pytest.HIDDEN_PARAM +~~~~~~~~~~~~~~~~~~~ + +.. versionadded:: 8.4 + +Can be passed to ``ids`` of :py:func:`Metafunc.parametrize ` +or to ``id`` of :func:`pytest.param` to hide a parameter set from the test name. +Can only be used at most 1 time, as test names need to be unique. .. _`version-tuple`: @@ -55,12 +64,20 @@ pytest.fail **Tutorial**: :ref:`skipping` -.. autofunction:: pytest.fail(reason, [pytrace=True, msg=None]) +.. autofunction:: pytest.fail(reason, [pytrace=True]) + +.. class:: pytest.fail.Exception + + The exception raised by :func:`pytest.fail`. pytest.skip ~~~~~~~~~~~ -.. autofunction:: pytest.skip(reason, [allow_module_level=False, msg=None]) +.. autofunction:: pytest.skip(reason, [allow_module_level=False]) + +.. class:: pytest.skip.Exception + + The exception raised by :func:`pytest.skip`. .. _`pytest.importorskip ref`: @@ -74,14 +91,24 @@ pytest.xfail .. autofunction:: pytest.xfail +.. class:: pytest.xfail.Exception + + The exception raised by :func:`pytest.xfail`. + pytest.exit ~~~~~~~~~~~ -.. autofunction:: pytest.exit(reason, [returncode=False, msg=None]) +.. autofunction:: pytest.exit(reason, [returncode=None]) + +.. class:: pytest.exit.Exception + + The exception raised by :func:`pytest.exit`. pytest.main ~~~~~~~~~~~ +**Tutorial**: :ref:`pytest.main-usage` + .. autofunction:: pytest.main pytest.param @@ -132,7 +159,7 @@ pytest.freeze_includes Marks ----- -Marks can be used apply meta data to *test functions* (but not fixtures), which can then be accessed by +Marks can be used to apply metadata to *test functions* (but not fixtures), which can then be accessed by fixtures or plugins. @@ -160,8 +187,7 @@ Add warning filters to marked test items. .. code-block:: python @pytest.mark.filterwarnings("ignore:.*usage will be deprecated.*:DeprecationWarning") - def test_foo(): - ... + def test_foo(): ... .. _`pytest.mark.parametrize ref`: @@ -235,22 +261,24 @@ pytest.mark.xfail Marks a test function as *expected to fail*. -.. py:function:: pytest.mark.xfail(condition=None, *, reason=None, raises=None, run=True, strict=False) +.. py:function:: pytest.mark.xfail(condition=False, *, reason=None, raises=None, run=True, strict=xfail_strict) - :type condition: bool or str - :param condition: + :keyword Union[bool, str] condition: Condition for marking the test function as xfail (``True/False`` or a - :ref:`condition string `). If a bool, you also have + :ref:`condition string `). If a ``bool``, you also have to specify ``reason`` (see :ref:`condition string `). :keyword str reason: Reason why the test function is marked as xfail. - :keyword Type[Exception] raises: - Exception subclass (or tuple of subclasses) expected to be raised by the test function; other exceptions will fail the test. + :keyword raises: + Exception class (or tuple of classes) expected to be raised by the test function; other exceptions will fail the test. + Note that subclasses of the classes passed will also result in a match (similar to how the ``except`` statement works). + :type raises: Type[:py:exc:`Exception`] + :keyword bool run: - If the test function should actually be executed. If ``False``, the function will always xfail and will + Whether the test function should actually be executed. If ``False``, the function will always xfail and will not be executed (useful if a function is segfaulting). :keyword bool strict: - * If ``False`` (the default) the function will be shown in the terminal output as ``xfailed`` if it fails + * If ``False`` the function will be shown in the terminal output as ``xfailed`` if it fails and as ``xpass`` if it passes. In both cases this will not cause the test suite to fail as a whole. This is particularly useful to mark *flaky* tests (tests that fail at random) to be tackled later. * If ``True``, the function will be shown in the terminal output as ``xfailed`` if it fails, but if it @@ -258,6 +286,8 @@ Marks a test function as *expected to fail*. that are always failing and there should be a clear indication if they unexpectedly start to pass (for example a new release of a library fixes a known bug). + Defaults to :confval:`xfail_strict`, which is ``False`` by default. + Custom marks ~~~~~~~~~~~~ @@ -269,8 +299,7 @@ For example: .. code-block:: python @pytest.mark.timeout(10, "slow", method="thread") - def test_function(): - ... + def test_function(): ... Will create and attach a :class:`Mark ` object to the collected :class:`Item `, which can then be accessed by fixtures or hooks with @@ -287,8 +316,7 @@ Example for using multiple custom markers: @pytest.mark.timeout(10, "slow", method="thread") @pytest.mark.slow - def test_function(): - ... + def test_function(): ... When :meth:`Node.iter_markers <_pytest.nodes.Node.iter_markers>` or :meth:`Node.iter_markers_with_node <_pytest.nodes.Node.iter_markers_with_node>` is used with multiple markers, the marker closest to the function will be iterated over first. The above example will result in ``@pytest.mark.slow`` followed by ``@pytest.mark.timeout(...)``. @@ -384,6 +412,16 @@ capsys .. autoclass:: pytest.CaptureFixture() :members: +.. fixture:: capteesys + +capteesys +~~~~~~~~~ + +**Tutorial**: :ref:`captures` + +.. autofunction:: _pytest.capture.capteesys() + :no-auto-options: + .. fixture:: capsysbinary capsysbinary @@ -511,13 +549,14 @@ record_testsuite_property recwarn ~~~~~~~ -**Tutorial**: :ref:`assertwarnings` +**Tutorial**: :ref:`recwarn` .. autofunction:: _pytest.recwarn.recwarn() :no-auto-options: .. autoclass:: pytest.WarningsRecorder() :members: + :special-members: __getitem__, __iter__, __len__ .. fixture:: request @@ -605,19 +644,37 @@ Hooks **Tutorial**: :ref:`writing-plugins` -.. currentmodule:: _pytest.hookspec - Reference to all hooks which can be implemented by :ref:`conftest.py files ` and :ref:`plugins `. +@pytest.hookimpl +~~~~~~~~~~~~~~~~ + +.. function:: pytest.hookimpl + :decorator: + + pytest's decorator for marking functions as hook implementations. + + See :ref:`writinghooks` and :func:`pluggy.HookimplMarker`. + +@pytest.hookspec +~~~~~~~~~~~~~~~~ + +.. function:: pytest.hookspec + :decorator: + + pytest's decorator for marking functions as hook specifications. + + See :ref:`declaringhooks` and :func:`pluggy.HookspecMarker`. + +.. currentmodule:: _pytest.hookspec + Bootstrapping hooks ~~~~~~~~~~~~~~~~~~~ -Bootstrapping hooks called for plugins registered early enough (internal and setuptools plugins). +Bootstrapping hooks called for plugins registered early enough (internal and third-party plugins). .. hook:: pytest_load_initial_conftests .. autofunction:: pytest_load_initial_conftests -.. hook:: pytest_cmdline_preparse -.. autofunction:: pytest_cmdline_preparse .. hook:: pytest_cmdline_parse .. autofunction:: pytest_cmdline_parse .. hook:: pytest_cmdline_main @@ -655,6 +712,8 @@ Collection hooks .. autofunction:: pytest_collection .. hook:: pytest_ignore_collect .. autofunction:: pytest_ignore_collect +.. hook:: pytest_collect_directory +.. autofunction:: pytest_collect_directory .. hook:: pytest_collect_file .. autofunction:: pytest_collect_file .. hook:: pytest_pycollect_makemodule @@ -783,23 +842,16 @@ reporting or interaction with exceptions: .. autofunction:: pytest_leave_pdb -Objects -------- - -Full reference to objects accessible from :ref:`fixtures ` or :ref:`hooks `. - - -CallInfo -~~~~~~~~ - -.. autoclass:: pytest.CallInfo() - :members: +Collection tree objects +----------------------- +These are the collector and item classes (collectively called "nodes") which +make up the collection tree. -Class -~~~~~ +Node +~~~~ -.. autoclass:: pytest.Class() +.. autoclass:: _pytest.nodes.Node() :members: :show-inheritance: @@ -810,52 +862,52 @@ Collector :members: :show-inheritance: -CollectReport -~~~~~~~~~~~~~ +Item +~~~~ -.. autoclass:: pytest.CollectReport() +.. autoclass:: pytest.Item() :members: :show-inheritance: - :inherited-members: -Config -~~~~~~ +File +~~~~ -.. autoclass:: pytest.Config() +.. autoclass:: pytest.File() :members: + :show-inheritance: -ExceptionInfo -~~~~~~~~~~~~~ +FSCollector +~~~~~~~~~~~ -.. autoclass:: pytest.ExceptionInfo() +.. autoclass:: _pytest.nodes.FSCollector() :members: + :show-inheritance: +Session +~~~~~~~ -ExitCode -~~~~~~~~ - -.. autoclass:: pytest.ExitCode +.. autoclass:: pytest.Session() :members: + :show-inheritance: -File -~~~~ +Package +~~~~~~~ -.. autoclass:: pytest.File() +.. autoclass:: pytest.Package() :members: :show-inheritance: +Module +~~~~~~ -FixtureDef -~~~~~~~~~~ - -.. autoclass:: _pytest.fixtures.FixtureDef() +.. autoclass:: pytest.Module() :members: :show-inheritance: -FSCollector -~~~~~~~~~~~ +Class +~~~~~ -.. autoclass:: _pytest.nodes.FSCollector() +.. autoclass:: pytest.Class() :members: :show-inheritance: @@ -873,10 +925,64 @@ FunctionDefinition :members: :show-inheritance: -Item -~~~~ -.. autoclass:: pytest.Item() +Objects +------- + +Objects accessible from :ref:`fixtures ` or :ref:`hooks ` +or importable from ``pytest``. + + +CallInfo +~~~~~~~~ + +.. autoclass:: pytest.CallInfo() + :members: + +CollectReport +~~~~~~~~~~~~~ + +.. autoclass:: pytest.CollectReport() + :members: + :show-inheritance: + :inherited-members: + +Config +~~~~~~ + +.. autoclass:: pytest.Config() + :members: + +Dir +~~~ + +.. autoclass:: pytest.Dir() + :members: + +Directory +~~~~~~~~~ + +.. autoclass:: pytest.Directory() + :members: + +ExceptionInfo +~~~~~~~~~~~~~ + +.. autoclass:: pytest.ExceptionInfo() + :members: + + +ExitCode +~~~~~~~~ + +.. autoclass:: pytest.ExitCode + :members: + + +FixtureDef +~~~~~~~~~~ + +.. autoclass:: pytest.FixtureDef() :members: :show-inheritance: @@ -907,19 +1013,6 @@ Metafunc .. autoclass:: pytest.Metafunc() :members: -Module -~~~~~~ - -.. autoclass:: pytest.Module() - :members: - :show-inheritance: - -Node -~~~~ - -.. autoclass:: _pytest.nodes.Node() - :members: - Parser ~~~~~~ @@ -941,12 +1034,29 @@ PytestPluginManager :inherited-members: :show-inheritance: -Session -~~~~~~~ +RaisesExc +~~~~~~~~~ -.. autoclass:: pytest.Session() +.. autoclass:: pytest.RaisesExc() :members: - :show-inheritance: + + .. autoattribute:: fail_reason + +RaisesGroup +~~~~~~~~~~~ +**Tutorial**: :ref:`assert-matching-exception-groups` + +.. autoclass:: pytest.RaisesGroup() + :members: + + .. autoattribute:: fail_reason + +TerminalReporter +~~~~~~~~~~~~~~~~ + +.. autoclass:: pytest.TerminalReporter + :members: + :inherited-members: TestReport ~~~~~~~~~~ @@ -962,10 +1072,10 @@ TestShortLogReport .. autoclass:: pytest.TestShortLogReport() :members: -_Result +Result ~~~~~~~ -Result object used within :ref:`hook wrappers `, see :py:class:`_Result in the pluggy documentation ` for more information. +Result object used within :ref:`hook wrappers `, see :py:class:`Result in the pluggy documentation ` for more information. Stash ~~~~~ @@ -1055,17 +1165,22 @@ Environment variables that can be used to change pytest's behavior. .. envvar:: CI -When set (regardless of value), pytest acknowledges that is running in a CI process. Alternative to ``BUILD_NUMBER`` variable. +When set (regardless of value), pytest acknowledges that is running in a CI process. Alternative to ``BUILD_NUMBER`` variable. See also :ref:`ci-pipelines`. .. envvar:: BUILD_NUMBER -When set (regardless of value), pytest acknowledges that is running in a CI process. Alternative to CI variable. +When set (regardless of value), pytest acknowledges that is running in a CI process. Alternative to CI variable. See also :ref:`ci-pipelines`. .. envvar:: PYTEST_ADDOPTS This contains a command-line (parsed by the py:mod:`shlex` module) that will be **prepended** to the command line given by the user, see :ref:`adding default options` for more information. +.. envvar:: PYTEST_VERSION + +This environment variable is defined at the start of the pytest session and is undefined afterwards. +It contains the value of ``pytest.__version__``, and among other things can be used to easily check if a code is running from within a pytest run. + .. envvar:: PYTEST_CURRENT_TEST This is not meant to be set by users, but is set by pytest internally with the name of the current test so other @@ -1075,10 +1190,17 @@ processes can inspect it, see :ref:`pytest current test env` for more informatio When set, pytest will print tracing and debug information. +.. envvar:: PYTEST_DEBUG_TEMPROOT + +Root for temporary directories produced by fixtures like :fixture:`tmp_path` +as discussed in :ref:`temporary directory location and retention`. + .. envvar:: PYTEST_DISABLE_PLUGIN_AUTOLOAD -When set, disables plugin auto-loading through setuptools entrypoints. Only explicitly specified plugins will be -loaded. +When set, disables plugin auto-loading through :std:doc:`entry point packaging +metadata `. Only plugins +explicitly specified in :envvar:`PYTEST_PLUGINS` or with ``-p`` will be loaded. +See also :ref:`--disable-plugin-autoload `. .. envvar:: PYTEST_PLUGINS @@ -1088,6 +1210,8 @@ Contains comma-separated list of modules that should be loaded as plugins: export PYTEST_PLUGINS=mymodule.plugin,xdist +See also ``-p``. + .. envvar:: PYTEST_THEME Sets a `pygment style `_ to use for the code output. @@ -1104,19 +1228,22 @@ When set to ``0``, pytest will not use color. .. envvar:: NO_COLOR -When set (regardless of value), pytest will not use color in terminal output. +When set to a non-empty string (regardless of value), pytest will not use color in terminal output. ``PY_COLORS`` takes precedence over ``NO_COLOR``, which takes precedence over ``FORCE_COLOR``. See `no-color.org `__ for other libraries supporting this community standard. .. envvar:: FORCE_COLOR -When set (regardless of value), pytest will use color in terminal output. +When set to a non-empty string (regardless of value), pytest will use color in terminal output. ``PY_COLORS`` and ``NO_COLOR`` take precedence over ``FORCE_COLOR``. Exceptions ---------- -.. autoclass:: pytest.UsageError() +.. autoexception:: pytest.UsageError() + :show-inheritance: + +.. autoexception:: pytest.FixtureLookupError() :show-inheritance: .. _`warnings ref`: @@ -1150,12 +1277,9 @@ Custom warnings generated in some situations such as improper usage or deprecate .. autoclass:: pytest.PytestReturnNotNoneWarning :show-inheritance: -.. autoclass:: pytest.PytestRemovedIn8Warning +.. autoclass:: pytest.PytestRemovedIn9Warning :show-inheritance: -.. autoclass:: pytest.PytestUnhandledCoroutineWarning - :show-inheritance: - .. autoclass:: pytest.PytestUnknownMarkWarning :show-inheritance: @@ -1213,13 +1337,59 @@ passed multiple times. The expected format is ``name=value``. For example:: .. confval:: cache_dir - Sets a directory where stores content of cache plugin. Default directory is + Sets the directory where the cache plugin's content is stored. Default directory is ``.pytest_cache`` which is created in :ref:`rootdir `. Directory may be relative or absolute path. If setting relative path, then directory is created - relative to :ref:`rootdir `. Additionally path may contain environment + relative to :ref:`rootdir `. Additionally, a path may contain environment variables, that will be expanded. For more information about cache plugin please refer to :ref:`cache_provider`. +.. confval:: collect_imported_tests + + .. versionadded:: 8.4 + + Setting this to ``false`` will make pytest collect classes/functions from test + files **only** if they are defined in that file (as opposed to imported there). + + .. code-block:: ini + + [pytest] + collect_imported_tests = false + + Default: ``true`` + + pytest traditionally collects classes/functions in the test module namespace even if they are imported from another file. + + For example: + + .. code-block:: python + + # contents of src/domain.py + class Testament: ... + + + # contents of tests/test_testament.py + from domain import Testament + + + def test_testament(): ... + + In this scenario, with the default options, pytest will collect the class `Testament` from `tests/test_testament.py` because it starts with `Test`, even though in this case it is a production class being imported in the test module namespace. + + Set ``collected_imported_tests`` to ``false`` in the configuration file prevents that. + +.. confval:: consider_namespace_packages + + Controls if pytest should attempt to identify `namespace packages `__ + when collecting Python modules. Default is ``False``. + + Set to ``True`` if the package you are testing is part of a namespace package. + + Only `native namespace packages `__ + are supported, with no plans to support `legacy namespace packages `__. + + .. versionadded:: 8.1 + .. confval:: console_output_style Sets the console output style while running tests: @@ -1228,6 +1398,7 @@ passed multiple times. The expected format is ``name=value``. For example:: * ``progress``: like classic pytest output, but with a progress indicator. * ``progress-even-when-capture-no``: allows the use of the progress indicator even when ``capture=no``. * ``count``: like progress, but shows progress as the number of tests completed instead of a percent. + * ``times``: show tests duration. The default is ``progress``, but you can fallback to ``classic`` if you prefer or the new mode is causing unexpected problems: @@ -1239,6 +1410,29 @@ passed multiple times. The expected format is ``name=value``. For example:: console_output_style = classic +.. confval:: disable_test_id_escaping_and_forfeit_all_rights_to_community_support + + .. versionadded:: 4.4 + + pytest by default escapes any non-ascii characters used in unicode strings + for the parametrization because it has several downsides. + If however you would like to use unicode strings in parametrization + and see them in the terminal as is (non-escaped), use this option + in your ``pytest.ini``: + + .. code-block:: ini + + [pytest] + disable_test_id_escaping_and_forfeit_all_rights_to_community_support = True + + Keep in mind however that this might cause unwanted side effects and + even bugs depending on the OS used and plugins currently installed, + so use it at your own risk. + + Default: ``False``. + + See :ref:`parametrizemark`. + .. confval:: doctest_encoding @@ -1616,14 +1810,14 @@ passed multiple times. The expected format is ``name=value``. For example:: This would tell ``pytest`` to not look into typical subversion or sphinx-build directories or into any ``tmp`` prefixed directory. - Additionally, ``pytest`` will attempt to intelligently identify and ignore a - virtualenv by the presence of an activation script. Any directory deemed to - be the root of a virtual environment will not be considered during test - collection unless ``‑‑collect‑in‑virtualenv`` is given. Note also that - ``norecursedirs`` takes precedence over ``‑‑collect‑in‑virtualenv``; e.g. if - you intend to run tests in a virtualenv with a base directory that matches - ``'.*'`` you *must* override ``norecursedirs`` in addition to using the - ``‑‑collect‑in‑virtualenv`` flag. + Additionally, ``pytest`` will attempt to intelligently identify and ignore + a virtualenv. Any directory deemed to be the root of a virtual environment + will not be considered during test collection unless + ``--collect-in-virtualenv`` is given. Note also that ``norecursedirs`` + takes precedence over ``--collect-in-virtualenv``; e.g. if you intend to + run tests in a virtualenv with a base directory that matches ``'.*'`` you + *must* override ``norecursedirs`` in addition to using the + ``--collect-in-virtualenv`` flag. .. confval:: python_classes @@ -1703,11 +1897,6 @@ passed multiple times. The expected format is ``name=value``. For example:: [pytest] pythonpath = src1 src2 - .. note:: - - ``pythonpath`` does not affect some imports that happen very early, - most notably plugins loaded using the ``-p`` command line option. - .. confval:: required_plugins @@ -1750,11 +1939,8 @@ passed multiple times. The expected format is ``name=value``. For example:: pytest testing doc - .. confval:: tmp_path_retention_count - - How many sessions should we keep the `tmp_path` directories, according to `tmp_path_retention_policy`. @@ -1780,11 +1966,51 @@ passed multiple times. The expected format is ``name=value``. For example:: .. code-block:: ini [pytest] - tmp_path_retention_policy = "all" + tmp_path_retention_policy = all Default: ``all`` +.. confval:: truncation_limit_chars + + Controls maximum number of characters to truncate assertion message contents. + + Setting value to ``0`` disables the character limit for truncation. + + .. code-block:: ini + + [pytest] + truncation_limit_chars = 640 + + pytest truncates the assert messages to a certain limit by default to prevent comparison with large data to overload the console output. + + Default: ``640`` + + .. note:: + + If pytest detects it is :ref:`running on CI `, truncation is disabled automatically. + + +.. confval:: truncation_limit_lines + + Controls maximum number of linesto truncate assertion message contents. + + Setting value to ``0`` disables the lines limit for truncation. + + .. code-block:: ini + + [pytest] + truncation_limit_lines = 8 + + pytest truncates the assert messages to a certain limit by default to prevent comparison with large data to overload the console output. + + Default: ``8`` + + .. note:: + + If pytest detects it is :ref:`running on CI `, truncation is disabled automatically. + + .. confval:: usefixtures List of fixtures that will be applied to all test functions; this is semantically the same to apply @@ -1798,6 +2024,32 @@ passed multiple times. The expected format is ``name=value``. For example:: clean_db +.. confval:: verbosity_assertions + + Set a verbosity level specifically for assertion related output, overriding the application wide level. + + .. code-block:: ini + + [pytest] + verbosity_assertions = 2 + + Defaults to application wide verbosity level (via the ``-v`` command-line option). A special value of + "auto" can be used to explicitly use the global verbosity level. + + +.. confval:: verbosity_test_cases + + Set a verbosity level specifically for test case execution related output, overriding the application wide level. + + .. code-block:: ini + + [pytest] + verbosity_test_cases = 2 + + Defaults to application wide verbosity level (via the ``-v`` command-line option). A special value of + "auto" can be used to explicitly use the global verbosity level. + + .. confval:: xfail_strict If set to ``True``, tests marked with ``@pytest.mark.xfail`` that actually succeed will by default fail the @@ -1826,7 +2078,7 @@ All the command-line flags can be obtained by running ``pytest --help``:: general: -k EXPRESSION Only run tests which match the given substring - expression. An expression is a Python evaluatable + expression. An expression is a Python evaluable expression where all names are substring-matched against test names and their parent classes. Example: -k 'test_method or test_other' matches all @@ -1844,6 +2096,12 @@ All the command-line flags can be obtained by running ``pytest --help``:: example: -m 'mark1 and not mark2'. --markers show markers (builtin, plugin and per-project ones). -x, --exitfirst Exit instantly on first error or failed test + --maxfail=num Exit after first num failures or errors + --strict-config Any warnings encountered while parsing the `pytest` + section of the configuration file raise errors + --strict-markers Markers not registered in the `markers` section of + the configuration file raise errors + --strict (Deprecated) alias to --strict-markers --fixtures, --funcargs Show available fixtures, sorted by plugin appearance (fixtures with leading '_' are only shown with '-v') @@ -1870,22 +2128,33 @@ All the command-line flags can be obtained by running ``pytest --help``:: Show cache contents, don't perform collection or tests. Optional argument: glob (default: '*'). --cache-clear Remove all cache contents at start of test run - --lfnf={all,none}, --last-failed-no-failures={all,none} - Which tests to run with no previously (known) - failures + --lfnf, --last-failed-no-failures={all,none} + With ``--lf``, determines whether to execute tests + when there are no previously (known) failures or + when no cached ``lastfailed`` data was found. + ``all`` (the default) runs the full test suite + again. ``none`` just emits a message about no known + failures and exits successfully. --sw, --stepwise Exit on test failure and continue from last failing test next time --sw-skip, --stepwise-skip Ignore the first failing test but stop on the next failing test. Implicitly enables --stepwise. + --sw-reset, --stepwise-reset + Resets stepwise state, restarting the stepwise + workflow. Implicitly enables --stepwise. Reporting: --durations=N Show N slowest setup/test durations (N=0 for all) --durations-min=N Minimal duration in seconds for inclusion in slowest - list. Default: 0.005. + list. Default: 0.005 (or 0.0 if -vv is given). -v, --verbose Increase verbosity --no-header Disable header --no-summary Disable summary + --no-fold-skipped Do not fold skipped tests in short summary. + --force-short-summary + Force condensed summary output regardless of + verbosity level. -q, --quiet Decrease verbosity --verbosity=VERBOSE Set verbosity. Default: 0. -r chars Show extra test summary info as specified by chars: @@ -1901,6 +2170,7 @@ All the command-line flags can be obtained by running ``pytest --help``:: passed through addopts) --tb=style Traceback print mode (auto/long/short/line/native/no) + --xfail-tb Show tracebacks for xfail (as long as --tb != no) --show-capture={no,stdout,stderr,log,all} Controls how captured stdout/stderr/log is shown on failed tests. Default: all. @@ -1910,29 +2180,15 @@ All the command-line flags can be obtained by running ``pytest --help``:: Whether code should be highlighted (only if --color is also enabled). Default: yes. --pastebin=mode Send failed|all info to bpaste.net pastebin service - --junit-xml=path Create junit-xml style report file at given path - --junit-prefix=str Prepend prefix to classnames in junit-xml output + --junitxml, --junit-xml=path + Create junit-xml style report file at given path + --junitprefix, --junit-prefix=str + Prepend prefix to classnames in junit-xml output pytest-warnings: - -W PYTHONWARNINGS, --pythonwarnings=PYTHONWARNINGS + -W, --pythonwarnings PYTHONWARNINGS Set which warnings to report, see -W option of Python itself - --maxfail=num Exit after first num failures or errors - --strict-config Any warnings encountered while parsing the `pytest` - section of the configuration file raise errors - --strict-markers Markers not registered in the `markers` section of - the configuration file raise errors - --strict (Deprecated) alias to --strict-markers - -c, --config-file FILE - Load configuration from `FILE` instead of trying to - locate one of the implicit configuration files - --continue-on-collection-errors - Force test execution even if collection errors occur - --rootdir=ROOTDIR Define root directory for tests. Can be relative - path: 'root_dir', './root_dir', - 'root_dir/another_dir/'; absolute path: - '/home/user/root_dir'; path with variables: - '$HOME/root_dir'. collection: --collect-only, --co Only collect tests, don't execute them @@ -1948,6 +2204,8 @@ All the command-line flags can be obtained by running ``pytest --help``:: --keep-duplicates Keep duplicate tests --collect-in-virtualenv Don't ignore tests in a local virtualenv directory + --continue-on-collection-errors + Force test execution even if collection errors occur --import-mode={prepend,append,importlib} Prepend/append to sys.path when importing test modules and conftest files. Default: prepend. @@ -1957,12 +2215,20 @@ All the command-line flags can be obtained by running ``pytest --help``:: failure --doctest-glob=pat Doctests file matching pattern, default: test*.txt --doctest-ignore-import-errors - Ignore doctest ImportErrors + Ignore doctest collection errors --doctest-continue-on-failure For a given doctest, continue to run after the first failure test session debugging and configuration: + -c, --config-file FILE + Load configuration from `FILE` instead of trying to + locate one of the implicit configuration files. + --rootdir=ROOTDIR Define root directory for tests. Can be relative + path: 'root_dir', './root_dir', + 'root_dir/another_dir/'; absolute path: + '/home/user/root_dir'; path with variables: + '$HOME/root_dir'. --basetemp=dir Base temporary directory for this test run. (Warning: this directory is removed if it exists.) -V, --version Display pytest version and information about @@ -1971,13 +2237,19 @@ All the command-line flags can be obtained by running ``pytest --help``:: -h, --help Show help message and configuration info -p name Early-load given plugin module name or entry point (multi-allowed). To avoid loading of plugins, use - the `no:` prefix, e.g. `no:doctest`. + the `no:` prefix, e.g. `no:doctest`. See also + --disable-plugin-autoload. + --disable-plugin-autoload + Disable plugin auto-loading through entry point + packaging metadata. Only plugins explicitly + specified in -p or env var PYTEST_PLUGINS will be + loaded. --trace-config Trace considerations of conftest.py files --debug=[DEBUG_FILE_NAME] Store internal tracing debug information in this log file. This file is opened with 'w' and truncated as a result, care advised. Default: pytestdebug.log. - -o OVERRIDE_INI, --override-ini=OVERRIDE_INI + -o, --override-ini OVERRIDE_INI Override ini option with "option=value" style, e.g. `-o xfail_strict=True -o cache_dir=cache`. --assert=MODE Control assertion debugging tools. @@ -2006,6 +2278,8 @@ All the command-line flags can be obtained by running ``pytest --help``:: --log-cli-date-format=LOG_CLI_DATE_FORMAT Log date format used by the logging module --log-file=LOG_FILE Path to a file when logging will be written to + --log-file-mode={w,a} + Log file open mode --log-file-level=LOG_FILE_LEVEL Log file logging level --log-file-format=LOG_FILE_FORMAT @@ -2021,16 +2295,22 @@ All the command-line flags can be obtained by running ``pytest --help``:: [pytest] ini-options in the first pytest.ini|tox.ini|setup.cfg|pyproject.toml file found: - markers (linelist): Markers for test functions + markers (linelist): Register new markers for test functions empty_parameter_set_mark (string): Default marker for empty parametersets - norecursedirs (args): Directory patterns to avoid for recursion - testpaths (args): Directories to search for tests when no files or - directories are given on the command line filterwarnings (linelist): Each line specifies a pattern for warnings.filterwarnings. Processed after -W/--pythonwarnings. + norecursedirs (args): Directory patterns to avoid for recursion + testpaths (args): Directories to search for tests when no files or + directories are given on the command line + collect_imported_tests (bool): + Whether to collect tests in imported modules outside + `testpaths` + consider_namespace_packages (bool): + Consider namespace packages when resolving module + names during import usefixtures (args): List of default fixtures to be used with this project python_files (args): Glob-style file patterns for Python test module @@ -2049,6 +2329,11 @@ All the command-line flags can be obtained by running ``pytest --help``:: progress information ("progress" (percentage) | "count" | "progress-even-when-capture-no" (forces progress even when capture=no) + verbosity_test_cases (string): + Specify a verbosity level for test case execution, + overriding the main level. Higher levels will + provide more detailed information about each test + case executed. xfail_strict (bool): Default for the strict parameter of xfail markers when not given explicitly (default: False) tmp_path_retention_count (string): @@ -2062,6 +2347,16 @@ All the command-line flags can be obtained by running ``pytest --help``:: enable_assertion_pass_hook (bool): Enables the pytest_assertion_pass hook. Make sure to delete any previously generated pyc cache files. + truncation_limit_lines (string): + Set threshold of LINES after which truncation will + take effect + truncation_limit_chars (string): + Set threshold of CHARS after which truncation will + take effect + verbosity_assertions (string): + Specify a verbosity level for assertions, overriding + the main level. Higher levels will provide more + detailed explanation when an assertion fails. junit_suite_name (string): Test suite name for JUnit report junit_logging (string): @@ -2092,6 +2387,8 @@ All the command-line flags can be obtained by running ``pytest --help``:: log_cli_date_format (string): Default value for --log-cli-date-format log_file (string): Default value for --log-file + log_file_mode (string): + Default value for --log-file-mode log_file_level (string): Default value for --log-file-level log_file_format (string): @@ -2100,16 +2397,18 @@ All the command-line flags can be obtained by running ``pytest --help``:: Default value for --log-file-date-format log_auto_indent (string): Default value for --log-auto-indent - pythonpath (paths): Add paths to sys.path faulthandler_timeout (string): Dump the traceback of all threads if a test takes more than TIMEOUT seconds to finish addopts (args): Extra command line options minversion (string): Minimally required pytest version + pythonpath (paths): Add paths to sys.path required_plugins (args): Plugins that must be present for pytest to run Environment variables: + CI When set (regardless of value), pytest knows it is running in a CI process and does not truncate summary info + BUILD_NUMBER Equivalent to CI PYTEST_ADDOPTS Extra command line options PYTEST_PLUGINS Comma-separated plugins to load during startup PYTEST_DISABLE_PLUGIN_AUTOLOAD Set to disable plugin auto-loading diff --git a/doc/en/requirements.txt b/doc/en/requirements.txt index b6059723cd5..ddcb7efb99b 100644 --- a/doc/en/requirements.txt +++ b/doc/en/requirements.txt @@ -1,11 +1,10 @@ -pallets-sphinx-themes -pluggy>=1.0 +-c broken-dep-constraints.txt +pluggy>=1.5.0 pygments-pytest>=2.3.0 sphinx-removed-in>=0.2.0 -sphinx>=5,<6 +sphinx>=7 sphinxcontrib-trio sphinxcontrib-svg2pdfconverter -# Pin packaging because it no longer handles 'latest' version, which -# is the version that is assigned to the docs. -# See https://github.com/pytest-dev/pytest/pull/10578#issuecomment-1348249045. -packaging <22 +furo +sphinxcontrib-towncrier +sphinx-issues diff --git a/extra/get_issues.py b/extra/get_issues.py index 4aaa3c3ec31..851d2f6d7f3 100644 --- a/extra/get_issues.py +++ b/extra/get_issues.py @@ -1,8 +1,12 @@ +from __future__ import annotations + import json from pathlib import Path +import sys import requests + issues_url = "https://api.github.com/repos/pytest-dev/pytest/issues" @@ -16,7 +20,7 @@ def get_issues(): if r.status_code == 403: # API request limit exceeded print(data["message"]) - exit(1) + sys.exit(1) issues.extend(data) # Look for next page @@ -59,7 +63,7 @@ def report(issues): kind = _get_kind(issue) status = issue["state"] number = issue["number"] - link = "https://github.com/pytest-dev/pytest/issues/%s/" % number + link = f"https://github.com/pytest-dev/pytest/issues/{number}/" print("----") print(status, kind, link) print(title) @@ -68,7 +72,7 @@ def report(issues): # print("\n".join(lines[:3])) # if len(lines) > 3 or len(body) > 240: # print("...") - print("\n\nFound %s open issues" % len(issues)) + print(f"\n\nFound {len(issues)} open issues") if __name__ == "__main__": diff --git a/pyproject.toml b/pyproject.toml index a4139a5c051..a3c82db4381 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,23 +1,382 @@ [build-system] +build-backend = "setuptools.build_meta" requires = [ - # sync with setup.py until we discard non-pep-517/518 - "setuptools>=45.0", - "setuptools-scm[toml]>=6.2.3", + "setuptools>=61", + "setuptools-scm[toml]>=6.2.3", +] + +[project] +name = "pytest" +description = "pytest: simple powerful testing with Python" +readme = "README.rst" +keywords = [ + "test", + "unittest", +] +license = { text = "MIT" } +authors = [ + { name = "Holger Krekel" }, + { name = "Bruno Oliveira" }, + { name = "Ronny Pfannschmidt" }, + { name = "Floris Bruynooghe" }, + { name = "Brianna Laugher" }, + { name = "Florian Bruhin" }, + { name = "Others (See AUTHORS)" }, +] +requires-python = ">=3.9" +classifiers = [ + "Development Status :: 6 - Mature", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: MacOS", + "Operating System :: Microsoft :: Windows", + "Operating System :: POSIX", + "Operating System :: Unix", + "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 :: Libraries", + "Topic :: Software Development :: Testing", + "Topic :: Utilities", +] +dynamic = [ + "version", +] +dependencies = [ + "colorama>=0.4; sys_platform=='win32'", + "exceptiongroup>=1; python_version<'3.11'", + "iniconfig>=1", + "packaging>=20", + "pluggy>=1.5,<2", + "pygments>=2.7.2", + "tomli>=1; python_version<'3.11'", +] +optional-dependencies.dev = [ + "argcomplete", + "attrs>=19.2", + "hypothesis>=3.56", + "mock", + "requests", + "setuptools", + "xmlschema", +] +urls.Changelog = "https://docs.pytest.org/en/stable/changelog.html" +urls.Contact = "https://docs.pytest.org/en/stable/contact.html" +urls.Funding = "https://docs.pytest.org/en/stable/sponsor.html" +urls.Homepage = "https://docs.pytest.org/en/latest/" +urls.Source = "https://github.com/pytest-dev/pytest" +urls.Tracker = "https://github.com/pytest-dev/pytest/issues" +scripts."py.test" = "pytest:console_main" +scripts.pytest = "pytest:console_main" + +[tool.setuptools.package-data] +"_pytest" = [ + "py.typed", +] +"pytest" = [ + "py.typed", ] -build-backend = "setuptools.build_meta" [tool.setuptools_scm] write_to = "src/_pytest/_version.py" +[tool.black] +# See https://black.readthedocs.io/en/stable/usage_and_configuration/the_basics.html#t-target-version +target-version = [ "py39", "py310", "py311", "py312", "py313" ] + +[tool.ruff] +target-version = "py39" +line-length = 88 +src = [ + "src", +] +format.docstring-code-format = true +lint.select = [ + "B", # bugbear + "D", # pydocstyle + "E", # pycodestyle + "F", # pyflakes + "FA100", # add future annotations + "I", # isort + "PGH004", # pygrep-hooks - Use specific rule codes when using noqa + "PIE", # flake8-pie + "PLC", # pylint convention + "PLE", # pylint error + "PLR", # pylint refactor + "PLR1714", # Consider merging multiple comparisons + "PLW", # pylint warning + "PYI", # flake8-pyi + "RUF", # ruff + "T100", # flake8-debugger + "UP", # pyupgrade + "W", # pycodestyle +] +lint.ignore = [ + # bugbear ignore + "B004", # Using `hasattr(x, "__call__")` to test if x is callable is unreliable. + "B007", # Loop control variable `i` not used within loop body + "B009", # Do not call `getattr` with a constant attribute value + "B010", # [*] Do not call `setattr` with a constant attribute value. + "B011", # Do not `assert False` (`python -O` removes these calls) + "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__` + "D205", # 1 blank line required between summary line and description + "D209", # [*] Multi-line docstring closing quotes should be on a separate line + "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 + # pytest can do weird low-level things, and we usually know + # what we're doing when we use type(..) is ... + "E721", # Do not compare types, use `isinstance()` + # pylint ignore + "PLC0105", # `TypeVar` name "E" does not reflect its covariance; + "PLC0414", # Import alias does not rename original package + "PLR0124", # Name compared with itself + "PLR0133", # Two constants compared in a comparison (lots of those in tests) + "PLR0402", # Use `from x.y import z` in lieu of alias + "PLR0911", # Too many return statements + "PLR0912", # Too many branches + "PLR0913", # Too many arguments in function definition + "PLR0915", # Too many statements + "PLR2004", # Magic value used in comparison + "PLR2044", # Line with empty comment + "PLR5501", # Use `elif` instead of `else` then `if` + "PLW0120", # remove the else and dedent its contents + "PLW0603", # Using the global statement + "PLW2901", # for loop variable overwritten by assignment target + # ruff ignore + "RUF012", # Mutable class attributes should be annotated with `typing.ClassVar` +] +lint.per-file-ignores."src/_pytest/_py/**/*.py" = [ + "B", + "PYI", +] +lint.per-file-ignores."src/_pytest/_version.py" = [ + "I001", +] +# can't be disabled on a line-by-line basis in file +lint.per-file-ignores."testing/code/test_source.py" = [ + "F841", +] +lint.per-file-ignores."testing/python/approx.py" = [ + "B015", +] +lint.extend-safe-fixes = [ + "UP006", + "UP007", +] +lint.isort.combine-as-imports = true +lint.isort.force-single-line = true +lint.isort.force-sort-within-sections = true +lint.isort.known-local-folder = [ + "pytest", + "_pytest", +] +lint.isort.lines-after-imports = 2 +lint.isort.order-by-type = false +lint.isort.required-imports = [ + "from __future__ import annotations", +] +# In order to be able to format for 88 char in ruff format +lint.pycodestyle.max-line-length = 120 +lint.pydocstyle.convention = "pep257" +lint.pyupgrade.keep-runtime-typing = false + +[tool.pylint.main] +# Maximum number of characters on a single line. +max-line-length = 120 +disable = [ + "abstract-method", + "arguments-differ", + "arguments-renamed", + "assigning-non-slot", + "attribute-defined-outside-init", + "bad-builtin", + "bad-classmethod-argument", + "bad-dunder-name", + "bad-mcs-method-argument", + "broad-exception-caught", + "broad-exception-raised", + "cell-var-from-loop", # B023 from ruff / flake8-bugbear + "comparison-of-constants", # disabled in ruff (PLR0133) + "comparison-with-callable", + "comparison-with-itself", # PLR0124 from ruff + "condition-evals-to-constant", + "consider-alternative-union-syntax", + "confusing-consecutive-elif", + "consider-using-assignment-expr", + "consider-using-dict-items", + "consider-using-from-import", + "consider-using-f-string", + "consider-using-in", + "consider-using-namedtuple-or-dataclass", + "consider-using-ternary", + "consider-using-tuple", + "consider-using-with", + "consider-using-from-import", # not activated by default, PLR0402 disabled in ruff + "consider-ternary-expression", + "cyclic-import", + "differing-param-doc", + "docstring-first-line-empty", + "deprecated-argument", + "deprecated-attribute", + "deprecated-class", + "disallowed-name", # foo / bar are used often in tests + "duplicate-code", + "else-if-used", # not activated by default, PLR5501 disabled in ruff + "empty-comment", # not activated by default, PLR2044 disabled in ruff + "eval-used", + "eq-without-hash", + "exec-used", + "expression-not-assigned", + "fixme", + "global-statement", # PLW0603 disabled in ruff + "import-error", + "import-outside-toplevel", + "import-private-name", + "inconsistent-return-statements", + "invalid-bool-returned", + "invalid-name", + "invalid-repr-returned", + "invalid-str-returned", + "keyword-arg-before-vararg", + "line-too-long", + "magic-value-comparison", # not activated by default, PLR2004 disabled in ruff + "method-hidden", + "missing-docstring", + "missing-param-doc", + "missing-raises-doc", + "missing-timeout", + "missing-type-doc", + "misplaced-bare-raise", # PLE0704 from ruff + "misplaced-comparison-constant", + "multiple-statements", # multiple-statements-on-one-line-colon (E701) from ruff + "no-else-break", + "no-else-continue", + "no-else-raise", + "no-else-return", + "no-member", + "no-name-in-module", + "no-self-argument", + "no-self-use", + "not-an-iterable", + "not-callable", + "pointless-exception-statement", # https://github.com/pytest-dev/pytest/pull/12379 + "pointless-statement", # https://github.com/pytest-dev/pytest/pull/12379 + "pointless-string-statement", # https://github.com/pytest-dev/pytest/pull/12379 + "possibly-used-before-assignment", + "protected-access", + "raise-missing-from", + "redefined-argument-from-local", + "redefined-builtin", + "redefined-loop-name", # PLW2901 disabled in ruff + "redefined-outer-name", + "redefined-variable-type", + "reimported", + "simplifiable-condition", + "simplifiable-if-expression", + "singleton-comparison", + "superfluous-parens", + "super-init-not-called", + "too-complex", + "too-few-public-methods", + "too-many-ancestors", + "too-many-arguments", # disabled in ruff + "too-many-branches", # disabled in ruff + "too-many-function-args", + "too-many-instance-attributes", + "too-many-lines", + "too-many-locals", + "too-many-nested-blocks", + "too-many-positional-arguments", + "too-many-public-methods", + "too-many-return-statements", # disabled in ruff + "too-many-statements", # disabled in ruff + "too-many-try-statements", + "try-except-raise", + "typevar-name-incorrect-variance", # PLC0105 disabled in ruff + "unbalanced-tuple-unpacking", + "undefined-loop-variable", + "undefined-variable", + "unexpected-keyword-arg", + "unidiomatic-typecheck", + "unnecessary-comprehension", + "unnecessary-dunder-call", + "unnecessary-lambda", + "unnecessary-lambda-assignment", + "unpacking-non-sequence", + "unspecified-encoding", + "unsubscriptable-object", + "unused-argument", + "unused-import", + "unused-variable", + "used-before-assignment", + "use-dict-literal", + "use-implicit-booleaness-not-comparison", + "use-implicit-booleaness-not-len", + "use-set-for-membership", + "useless-else-on-loop", # PLC0414 disabled in ruff + "useless-import-alias", + "useless-return", + "using-constant-test", + "while-used", + "wrong-import-order", # handled by isort / ruff + "wrong-import-position", # handled by isort / ruff +] + +[tool.codespell] +ignore-words-list = "afile,asend,asser,assertio,feld,hove,ned,noes,notin,paramete,parth,socio-economic,tesults,varius,wil" +skip = "AUTHORS,*/plugin_list.rst" +write-changes = true + +[tool.check-wheel-contents] +# check-wheel-contents is executed by the build-and-inspect-python-package action. +# W009: Wheel contains multiple toplevel library entries +ignore = "W009" + +[tool.pyproject-fmt] +indent = 4 +max_supported_python = "3.14" + [tool.pytest.ini_options] minversion = "2.0" addopts = "-rfEX -p pytester --strict-markers" -python_files = ["test_*.py", "*_test.py", "testing/python/*.py"] -python_classes = ["Test", "Acceptance"] -python_functions = ["test"] +python_files = [ + "test_*.py", + "*_test.py", + "testing/python/*.py", +] +python_classes = [ + "Test", + "Acceptance", +] +python_functions = [ + "test", +] # NOTE: "doc" is not included here, but gets tested explicitly via "doctesting". -testpaths = ["testing"] -norecursedirs = ["testing/example_scripts"] +testpaths = [ + "testing", +] +norecursedirs = [ + "testing/example_scripts", + ".*", + "build", + "dist", +] xfail_strict = true filterwarnings = [ "error", @@ -50,6 +409,9 @@ filterwarnings = [ "ignore:VendorImporter\\.find_spec\\(\\) not found; falling back to find_module\\(\\):ImportWarning", # https://github.com/pytest-dev/execnet/pull/127 "ignore:isSet\\(\\) is deprecated, use is_set\\(\\) instead:DeprecationWarning", + # https://github.com/pytest-dev/pytest/issues/2366 + # https://github.com/pytest-dev/pytest/pull/13057 + "default::pytest.PytestFDWarning", ] pytester_example_dir = "testing/example_scripts" markers = [ @@ -57,13 +419,18 @@ markers = [ "foo", "bar", "baz", + "number_mark", + "builtin_matchers_mark", + "str_mark", # conftest.py reorders tests moving slow ones to the end of the list "slow", # experimental mark for all tests using pexpect "uses_pexpect", + # Disables the `remove_ci_env_var` autouse fixture on a given test that + # actually inspects whether the CI environment variable is set. + "keep_ci_var", ] - [tool.towncrier] package = "pytest" package_dir = "src" @@ -72,50 +439,109 @@ directory = "changelog/" title_format = "pytest {version} ({project_date})" template = "changelog/_template.rst" - [[tool.towncrier.type]] - directory = "breaking" - name = "Breaking Changes" - showcontent = true - - [[tool.towncrier.type]] - directory = "deprecation" - name = "Deprecations" - showcontent = true - - [[tool.towncrier.type]] - directory = "feature" - name = "Features" - showcontent = true - - [[tool.towncrier.type]] - directory = "improvement" - name = "Improvements" - showcontent = true - - [[tool.towncrier.type]] - directory = "bugfix" - name = "Bug Fixes" - showcontent = true - - [[tool.towncrier.type]] - directory = "vendor" - name = "Vendored Libraries" - showcontent = true - - [[tool.towncrier.type]] - directory = "doc" - name = "Improved Documentation" - showcontent = true - - [[tool.towncrier.type]] - directory = "trivial" - name = "Trivial/Internal Changes" - showcontent = true +# NOTE: The types are declared because: +# NOTE: - there is no mechanism to override just the value of +# NOTE: `tool.towncrier.type.misc.showcontent`; +# NOTE: - and, we want to declare extra non-default types for +# NOTE: clarity and flexibility. -[tool.black] -target-version = ['py37'] +[[tool.towncrier.type]] +# When something public gets removed in a breaking way. Could be +# deprecated in an earlier release. +directory = "breaking" +name = "Removals and backward incompatible breaking changes" +showcontent = true -# check-wheel-contents is executed by the build-and-inspect-python-package action. -[tool.check-wheel-contents] -# W009: Wheel contains multiple toplevel library entries -ignore = "W009" +[[tool.towncrier.type]] +# Declarations of future API removals and breaking changes in behavior. +directory = "deprecation" +name = "Deprecations (removal in next major release)" +showcontent = true + +[[tool.towncrier.type]] +# New behaviors, public APIs. That sort of stuff. +directory = "feature" +name = "New features" +showcontent = true + +[[tool.towncrier.type]] +# New behaviors in existing features. +directory = "improvement" +name = "Improvements in existing functionality" +showcontent = true + +[[tool.towncrier.type]] +# Something we deemed an improper undesired behavior that got corrected +# in the release to match pre-agreed expectations. +directory = "bugfix" +name = "Bug fixes" +showcontent = true + +[[tool.towncrier.type]] +# Updates regarding bundling dependencies. +directory = "vendor" +name = "Vendored libraries" +showcontent = true + +[[tool.towncrier.type]] +# Notable updates to the documentation structure or build process. +directory = "doc" +name = "Improved documentation" +showcontent = true + +[[tool.towncrier.type]] +# Notes for downstreams about unobvious side effects and tooling. Changes +# in the test invocation considerations and runtime assumptions. +directory = "packaging" +name = "Packaging updates and notes for downstreams" +showcontent = true + +[[tool.towncrier.type]] +# Stuff that affects the contributor experience. e.g. Running tests, +# building the docs, setting up the development environment. +directory = "contrib" +name = "Contributor-facing changes" +showcontent = true + +[[tool.towncrier.type]] +# Changes that are hard to assign to any of the above categories. +directory = "misc" +name = "Miscellaneous internal changes" +showcontent = true + +[tool.mypy] +files = [ + "src", + "testing", + "scripts", +] +mypy_path = [ + "src", +] +python_version = "3.9" +check_untyped_defs = true +disallow_any_generics = true +disallow_untyped_defs = true +ignore_missing_imports = true +show_error_codes = true +strict_equality = true +warn_redundant_casts = true +warn_return_any = true +warn_unreachable = true +warn_unused_configs = true +no_implicit_reexport = true +warn_unused_ignores = true + +[tool.pyright] +include = [ + "src", + "testing", + "scripts", +] +extraPaths = [ + "src", +] +pythonVersion = "3.9" +typeCheckingMode = "basic" +reportMissingImports = "none" +reportMissingModuleSource = "none" diff --git a/scripts/.gitignore b/scripts/.gitignore new file mode 100644 index 00000000000..50a75b62959 --- /dev/null +++ b/scripts/.gitignore @@ -0,0 +1 @@ +latest-release-notes.md diff --git a/scripts/generate-gh-release-notes.py b/scripts/generate-gh-release-notes.py new file mode 100644 index 00000000000..b6d92d085e1 --- /dev/null +++ b/scripts/generate-gh-release-notes.py @@ -0,0 +1,69 @@ +# mypy: disallow-untyped-defs +""" +Script used to generate a Markdown file containing only the changelog entries of a specific pytest release, which +is then published as a GitHub Release during deploy (see workflows/deploy.yml). + +The script requires ``pandoc`` to be previously installed in the system -- we need to convert from RST (the format of +our CHANGELOG) into Markdown (which is required by GitHub Releases). + +Requires Python3.6+. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from pathlib import Path +import re +import sys + +import pypandoc + + +def extract_changelog_entries_for(version: str) -> str: + p = Path(__file__).parent.parent / "doc/en/changelog.rst" + changelog_lines = p.read_text(encoding="UTF-8").splitlines() + + title_regex = re.compile(r"pytest (\d\.\d+\.\d+\w*) \(\d{4}-\d{2}-\d{2}\)") + consuming_version = False + version_lines = [] + for line in changelog_lines: + m = title_regex.match(line) + if m: + # Found the version we want: start to consume lines until we find the next version title. + if m.group(1) == version: + consuming_version = True + # Found a new version title while parsing the version we want: break out. + elif consuming_version: + break + if consuming_version: + version_lines.append(line) + + return "\n".join(version_lines) + + +def convert_rst_to_md(text: str) -> str: + result = pypandoc.convert_text( + text, "md", format="rst", extra_args=["--wrap=preserve"] + ) + assert isinstance(result, str), repr(result) + return result + + +def main(argv: Sequence[str]) -> int: + if len(argv) != 3: + print("Usage: generate-gh-release-notes VERSION FILE") + return 2 + + version, filename = argv[1:3] + print(f"Generating GitHub release notes for version {version}") + rst_body = extract_changelog_entries_for(version) + md_body = convert_rst_to_md(rst_body) + Path(filename).write_text(md_body, encoding="UTF-8") + print() + print(f"Done: {filename}") + print() + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/scripts/prepare-release-pr.py b/scripts/prepare-release-pr.py index 7a80de7edaa..b288e3b3982 100644 --- a/scripts/prepare-release-pr.py +++ b/scripts/prepare-release-pr.py @@ -1,3 +1,4 @@ +# mypy: disallow-untyped-defs """ This script is part of the pytest release process which is triggered manually in the Actions tab of the repository. @@ -9,19 +10,20 @@ After that, it will create a release using the `release` tox environment, and push a new PR. -**Token**: currently the token from the GitHub Actions is used, pushed with -`pytest bot ` commit author. +Note: the script uses the `gh` command-line tool, so `GH_TOKEN` must be set in the environment. """ + +from __future__ import annotations + import argparse -import re from pathlib import Path +import re from subprocess import check_call from subprocess import check_output from subprocess import run from colorama import Fore from colorama import init -from github3.repos import Repository class InvalidFeatureRelease(Exception): @@ -31,24 +33,26 @@ class InvalidFeatureRelease(Exception): SLUG = "pytest-dev/pytest" PR_BODY = """\ -Created automatically from manual trigger. +Created by the [prepare release pr]\ +(https://github.com/pytest-dev/pytest/actions/workflows/prepare-release-pr.yml) workflow. -Once all builds pass and it has been **approved** by one or more maintainers, the build -can be released by pushing a tag `{version}` to this repository. -""" +Once all builds pass and it has been **approved** by one or more maintainers, start the \ +[deploy](https://github.com/pytest-dev/pytest/actions/workflows/deploy.yml) workflow, using these parameters: +* `Use workflow from`: `release-{version}`. +* `Release version`: `{version}`. -def login(token: str) -> Repository: - import github3 +Or execute on the command line: - github = github3.login(token=token) - owner, repo = SLUG.split("/") - return github.repository(owner, repo) +```console +gh workflow run deploy.yml -r release-{version} -f version={version} +``` + +After the workflow has been approved by a core maintainer, the package will be uploaded to PyPI automatically. +""" -def prepare_release_pr( - base_branch: str, is_major: bool, token: str, prerelease: str -) -> None: +def prepare_release_pr(base_branch: str, is_major: bool, prerelease: str) -> None: print() print(f"Processing release for branch {Fore.CYAN}{base_branch}") @@ -66,7 +70,7 @@ def prepare_release_pr( ) except InvalidFeatureRelease as e: print(f"{Fore.RED}{e}") - raise SystemExit(1) + raise SystemExit(1) from None print(f"Version: {Fore.CYAN}{version}") @@ -115,22 +119,26 @@ def prepare_release_pr( check=True, ) - oauth_url = f"https://{token}:x-oauth-basic@github.com/{SLUG}.git" run( - ["git", "push", oauth_url, f"HEAD:{release_branch}", "--force"], + ["git", "push", "origin", f"HEAD:{release_branch}", "--force"], check=True, ) print(f"Branch {Fore.CYAN}{release_branch}{Fore.RESET} pushed.") body = PR_BODY.format(version=version) - repo = login(token) - pr = repo.create_pull( - f"Prepare release {version}", - base=base_branch, - head=release_branch, - body=body, + run( + [ + "gh", + "pr", + "new", + f"--base={base_branch}", + f"--head={release_branch}", + f"--title=Release {version}", + f"--body={body}", + "--draft", + ], + check=True, ) - print(f"Pull request {Fore.CYAN}{pr.url}{Fore.RESET} created.") def find_next_version( @@ -147,7 +155,7 @@ def find_next_version( last_version = valid_versions[-1] if is_major: - return f"{last_version[0]+1}.0.0{prerelease}" + return f"{last_version[0] + 1}.0.0{prerelease}" elif is_feature_release: return f"{last_version[0]}.{last_version[1] + 1}.0{prerelease}" else: @@ -158,14 +166,12 @@ def main() -> None: init(autoreset=True) parser = argparse.ArgumentParser() parser.add_argument("base_branch") - parser.add_argument("token") parser.add_argument("--major", action="store_true", default=False) parser.add_argument("--prerelease", default="") options = parser.parse_args() prepare_release_pr( base_branch=options.base_branch, is_major=options.major, - token=options.token, prerelease=options.prerelease, ) diff --git a/scripts/publish-gh-release-notes.py b/scripts/publish-gh-release-notes.py deleted file mode 100644 index 68cbd7adffd..00000000000 --- a/scripts/publish-gh-release-notes.py +++ /dev/null @@ -1,102 +0,0 @@ -""" -Script used to publish GitHub release notes extracted from CHANGELOG.rst. - -This script is meant to be executed after a successful deployment in GitHub actions. - -Uses the following environment variables: - -* GIT_TAG: the name of the tag of the current commit. -* GH_RELEASE_NOTES_TOKEN: a personal access token with 'repo' permissions. - - Create one at: - - https://github.com/settings/tokens - - This token should be set in a secret in the repository, which is exposed as an - environment variable in the main.yml workflow file. - -The script also requires ``pandoc`` to be previously installed in the system. - -Requires Python3.6+. -""" -import os -import re -import sys -from pathlib import Path - -import github3 -import pypandoc - - -def publish_github_release(slug, token, tag_name, body): - github = github3.login(token=token) - owner, repo = slug.split("/") - repo = github.repository(owner, repo) - return repo.create_release(tag_name=tag_name, body=body) - - -def parse_changelog(tag_name): - p = Path(__file__).parent.parent / "doc/en/changelog.rst" - changelog_lines = p.read_text(encoding="UTF-8").splitlines() - - title_regex = re.compile(r"pytest (\d\.\d+\.\d+) \(\d{4}-\d{2}-\d{2}\)") - consuming_version = False - version_lines = [] - for line in changelog_lines: - m = title_regex.match(line) - if m: - # found the version we want: start to consume lines until we find the next version title - if m.group(1) == tag_name: - consuming_version = True - # found a new version title while parsing the version we want: break out - elif consuming_version: - break - if consuming_version: - version_lines.append(line) - - return "\n".join(version_lines) - - -def convert_rst_to_md(text): - return pypandoc.convert_text( - text, "md", format="rst", extra_args=["--wrap=preserve"] - ) - - -def main(argv): - if len(argv) > 1: - tag_name = argv[1] - else: - tag_name = os.environ.get("GITHUB_REF") - if not tag_name: - print("tag_name not given and $GITHUB_REF not set", file=sys.stderr) - return 1 - if tag_name.startswith("refs/tags/"): - tag_name = tag_name[len("refs/tags/") :] - - token = os.environ.get("GH_RELEASE_NOTES_TOKEN") - if not token: - print("GH_RELEASE_NOTES_TOKEN not set", file=sys.stderr) - return 1 - - slug = os.environ.get("GITHUB_REPOSITORY") - if not slug: - print("GITHUB_REPOSITORY not set", file=sys.stderr) - return 1 - - rst_body = parse_changelog(tag_name) - md_body = convert_rst_to_md(rst_body) - if not publish_github_release(slug, token, tag_name, md_body): - print("Could not publish release notes:", file=sys.stderr) - print(md_body, file=sys.stderr) - return 5 - - print() - print(f"Release notes for {tag_name} published successfully:") - print(f"https://github.com/{slug}/releases/tag/{tag_name}") - print() - return 0 - - -if __name__ == "__main__": - sys.exit(main(sys.argv)) diff --git a/scripts/release.patch.rst b/scripts/release.patch.rst index 59fbe50ce0e..120cae51702 100644 --- a/scripts/release.patch.rst +++ b/scripts/release.patch.rst @@ -3,9 +3,7 @@ pytest-{version} pytest {version} has just been released to PyPI. -This is a bug-fix release, being a drop-in replacement. To upgrade:: - - pip install --upgrade pytest +This is a bug-fix release, being a drop-in replacement. The full changelog is available at https://docs.pytest.org/en/stable/changelog.html. diff --git a/scripts/release.py b/scripts/release.py index 19fef428428..6549cd00a3d 100644 --- a/scripts/release.py +++ b/scripts/release.py @@ -1,7 +1,12 @@ +# mypy: disallow-untyped-defs """Invoke development tasks.""" + +from __future__ import annotations + import argparse import os from pathlib import Path +import re from subprocess import call from subprocess import check_call from subprocess import check_output @@ -10,19 +15,29 @@ from colorama import init -def announce(version, template_name, doc_version): +def announce(version: str, template_name: str, doc_version: str) -> None: """Generates a new release announcement entry in the docs.""" - # Get our list of authors - stdout = check_output(["git", "describe", "--abbrev=0", "--tags"]) - stdout = stdout.decode("utf-8") + # Get our list of authors and co-authors. + stdout = check_output(["git", "describe", "--abbrev=0", "--tags"], encoding="UTF-8") last_version = stdout.strip() + rev_range = f"{last_version}..HEAD" - stdout = check_output(["git", "log", f"{last_version}..HEAD", "--format=%aN"]) - stdout = stdout.decode("utf-8") + authors = check_output( + ["git", "log", rev_range, "--format=%aN"], encoding="UTF-8" + ).splitlines() + + co_authors_output = check_output( + ["git", "log", rev_range, "--format=%(trailers:key=Co-authored-by) "], + encoding="UTF-8", + ) + co_authors: list[str] = [] + for co_author_line in co_authors_output.splitlines(): + if m := re.search(r"Co-authored-by: (.+?)<", co_author_line): + co_authors.append(m.group(1).strip()) contributors = { name - for name in stdout.splitlines() + for name in authors + co_authors if not name.endswith("[bot]") and name != "pytest bot" } @@ -61,7 +76,7 @@ def announce(version, template_name, doc_version): check_call(["git", "add", str(target)]) -def regen(version): +def regen(version: str) -> None: """Call regendoc tool to update examples and pytest output in the docs.""" print(f"{Fore.CYAN}[generate.regen] {Fore.RESET}Updating docs") check_call( @@ -70,7 +85,7 @@ def regen(version): ) -def fix_formatting(): +def fix_formatting() -> None: """Runs pre-commit in all files to ensure they are formatted correctly""" print( f"{Fore.CYAN}[generate.fix linting] {Fore.RESET}Fixing formatting using pre-commit" @@ -78,13 +93,15 @@ def fix_formatting(): call(["pre-commit", "run", "--all-files"]) -def check_links(): +def check_links() -> None: """Runs sphinx-build to check links""" print(f"{Fore.CYAN}[generate.check_links] {Fore.RESET}Checking links") check_call(["tox", "-e", "docs-checklinks"]) -def pre_release(version, template_name, doc_version, *, skip_check_links): +def pre_release( + version: str, template_name: str, doc_version: str, *, skip_check_links: bool +) -> None: """Generates new docs, release announcements and creates a local tag.""" announce(version, template_name, doc_version) regen(version) @@ -102,12 +119,12 @@ def pre_release(version, template_name, doc_version, *, skip_check_links): print("Please push your branch and open a PR.") -def changelog(version, write_out=False): +def changelog(version: str, write_out: bool = False) -> None: addopts = [] if write_out else ["--draft"] - check_call(["towncrier", "--yes", "--version", version] + addopts) + check_call(["towncrier", "build", "--yes", "--version", version, *addopts]) -def main(): +def main() -> None: init(autoreset=True) parser = argparse.ArgumentParser() parser.add_argument("version", help="Release version") diff --git a/scripts/towncrier-draft-to-file.py b/scripts/towncrier-draft-to-file.py deleted file mode 100644 index 1f1068689a8..00000000000 --- a/scripts/towncrier-draft-to-file.py +++ /dev/null @@ -1,17 +0,0 @@ -import sys -from subprocess import call - - -def main(): - """ - Platform agnostic wrapper script for towncrier. - Fixes the issue (#7251) where windows users are unable to natively run tox -e docs to build pytest docs. - """ - with open( - "doc/en/_changelog_towncrier_draft.rst", "w", encoding="utf-8" - ) as draft_file: - return call(("towncrier", "--draft"), stdout=draft_file) - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/scripts/update-plugin-list.py b/scripts/update-plugin-list.py index ea7e7986e15..61debb44043 100644 --- a/scripts/update-plugin-list.py +++ b/scripts/update-plugin-list.py @@ -1,26 +1,52 @@ +# mypy: disallow-untyped-defs +from __future__ import annotations + +from collections.abc import Iterable +from collections.abc import Iterator import datetime import pathlib import re from textwrap import dedent from textwrap import indent +from typing import Any +from typing import TypedDict import packaging.version -import requests +import platformdirs +from requests_cache import CachedResponse +from requests_cache import CachedSession +from requests_cache import OriginalResponse +from requests_cache import SQLiteCache import tabulate -import wcwidth from tqdm import tqdm +import wcwidth + FILE_HEAD = r""" +.. Note this file is autogenerated by scripts/update-plugin-list.py - usually weekly via github action + .. _plugin-list: -Plugin List -=========== +Pytest Plugin List +================== -PyPI projects that match "pytest-\*" are considered plugins and are listed -automatically together with a manually-maintained list in `the source -code `_. +Below is an automated compilation of ``pytest``` plugins available on `PyPI `_. +It includes PyPI projects whose names begin with ``pytest-`` or ``pytest_`` and a handful of manually selected projects. Packages classified as inactive are excluded. +For detailed insights into how this list is generated, +please refer to `the update script `_. + +.. warning:: + + Please be aware that this list is not a curated collection of projects + and does not undergo a systematic review process. + It serves purely as an informational resource to aid in the discovery of ``pytest`` plugins. + + Do not presume any endorsement from the ``pytest`` project or its developers, + and always conduct your own quality assessment before incorporating any of these plugins into your own projects. + + .. The following conditional uses a different format for this list when creating a PDF, because otherwise the table gets far too wide for the page. @@ -37,6 +63,11 @@ ) ADDITIONAL_PROJECTS = { # set of additional projects to consider as plugins "logassert", + "logot", + "nuts", + "flask_fixture", + "databricks-labs-pytester", + "tursu", } @@ -53,19 +84,59 @@ def escape_rst(text: str) -> str: return text -def iter_plugins(): - regex = r">([\d\w-]*)" - response = requests.get("https://pypi.org/simple") +def project_response_with_refresh( + session: CachedSession, name: str, last_serial: int +) -> OriginalResponse | CachedResponse: + """Get a http cached pypi project + + force refresh in case of last serial mismatch + """ + response = session.get(f"https://pypi.org/pypi/{name}/json") + if int(response.headers.get("X-PyPI-Last-Serial", -1)) != last_serial: + response = session.get(f"https://pypi.org/pypi/{name}/json", refresh=True) + return response - match_names = (match.groups()[0] for match in re.finditer(regex, response.text)) - plugin_names = [ - name - for name in match_names - if name.startswith("pytest-") or name in ADDITIONAL_PROJECTS - ] - for name in tqdm(plugin_names, smoothing=0): - response = requests.get(f"https://pypi.org/pypi/{name}/json") +def get_session() -> CachedSession: + """Configures the requests-cache session""" + cache_path = platformdirs.user_cache_path("pytest-plugin-list") + cache_path.mkdir(exist_ok=True, parents=True) + cache_file = cache_path.joinpath("http_cache.sqlite3") + return CachedSession(backend=SQLiteCache(cache_file)) + + +def pytest_plugin_projects_from_pypi(session: CachedSession) -> dict[str, int]: + response = session.get( + "https://pypi.org/simple", + headers={"Accept": "application/vnd.pypi.simple.v1+json"}, + refresh=True, + ) + return { + name: p["_last-serial"] + for p in response.json()["projects"] + if ( + (name := p["name"]).startswith(("pytest-", "pytest_")) + or name in ADDITIONAL_PROJECTS + ) + } + + +class PluginInfo(TypedDict): + """Relevant information about a plugin to generate the summary.""" + + name: str + summary: str + last_release: str + status: str + requires: str + + +def iter_plugins() -> Iterator[PluginInfo]: + session = get_session() + name_2_serial = pytest_plugin_projects_from_pypi(session) + + for name, last_serial in tqdm(name_2_serial.items(), smoothing=0): + response = project_response_with_refresh(session, name, last_serial) if response.status_code == 404: # Some packages, like pytest-azurepipelines42, are included in https://pypi.org/simple # but return 404 on the JSON API. Skip. @@ -87,7 +158,7 @@ def iter_plugins(): requires = requirement break - def version_sort_key(version_string): + def version_sort_key(version_string: str) -> Any: """ Return the sort key for the given version string returned by the API. @@ -106,27 +177,26 @@ def version_sort_key(version_string): ) last_release = release_date.strftime("%b %d, %Y") break - name = f':pypi:`{info["name"]}`' + name = f":pypi:`{info['name']}`" summary = "" if info["summary"]: summary = escape_rst(info["summary"].replace("\n", "")) yield { "name": name, "summary": summary.strip(), - "last release": last_release, + "last_release": last_release, "status": status, "requires": requires, } -def plugin_definitions(plugins): +def plugin_definitions(plugins: Iterable[PluginInfo]) -> Iterator[str]: """Return RST for the plugin list that fits better on a vertical page.""" - for plugin in plugins: yield dedent( f""" - {plugin['name']} - *last release*: {plugin["last release"]}, + {plugin["name"]} + *last release*: {plugin["last_release"]}, *status*: {plugin["status"]}, *requires*: {plugin["requires"]} @@ -135,8 +205,8 @@ def plugin_definitions(plugins): ) -def main(): - plugins = list(iter_plugins()) +def main() -> None: + plugins = [*iter_plugins()] reference_dir = pathlib.Path("doc", "en", "reference") @@ -146,7 +216,7 @@ def main(): f.write(f"This list contains {len(plugins)} plugins.\n\n") f.write(".. only:: not latex\n\n") - wcwidth # reference library that must exist for tabulate to work + _ = wcwidth # reference library that must exist for tabulate to work plugin_table = tabulate.tabulate(plugins, headers="keys", tablefmt="rst") f.write(indent(plugin_table, " ")) f.write("\n\n") diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index bc93b1c06b9..00000000000 --- a/setup.cfg +++ /dev/null @@ -1,107 +0,0 @@ -[metadata] -name = pytest -description = pytest: simple powerful testing with Python -long_description = file: README.rst -long_description_content_type = text/x-rst -url = https://docs.pytest.org/en/latest/ -author = Holger Krekel, Bruno Oliveira, Ronny Pfannschmidt, Floris Bruynooghe, Brianna Laugher, Florian Bruhin and others -license = MIT -license_files = LICENSE -platforms = unix, linux, osx, cygwin, win32 -classifiers = - Development Status :: 6 - Mature - Intended Audience :: Developers - License :: OSI Approved :: MIT License - Operating System :: MacOS :: MacOS X - Operating System :: Microsoft :: Windows - Operating System :: POSIX - Programming Language :: Python :: 3 - Programming Language :: Python :: 3 :: Only - Programming Language :: Python :: 3.7 - 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 :: Libraries - Topic :: Software Development :: Testing - Topic :: Utilities -keywords = test, unittest -project_urls = - Changelog=https://docs.pytest.org/en/stable/changelog.html - Twitter=https://twitter.com/pytestdotorg - Source=https://github.com/pytest-dev/pytest - Tracker=https://github.com/pytest-dev/pytest/issues - -[options] -packages = - _pytest - _pytest._code - _pytest._io - _pytest._py - _pytest.assertion - _pytest.config - _pytest.mark - pytest -py_modules = py -install_requires = - iniconfig - packaging - pluggy>=0.12,<2.0 - colorama;sys_platform=="win32" - exceptiongroup>=1.0.0rc8;python_version<"3.11" - importlib-metadata>=0.12;python_version<"3.8" - tomli>=1.0.0;python_version<"3.11" -python_requires = >=3.7 -package_dir = - =src -setup_requires = - setuptools - setuptools-scm>=6.0 -zip_safe = no - -[options.entry_points] -console_scripts = - pytest=pytest:console_main - py.test=pytest:console_main - -[options.extras_require] -testing = - argcomplete - attrs>=19.2.0 - hypothesis>=3.56 - mock - nose - pygments>=2.7.2 - requests - setuptools - xmlschema - -[options.package_data] -_pytest = py.typed -pytest = py.typed - -[build_sphinx] -source_dir = doc/en/ -build_dir = doc/build -all_files = 1 - -[check-manifest] -ignore = - src/_pytest/_version.py - -[devpi:upload] -formats = sdist.tgz,bdist_wheel - -[mypy] -mypy_path = src -check_untyped_defs = True -disallow_any_generics = True -ignore_missing_imports = True -show_error_codes = True -strict_equality = True -warn_redundant_casts = True -warn_return_any = True -warn_unreachable = True -warn_unused_configs = True -no_implicit_reexport = True diff --git a/setup.py b/setup.py deleted file mode 100644 index 7f1a1763ca9..00000000000 --- a/setup.py +++ /dev/null @@ -1,4 +0,0 @@ -from setuptools import setup - -if __name__ == "__main__": - setup() diff --git a/src/_pytest/__init__.py b/src/_pytest/__init__.py index 8a406c5c751..8eb8ec9605c 100644 --- a/src/_pytest/__init__.py +++ b/src/_pytest/__init__.py @@ -1,9 +1,13 @@ +from __future__ import annotations + + __all__ = ["__version__", "version_tuple"] try: - from ._version import version as __version__, version_tuple + from ._version import version as __version__ + from ._version import version_tuple except ImportError: # pragma: no cover # broken installation, we don't even try # unknown only works because we do poor mans version compare __version__ = "unknown" - version_tuple = (0, 0, "unknown") # type:ignore[assignment] + version_tuple = (0, 0, "unknown") diff --git a/src/_pytest/_argcomplete.py b/src/_pytest/_argcomplete.py index 6a8083770ae..59426ef949e 100644 --- a/src/_pytest/_argcomplete.py +++ b/src/_pytest/_argcomplete.py @@ -61,13 +61,14 @@ which should throw a KeyError: 'COMPLINE' (which is properly set by the global argcomplete script). """ + +from __future__ import annotations + import argparse +from glob import glob import os import sys -from glob import glob from typing import Any -from typing import List -from typing import Optional class FastFilesCompleter: @@ -76,7 +77,7 @@ class FastFilesCompleter: def __init__(self, directories: bool = True) -> None: self.directories = directories - def __call__(self, prefix: str, **kwargs: Any) -> List[str]: + def __call__(self, prefix: str, **kwargs: Any) -> list[str]: # Only called on non option completions. if os.sep in prefix[1:]: prefix_dir = len(os.path.dirname(prefix) + os.sep) @@ -103,7 +104,7 @@ def __call__(self, prefix: str, **kwargs: Any) -> List[str]: import argcomplete.completers except ImportError: sys.exit(-1) - filescompleter: Optional[FastFilesCompleter] = FastFilesCompleter() + filescompleter: FastFilesCompleter | None = FastFilesCompleter() def try_argcomplete(parser: argparse.ArgumentParser) -> None: argcomplete.autocomplete(parser, always_complete_options=False) diff --git a/src/_pytest/_code/__init__.py b/src/_pytest/_code/__init__.py index 511d0dde661..7f67a2e3e0a 100644 --- a/src/_pytest/_code/__init__.py +++ b/src/_pytest/_code/__init__.py @@ -1,4 +1,7 @@ """Python inspection/code generation API.""" + +from __future__ import annotations + from .code import Code from .code import ExceptionInfo from .code import filter_traceback @@ -9,14 +12,15 @@ from .source import getrawcode from .source import Source + __all__ = [ "Code", "ExceptionInfo", - "filter_traceback", "Frame", - "getfslineno", - "getrawcode", + "Source", "Traceback", "TracebackEntry", - "Source", + "filter_traceback", + "getfslineno", + "getrawcode", ] diff --git a/src/_pytest/_code/code.py b/src/_pytest/_code/code.py index 9b051332baf..f1241f14136 100644 --- a/src/_pytest/_code/code.py +++ b/src/_pytest/_code/code.py @@ -1,33 +1,35 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + import ast +from collections.abc import Callable +from collections.abc import Iterable +from collections.abc import Mapping +from collections.abc import Sequence import dataclasses import inspect -import os -import re -import sys -import traceback from inspect import CO_VARARGS from inspect import CO_VARKEYWORDS from io import StringIO +import os from pathlib import Path +import re +import sys +from traceback import extract_tb +from traceback import format_exception from traceback import format_exception_only +from traceback import FrameSummary from types import CodeType from types import FrameType from types import TracebackType from typing import Any -from typing import Callable from typing import ClassVar -from typing import Dict +from typing import Final +from typing import final from typing import Generic -from typing import Iterable -from typing import List -from typing import Mapping -from typing import Optional +from typing import Literal from typing import overload -from typing import Pattern -from typing import Sequence -from typing import Set -from typing import Tuple -from typing import Type +from typing import SupportsIndex from typing import TYPE_CHECKING from typing import TypeVar from typing import Union @@ -42,22 +44,19 @@ from _pytest._io import TerminalWriter from _pytest._io.saferepr import safeformat from _pytest._io.saferepr import saferepr -from _pytest.compat import final from _pytest.compat import get_real_func from _pytest.deprecated import check_ispytest from _pytest.pathlib import absolutepath from _pytest.pathlib import bestrelpath -if TYPE_CHECKING: - from typing_extensions import Final - from typing_extensions import Literal - from typing_extensions import SupportsIndex - - _TracebackStyle = Literal["long", "short", "line", "no", "native", "value", "auto"] -if sys.version_info[:2] < (3, 11): +if sys.version_info < (3, 11): from exceptiongroup import BaseExceptionGroup +TracebackStyle = Literal["long", "short", "line", "no", "native", "value", "auto"] + +EXCEPTION_OR_MORE = Union[type[BaseException], tuple[type[BaseException], ...]] + class Code: """Wrapper around Python code objects.""" @@ -68,7 +67,7 @@ def __init__(self, obj: CodeType) -> None: self.raw = obj @classmethod - def from_function(cls, obj: object) -> "Code": + def from_function(cls, obj: object) -> Code: return cls(getrawcode(obj)) def __eq__(self, other): @@ -86,7 +85,7 @@ def name(self) -> str: return self.raw.co_name @property - def path(self) -> Union[Path, str]: + def path(self) -> Path | str: """Return a path object pointing to source code, or an ``str`` in case of ``OSError`` / non-existing file.""" if not self.raw.co_filename: @@ -103,17 +102,17 @@ def path(self) -> Union[Path, str]: return self.raw.co_filename @property - def fullsource(self) -> Optional["Source"]: + def fullsource(self) -> Source | None: """Return a _pytest._code.Source object for the full source file of the code.""" full, _ = findsource(self.raw) return full - def source(self) -> "Source": + def source(self) -> Source: """Return a _pytest._code.Source object for the code object's source only.""" # return source only for that part of code return Source(self.raw) - def getargs(self, var: bool = False) -> Tuple[str, ...]: + def getargs(self, var: bool = False) -> tuple[str, ...]: """Return a tuple with the argument names for the code object. If 'var' is set True also return the names of the variable and @@ -142,11 +141,11 @@ def lineno(self) -> int: return self.raw.f_lineno - 1 @property - def f_globals(self) -> Dict[str, Any]: + def f_globals(self) -> dict[str, Any]: return self.raw.f_globals @property - def f_locals(self) -> Dict[str, Any]: + def f_locals(self) -> dict[str, Any]: return self.raw.f_locals @property @@ -154,7 +153,7 @@ def code(self) -> Code: return Code(self.raw.f_code) @property - def statement(self) -> "Source": + def statement(self) -> Source: """Statement this frame is at.""" if self.code.fullsource is None: return Source("") @@ -198,20 +197,59 @@ class TracebackEntry: def __init__( self, rawentry: TracebackType, - repr_style: Optional['Literal["short", "long"]'] = None, + repr_style: Literal["short", "long"] | None = None, ) -> None: - self._rawentry: "Final" = rawentry - self._repr_style: "Final" = repr_style + self._rawentry: Final = rawentry + self._repr_style: Final = repr_style def with_repr_style( - self, repr_style: Optional['Literal["short", "long"]'] - ) -> "TracebackEntry": + self, repr_style: Literal["short", "long"] | None + ) -> TracebackEntry: return TracebackEntry(self._rawentry, repr_style) @property def lineno(self) -> int: return self._rawentry.tb_lineno - 1 + def get_python_framesummary(self) -> FrameSummary: + # Python's built-in traceback module implements all the nitty gritty + # details to get column numbers of out frames. + stack_summary = extract_tb(self._rawentry, limit=1) + return stack_summary[0] + + # Column and end line numbers introduced in python 3.11 + if sys.version_info < (3, 11): + + @property + def end_lineno_relative(self) -> int | None: + return None + + @property + def colno(self) -> int | None: + return None + + @property + def end_colno(self) -> int | None: + return None + else: + + @property + def end_lineno_relative(self) -> int | None: + frame_summary = self.get_python_framesummary() + if frame_summary.end_lineno is None: # pragma: no cover + return None + return frame_summary.end_lineno - 1 - self.frame.code.firstlineno + + @property + def colno(self) -> int | None: + """Starting byte offset of the expression in the traceback entry.""" + return self.get_python_framesummary().colno + + @property + def end_colno(self) -> int | None: + """Ending byte offset of the expression in the traceback entry.""" + return self.get_python_framesummary().end_colno + @property def frame(self) -> Frame: return Frame(self._rawentry.tb_frame) @@ -221,22 +259,22 @@ def relline(self) -> int: return self.lineno - self.frame.code.firstlineno def __repr__(self) -> str: - return "" % (self.frame.code.path, self.lineno + 1) + return f"" @property - def statement(self) -> "Source": + def statement(self) -> Source: """_pytest._code.Source object for the current statement.""" source = self.frame.code.fullsource assert source is not None return source.getstatement(self.lineno) @property - def path(self) -> Union[Path, str]: + def path(self) -> Path | str: """Path to the source code.""" return self.frame.code.path @property - def locals(self) -> Dict[str, Any]: + def locals(self) -> dict[str, Any]: """Locals of underlying frame.""" return self.frame.f_locals @@ -244,8 +282,8 @@ def getfirstlinesource(self) -> int: return self.frame.code.firstlineno def getsource( - self, astcache: Optional[Dict[Union[str, Path], ast.AST]] = None - ) -> Optional["Source"]: + self, astcache: dict[str | Path, ast.AST] | None = None + ) -> Source | None: """Return failing source code.""" # we use the passed in astcache to not reparse asttrees # within exception info printing @@ -271,7 +309,7 @@ def getsource( source = property(getsource) - def ishidden(self, excinfo: Optional["ExceptionInfo[BaseException]"]) -> bool: + def ishidden(self, excinfo: ExceptionInfo[BaseException] | None) -> bool: """Return True if the current frame has a var __tracebackhide__ resolving to True. @@ -280,9 +318,7 @@ def ishidden(self, excinfo: Optional["ExceptionInfo[BaseException]"]) -> bool: Mostly for internal use. """ - tbh: Union[ - bool, Callable[[Optional[ExceptionInfo[BaseException]]], bool] - ] = False + tbh: bool | Callable[[ExceptionInfo[BaseException] | None], bool] = False for maybe_ns_dct in (self.frame.f_locals, self.frame.f_globals): # in normal cases, f_locals and f_globals are dictionaries # however via `exec(...)` / `eval(...)` they can be other types @@ -309,12 +345,7 @@ def __str__(self) -> str: # This output does not quite match Python's repr for traceback entries, # but changing it to do so would break certain plugins. See # https://github.com/pytest-dev/pytest/pull/7535/ for details. - return " File %r:%d in %s\n %s\n" % ( - str(self.path), - self.lineno + 1, - name, - line, - ) + return f" File '{self.path}':{self.lineno + 1} in {name}\n {line}\n" @property def name(self) -> str: @@ -322,18 +353,18 @@ def name(self) -> str: return self.frame.code.raw.co_name -class Traceback(List[TracebackEntry]): +class Traceback(list[TracebackEntry]): """Traceback objects encapsulate and offer higher level access to Traceback entries.""" def __init__( self, - tb: Union[TracebackType, Iterable[TracebackEntry]], + tb: TracebackType | Iterable[TracebackEntry], ) -> None: """Initialize from given python traceback object and ExceptionInfo.""" if isinstance(tb, TracebackType): def f(cur: TracebackType) -> Iterable[TracebackEntry]: - cur_: Optional[TracebackType] = cur + cur_: TracebackType | None = cur while cur_ is not None: yield TracebackEntry(cur_) cur_ = cur_.tb_next @@ -344,11 +375,11 @@ def f(cur: TracebackType) -> Iterable[TracebackEntry]: def cut( self, - path: Optional[Union["os.PathLike[str]", str]] = None, - lineno: Optional[int] = None, - firstlineno: Optional[int] = None, - excludepath: Optional["os.PathLike[str]"] = None, - ) -> "Traceback": + path: os.PathLike[str] | str | None = None, + lineno: int | None = None, + firstlineno: int | None = None, + excludepath: os.PathLike[str] | None = None, + ) -> Traceback: """Return a Traceback instance wrapping part of this Traceback. By providing any combination of path, lineno and firstlineno, the @@ -379,16 +410,12 @@ def cut( return self @overload - def __getitem__(self, key: "SupportsIndex") -> TracebackEntry: - ... + def __getitem__(self, key: SupportsIndex) -> TracebackEntry: ... @overload - def __getitem__(self, key: slice) -> "Traceback": - ... + def __getitem__(self, key: slice) -> Traceback: ... - def __getitem__( - self, key: Union["SupportsIndex", slice] - ) -> Union[TracebackEntry, "Traceback"]: + def __getitem__(self, key: SupportsIndex | slice) -> TracebackEntry | Traceback: if isinstance(key, slice): return self.__class__(super().__getitem__(key)) else: @@ -396,12 +423,9 @@ def __getitem__( def filter( self, - # TODO(py38): change to positional only. - _excinfo_or_fn: Union[ - "ExceptionInfo[BaseException]", - Callable[[TracebackEntry], bool], - ], - ) -> "Traceback": + excinfo_or_fn: ExceptionInfo[BaseException] | Callable[[TracebackEntry], bool], + /, + ) -> Traceback: """Return a Traceback instance with certain items removed. If the filter is an `ExceptionInfo`, removes all the ``TracebackEntry``s @@ -411,34 +435,60 @@ def filter( ``TracebackEntry`` instance, and should return True when the item should be added to the ``Traceback``, False when not. """ - if isinstance(_excinfo_or_fn, ExceptionInfo): - fn = lambda x: not x.ishidden(_excinfo_or_fn) # noqa: E731 + if isinstance(excinfo_or_fn, ExceptionInfo): + fn = lambda x: not x.ishidden(excinfo_or_fn) # noqa: E731 else: - fn = _excinfo_or_fn + fn = excinfo_or_fn return Traceback(filter(fn, self)) - def recursionindex(self) -> Optional[int]: + def recursionindex(self) -> int | None: """Return the index of the frame/TracebackEntry where recursion originates if appropriate, None if no recursion occurred.""" - cache: Dict[Tuple[Any, int, int], List[Dict[str, Any]]] = {} + cache: dict[tuple[Any, int, int], list[dict[str, Any]]] = {} for i, entry in enumerate(self): # id for the code.raw is needed to work around # the strange metaprogramming in the decorator lib from pypi # which generates code objects that have hash/value equality # XXX needs a test key = entry.frame.code.path, id(entry.frame.code.raw), entry.lineno - # print "checking for recursion at", key values = cache.setdefault(key, []) + # Since Python 3.13 f_locals is a proxy, freeze it. + loc = dict(entry.frame.f_locals) if values: - f = entry.frame - loc = f.f_locals for otherloc in values: if otherloc == loc: return i - values.append(entry.frame.f_locals) + values.append(loc) return None +def stringify_exception( + exc: BaseException, include_subexception_msg: bool = True +) -> str: + try: + notes = getattr(exc, "__notes__", []) + except KeyError: + # Workaround for https://github.com/python/cpython/issues/98778 on + # Python <= 3.9, and some 3.10 and 3.11 patch versions. + HTTPError = getattr(sys.modules.get("urllib.error", None), "HTTPError", ()) + if sys.version_info < (3, 12) and isinstance(exc, HTTPError): + notes = [] + else: # pragma: no cover + # exception not related to above bug, reraise + raise + if not include_subexception_msg and isinstance(exc, BaseExceptionGroup): + message = exc.message + else: + message = str(exc) + + return "\n".join( + [ + message, + *notes, + ] + ) + + E = TypeVar("E", bound=BaseException, covariant=True) @@ -449,15 +499,15 @@ class ExceptionInfo(Generic[E]): _assert_start_repr: ClassVar = "AssertionError('assert " - _excinfo: Optional[Tuple[Type["E"], "E", TracebackType]] + _excinfo: tuple[type[E], E, TracebackType] | None _striptext: str - _traceback: Optional[Traceback] + _traceback: Traceback | None def __init__( self, - excinfo: Optional[Tuple[Type["E"], "E", TracebackType]], + excinfo: tuple[type[E], E, TracebackType] | None, striptext: str = "", - traceback: Optional[Traceback] = None, + traceback: Traceback | None = None, *, _ispytest: bool = False, ) -> None: @@ -473,8 +523,8 @@ def from_exception( # This is OK to ignore because this class is (conceptually) readonly. # See https://github.com/python/mypy/issues/7049. exception: E, # type: ignore[misc] - exprinfo: Optional[str] = None, - ) -> "ExceptionInfo[E]": + exprinfo: str | None = None, + ) -> ExceptionInfo[E]: """Return an ExceptionInfo for an existing exception. The exception must have a non-``None`` ``__traceback__`` attribute, @@ -489,18 +539,19 @@ def from_exception( .. versionadded:: 7.4 """ - assert ( - exception.__traceback__ - ), "Exceptions passed to ExcInfo.from_exception(...) must have a non-None __traceback__." + assert exception.__traceback__, ( + "Exceptions passed to ExcInfo.from_exception(...)" + " must have a non-None __traceback__." + ) exc_info = (type(exception), exception, exception.__traceback__) return cls.from_exc_info(exc_info, exprinfo) @classmethod def from_exc_info( cls, - exc_info: Tuple[Type[E], E, TracebackType], - exprinfo: Optional[str] = None, - ) -> "ExceptionInfo[E]": + exc_info: tuple[type[E], E, TracebackType], + exprinfo: str | None = None, + ) -> ExceptionInfo[E]: """Like :func:`from_exception`, but using old-style exc_info tuple.""" _striptext = "" if exprinfo is None and isinstance(exc_info[1], AssertionError): @@ -513,9 +564,7 @@ def from_exc_info( return cls(exc_info, _striptext, _ispytest=True) @classmethod - def from_current( - cls, exprinfo: Optional[str] = None - ) -> "ExceptionInfo[BaseException]": + def from_current(cls, exprinfo: str | None = None) -> ExceptionInfo[BaseException]: """Return an ExceptionInfo matching the current traceback. .. warning:: @@ -535,45 +584,45 @@ def from_current( return ExceptionInfo.from_exc_info(exc_info, exprinfo) @classmethod - def for_later(cls) -> "ExceptionInfo[E]": + def for_later(cls) -> ExceptionInfo[E]: """Return an unfilled ExceptionInfo.""" return cls(None, _ispytest=True) - def fill_unfilled(self, exc_info: Tuple[Type[E], E, TracebackType]) -> None: + def fill_unfilled(self, exc_info: tuple[type[E], E, TracebackType]) -> None: """Fill an unfilled ExceptionInfo created with ``for_later()``.""" assert self._excinfo is None, "ExceptionInfo was already filled" self._excinfo = exc_info @property - def type(self) -> Type[E]: + def type(self) -> type[E]: """The exception class.""" - assert ( - self._excinfo is not None - ), ".type can only be used after the context manager exits" + assert self._excinfo is not None, ( + ".type can only be used after the context manager exits" + ) return self._excinfo[0] @property def value(self) -> E: """The exception value.""" - assert ( - self._excinfo is not None - ), ".value can only be used after the context manager exits" + assert self._excinfo is not None, ( + ".value can only be used after the context manager exits" + ) return self._excinfo[1] @property def tb(self) -> TracebackType: """The exception raw traceback.""" - assert ( - self._excinfo is not None - ), ".tb can only be used after the context manager exits" + assert self._excinfo is not None, ( + ".tb can only be used after the context manager exits" + ) return self._excinfo[2] @property def typename(self) -> str: """The type name of the exception.""" - assert ( - self._excinfo is not None - ), ".typename can only be used after the context manager exits" + assert self._excinfo is not None, ( + ".typename can only be used after the context manager exits" + ) return self.type.__name__ @property @@ -590,9 +639,7 @@ def traceback(self, value: Traceback) -> None: def __repr__(self) -> str: if self._excinfo is None: return "" - return "<{} {} tblen={}>".format( - self.__class__.__name__, saferepr(self._excinfo[1]), len(self.traceback) - ) + return f"<{self.__class__.__name__} {saferepr(self._excinfo[1])} tblen={len(self.traceback)}>" def exconly(self, tryshort: bool = False) -> str: """Return the exception as a string. @@ -602,6 +649,23 @@ def exconly(self, tryshort: bool = False) -> str: representation is returned (so 'AssertionError: ' is removed from the beginning). """ + + def _get_single_subexc( + eg: BaseExceptionGroup[BaseException], + ) -> BaseException | None: + if len(eg.exceptions) != 1: + return None + if isinstance(e := eg.exceptions[0], BaseExceptionGroup): + return _get_single_subexc(e) + return e + + if ( + tryshort + and isinstance(self.value, BaseExceptionGroup) + and (subexc := _get_single_subexc(self.value)) is not None + ): + return f"{subexc!r} [single exception in {type(self.value).__name__}]" + lines = format_exception_only(self.type, self.value) text = "".join(lines) text = text.rstrip() @@ -610,16 +674,14 @@ def exconly(self, tryshort: bool = False) -> str: text = text[len(self._striptext) :] return text - def errisinstance( - self, exc: Union[Type[BaseException], Tuple[Type[BaseException], ...]] - ) -> bool: + def errisinstance(self, exc: EXCEPTION_OR_MORE) -> bool: """Return True if the exception is an instance of exc. Consider using ``isinstance(excinfo.value, exc)`` instead. """ return isinstance(self.value, exc) - def _getreprcrash(self) -> Optional["ReprFileLocation"]: + def _getreprcrash(self) -> ReprFileLocation | None: # Find last non-hidden traceback entry that led to the exception of the # traceback, or None if all hidden. for i in range(-1, -len(self.traceback) - 1, -1): @@ -633,15 +695,14 @@ def _getreprcrash(self) -> Optional["ReprFileLocation"]: def getrepr( self, showlocals: bool = False, - style: "_TracebackStyle" = "long", + style: TracebackStyle = "long", abspath: bool = False, - tbfilter: Union[ - bool, Callable[["ExceptionInfo[BaseException]"], Traceback] - ] = True, + tbfilter: bool | Callable[[ExceptionInfo[BaseException]], Traceback] = True, funcargs: bool = False, truncate_locals: bool = True, + truncate_args: bool = True, chain: bool = True, - ) -> Union["ReprExceptionInfo", "ExceptionChainRepr"]: + ) -> ReprExceptionInfo | ExceptionChainRepr: """Return str()able representation of this exception info. :param bool showlocals: @@ -670,6 +731,9 @@ def getrepr( :param bool truncate_locals: With ``showlocals==True``, make sure locals can be safely represented as strings. + :param bool truncate_args: + With ``showargs==True``, make sure args can be safely represented as strings. + :param bool chain: If chained exceptions in Python 3 should be shown. @@ -680,7 +744,7 @@ def getrepr( if style == "native": return ReprExceptionInfo( reprtraceback=ReprTracebackNative( - traceback.format_exception( + format_exception( self.type, self.value, self.traceback[0]._rawentry if self.traceback else None, @@ -696,18 +760,19 @@ def getrepr( tbfilter=tbfilter, funcargs=funcargs, truncate_locals=truncate_locals, + truncate_args=truncate_args, chain=chain, ) return fmt.repr_excinfo(self) - def match(self, regexp: Union[str, Pattern[str]]) -> "Literal[True]": + def match(self, regexp: str | re.Pattern[str]) -> Literal[True]: """Check whether the regular expression `regexp` matches the string representation of the exception using :func:`python:re.search`. If it matches `True` is returned, otherwise an `AssertionError` is raised. """ __tracebackhide__ = True - value = str(self.value) + value = stringify_exception(self.value) msg = f"Regex pattern did not match.\n Regex: {regexp!r}\n Input: {value!r}" if regexp == value: msg += "\n Did you mean to `re.escape()` the regex?" @@ -715,6 +780,89 @@ def match(self, regexp: Union[str, Pattern[str]]) -> "Literal[True]": # Return True to allow for "assert excinfo.match()". return True + def _group_contains( + self, + exc_group: BaseExceptionGroup[BaseException], + expected_exception: EXCEPTION_OR_MORE, + match: str | re.Pattern[str] | None, + target_depth: int | None = None, + current_depth: int = 1, + ) -> bool: + """Return `True` if a `BaseExceptionGroup` contains a matching exception.""" + if (target_depth is not None) and (current_depth > target_depth): + # already descended past the target depth + return False + for exc in exc_group.exceptions: + if isinstance(exc, BaseExceptionGroup): + if self._group_contains( + exc, expected_exception, match, target_depth, current_depth + 1 + ): + return True + if (target_depth is not None) and (current_depth != target_depth): + # not at the target depth, no match + continue + if not isinstance(exc, expected_exception): + continue + if match is not None: + value = stringify_exception(exc) + if not re.search(match, value): + continue + return True + return False + + def group_contains( + self, + expected_exception: EXCEPTION_OR_MORE, + *, + match: str | re.Pattern[str] | None = None, + depth: int | None = None, + ) -> bool: + """Check whether a captured exception group contains a matching exception. + + :param Type[BaseException] | Tuple[Type[BaseException]] expected_exception: + The expected exception type, or a tuple if one of multiple possible + exception types are expected. + + :param str | re.Pattern[str] | None match: + If specified, a string containing a regular expression, + or a regular expression object, that is tested against the string + representation of the exception and its `PEP-678 ` `__notes__` + using :func:`re.search`. + + To match a literal string that may contain :ref:`special characters + `, the pattern can first be escaped with :func:`re.escape`. + + :param Optional[int] depth: + If `None`, will search for a matching exception at any nesting depth. + If >= 1, will only match an exception if it's at the specified depth (depth = 1 being + the exceptions contained within the topmost exception group). + + .. versionadded:: 8.0 + + .. warning:: + This helper makes it easy to check for the presence of specific exceptions, + but it is very bad for checking that the group does *not* contain + *any other exceptions*. + You should instead consider using :class:`pytest.RaisesGroup` + + """ + msg = "Captured exception is not an instance of `BaseExceptionGroup`" + assert isinstance(self.value, BaseExceptionGroup), msg + msg = "`depth` must be >= 1 if specified" + assert (depth is None) or (depth >= 1), msg + return self._group_contains(self.value, expected_exception, match, depth) + + +if TYPE_CHECKING: + from typing_extensions import TypeAlias + + # Type alias for the `tbfilter` setting: + # bool: If True, it should be filtered using Traceback.filter() + # callable: A callable that takes an ExceptionInfo and returns the filtered traceback. + TracebackFilter: TypeAlias = Union[ + bool, Callable[[ExceptionInfo[BaseException]], Traceback] + ] + @dataclasses.dataclass class FormattedExcinfo: @@ -725,17 +873,18 @@ class FormattedExcinfo: fail_marker: ClassVar = "E" showlocals: bool = False - style: "_TracebackStyle" = "long" + style: TracebackStyle = "long" abspath: bool = True - tbfilter: Union[bool, Callable[[ExceptionInfo[BaseException]], Traceback]] = True + tbfilter: TracebackFilter = True funcargs: bool = False truncate_locals: bool = True + truncate_args: bool = True chain: bool = True - astcache: Dict[Union[str, Path], ast.AST] = dataclasses.field( + astcache: dict[str | Path, ast.AST] = dataclasses.field( default_factory=dict, init=False, repr=False ) - def _getindent(self, source: "Source") -> int: + def _getindent(self, source: Source) -> int: # Figure out indent for the given source. try: s = str(source.getstatement(len(source) - 1)) @@ -750,27 +899,34 @@ def _getindent(self, source: "Source") -> int: return 0 return 4 + (len(s) - len(s.lstrip())) - def _getentrysource(self, entry: TracebackEntry) -> Optional["Source"]: + def _getentrysource(self, entry: TracebackEntry) -> Source | None: source = entry.getsource(self.astcache) if source is not None: source = source.deindent() return source - def repr_args(self, entry: TracebackEntry) -> Optional["ReprFuncArgs"]: + def repr_args(self, entry: TracebackEntry) -> ReprFuncArgs | None: if self.funcargs: args = [] for argname, argvalue in entry.frame.getargs(var=True): - args.append((argname, saferepr(argvalue))) + if self.truncate_args: + str_repr = saferepr(argvalue) + else: + str_repr = saferepr(argvalue, maxsize=None) + args.append((argname, str_repr)) return ReprFuncArgs(args) return None def get_source( self, - source: Optional["Source"], + source: Source | None, line_index: int = -1, - excinfo: Optional[ExceptionInfo[BaseException]] = None, + excinfo: ExceptionInfo[BaseException] | None = None, short: bool = False, - ) -> List[str]: + end_line_index: int | None = None, + colno: int | None = None, + end_colno: int | None = None, + ) -> list[str]: """Return formatted and marked up source lines.""" lines = [] if source is not None and line_index < 0: @@ -783,10 +939,30 @@ def get_source( space_prefix = " " if short: lines.append(space_prefix + source.lines[line_index].strip()) + lines.extend( + self.get_highlight_arrows_for_line( + raw_line=source.raw_lines[line_index], + line=source.lines[line_index].strip(), + lineno=line_index, + end_lineno=end_line_index, + colno=colno, + end_colno=end_colno, + ) + ) else: for line in source.lines[:line_index]: lines.append(space_prefix + line) lines.append(self.flow_marker + " " + source.lines[line_index]) + lines.extend( + self.get_highlight_arrows_for_line( + raw_line=source.raw_lines[line_index], + line=source.lines[line_index], + lineno=line_index, + end_lineno=end_line_index, + colno=colno, + end_colno=end_colno, + ) + ) for line in source.lines[line_index + 1 :]: lines.append(space_prefix + line) if excinfo is not None: @@ -794,12 +970,49 @@ def get_source( lines.extend(self.get_exconly(excinfo, indent=indent, markall=True)) return lines + def get_highlight_arrows_for_line( + self, + line: str, + raw_line: str, + lineno: int | None, + end_lineno: int | None, + colno: int | None, + end_colno: int | None, + ) -> list[str]: + """Return characters highlighting a source line. + + Example with colno and end_colno pointing to the bar expression: + "foo() + bar()" + returns " ^^^^^" + """ + if lineno != end_lineno: + # Don't handle expressions that span multiple lines. + return [] + if colno is None or end_colno is None: + # Can't do anything without column information. + return [] + + num_stripped_chars = len(raw_line) - len(line) + + start_char_offset = _byte_offset_to_character_offset(raw_line, colno) + end_char_offset = _byte_offset_to_character_offset(raw_line, end_colno) + num_carets = end_char_offset - start_char_offset + # If the highlight would span the whole line, it is redundant, don't + # show it. + if num_carets >= len(line.strip()): + return [] + + highlights = " " + highlights += " " * (start_char_offset - num_stripped_chars + 1) + highlights += "^" * num_carets + return [highlights] + def get_exconly( self, excinfo: ExceptionInfo[BaseException], indent: int = 4, markall: bool = False, - ) -> List[str]: + ) -> list[str]: lines = [] indentstr = " " * indent # Get the real exception information out. @@ -811,7 +1024,7 @@ def get_exconly( failindent = indentstr return lines - def repr_locals(self, locals: Mapping[str, object]) -> Optional["ReprLocals"]: + def repr_locals(self, locals: Mapping[str, object]) -> ReprLocals | None: if self.showlocals: lines = [] keys = [loc for loc in locals if loc[0] != "@"] @@ -839,10 +1052,10 @@ def repr_locals(self, locals: Mapping[str, object]) -> Optional["ReprLocals"]: def repr_traceback_entry( self, - entry: Optional[TracebackEntry], - excinfo: Optional[ExceptionInfo[BaseException]] = None, - ) -> "ReprEntry": - lines: List[str] = [] + entry: TracebackEntry | None, + excinfo: ExceptionInfo[BaseException] | None = None, + ) -> ReprEntry: + lines: list[str] = [] style = ( entry._repr_style if entry is not None and entry._repr_style is not None @@ -853,16 +1066,28 @@ def repr_traceback_entry( if source is None: source = Source("???") line_index = 0 + end_line_index, colno, end_colno = None, None, None else: - line_index = entry.lineno - entry.getfirstlinesource() + line_index = entry.relline + end_line_index = entry.end_lineno_relative + colno = entry.colno + end_colno = entry.end_colno short = style == "short" reprargs = self.repr_args(entry) if not short else None - s = self.get_source(source, line_index, excinfo, short=short) + s = self.get_source( + source=source, + line_index=line_index, + excinfo=excinfo, + short=short, + end_line_index=end_line_index, + colno=colno, + end_colno=end_colno, + ) lines.extend(s) if short: - message = "in %s" % (entry.name) + message = f"in {entry.name}" else: - message = excinfo and excinfo.typename or "" + message = (excinfo and excinfo.typename) or "" entry_path = entry.path path = self._makepath(entry_path) reprfileloc = ReprFileLocation(path, entry.lineno + 1, message) @@ -877,7 +1102,7 @@ def repr_traceback_entry( lines.extend(self.get_exconly(excinfo, indent=4)) return ReprEntry(lines, None, None, None, style) - def _makepath(self, path: Union[Path, str]) -> str: + def _makepath(self, path: Path | str) -> str: if not self.abspath and isinstance(path, Path): try: np = bestrelpath(Path.cwd(), path) @@ -887,12 +1112,8 @@ def _makepath(self, path: Union[Path, str]) -> str: return np return str(path) - def repr_traceback(self, excinfo: ExceptionInfo[BaseException]) -> "ReprTraceback": - traceback = excinfo.traceback - if callable(self.tbfilter): - traceback = self.tbfilter(excinfo) - elif self.tbfilter: - traceback = traceback.filter(excinfo) + def repr_traceback(self, excinfo: ExceptionInfo[BaseException]) -> ReprTraceback: + traceback = filter_excinfo_traceback(self.tbfilter, excinfo) if isinstance(excinfo.value, RecursionError): traceback, extraline = self._truncate_recursive_traceback(traceback) @@ -918,7 +1139,7 @@ def repr_traceback(self, excinfo: ExceptionInfo[BaseException]) -> "ReprTracebac def _truncate_recursive_traceback( self, traceback: Traceback - ) -> Tuple[Traceback, Optional[str]]: + ) -> tuple[Traceback, str | None]: """Truncate the given recursive traceback trying to find the starting point of the recursion. @@ -935,16 +1156,11 @@ def _truncate_recursive_traceback( recursionindex = traceback.recursionindex() except Exception as e: max_frames = 10 - extraline: Optional[str] = ( + extraline: str | None = ( "!!! Recursion error detected, but an error occurred locating the origin of recursion.\n" " The following exception happened when comparing locals in the stack frame:\n" - " {exc_type}: {exc_msg}\n" - " Displaying first and last {max_frames} stack frames out of {total}." - ).format( - exc_type=type(e).__name__, - exc_msg=str(e), - max_frames=max_frames, - total=len(traceback), + f" {type(e).__name__}: {e!s}\n" + f" Displaying first and last {max_frames} stack frames out of {len(traceback)}." ) # Type ignored because adding two instances of a List subtype # currently incorrectly has type List instead of the subtype. @@ -958,16 +1174,12 @@ def _truncate_recursive_traceback( return traceback, extraline - def repr_excinfo( - self, excinfo: ExceptionInfo[BaseException] - ) -> "ExceptionChainRepr": - repr_chain: List[ - Tuple[ReprTraceback, Optional[ReprFileLocation], Optional[str]] - ] = [] - e: Optional[BaseException] = excinfo.value - excinfo_: Optional[ExceptionInfo[BaseException]] = excinfo + def repr_excinfo(self, excinfo: ExceptionInfo[BaseException]) -> ExceptionChainRepr: + repr_chain: list[tuple[ReprTraceback, ReprFileLocation | None, str | None]] = [] + e: BaseException | None = excinfo.value + excinfo_: ExceptionInfo[BaseException] | None = excinfo descr = None - seen: Set[int] = set() + seen: set[int] = set() while e is not None and id(e) not in seen: seen.add(id(e)) @@ -975,14 +1187,15 @@ def repr_excinfo( # Fall back to native traceback as a temporary workaround until # full support for exception groups added to ExceptionInfo. # See https://github.com/pytest-dev/pytest/issues/9159 + reprtraceback: ReprTraceback | ReprTracebackNative if isinstance(e, BaseExceptionGroup): - reprtraceback: Union[ - ReprTracebackNative, ReprTraceback - ] = ReprTracebackNative( - traceback.format_exception( - type(excinfo_.value), - excinfo_.value, - excinfo_.traceback[0]._rawentry, + # don't filter any sub-exceptions since they shouldn't have any internal frames + traceback = filter_excinfo_traceback(self.tbfilter, excinfo) + reprtraceback = ReprTracebackNative( + format_exception( + type(excinfo.value), + excinfo.value, + traceback[0]._rawentry, ) ) else: @@ -991,9 +1204,7 @@ def repr_excinfo( else: # Fallback to native repr if the exception doesn't have a traceback: # ExceptionInfo objects require a full traceback to work. - reprtraceback = ReprTracebackNative( - traceback.format_exception(type(e), e, None) - ) + reprtraceback = ReprTracebackNative(format_exception(type(e), e, None)) reprcrash = None repr_chain += [(reprtraceback, reprcrash, descr)] @@ -1034,9 +1245,9 @@ def toterminal(self, tw: TerminalWriter) -> None: @dataclasses.dataclass(eq=False) class ExceptionRepr(TerminalRepr): # Provided by subclasses. - reprtraceback: "ReprTraceback" - reprcrash: Optional["ReprFileLocation"] - sections: List[Tuple[str, str, str]] = dataclasses.field( + reprtraceback: ReprTraceback + reprcrash: ReprFileLocation | None + sections: list[tuple[str, str, str]] = dataclasses.field( init=False, default_factory=list ) @@ -1051,13 +1262,11 @@ def toterminal(self, tw: TerminalWriter) -> None: @dataclasses.dataclass(eq=False) class ExceptionChainRepr(ExceptionRepr): - chain: Sequence[Tuple["ReprTraceback", Optional["ReprFileLocation"], Optional[str]]] + chain: Sequence[tuple[ReprTraceback, ReprFileLocation | None, str | None]] def __init__( self, - chain: Sequence[ - Tuple["ReprTraceback", Optional["ReprFileLocation"], Optional[str]] - ], + chain: Sequence[tuple[ReprTraceback, ReprFileLocation | None, str | None]], ) -> None: # reprcrash and reprtraceback of the outermost (the newest) exception # in the chain. @@ -1078,8 +1287,8 @@ def toterminal(self, tw: TerminalWriter) -> None: @dataclasses.dataclass(eq=False) class ReprExceptionInfo(ExceptionRepr): - reprtraceback: "ReprTraceback" - reprcrash: Optional["ReprFileLocation"] + reprtraceback: ReprTraceback + reprcrash: ReprFileLocation | None def toterminal(self, tw: TerminalWriter) -> None: self.reprtraceback.toterminal(tw) @@ -1088,9 +1297,9 @@ def toterminal(self, tw: TerminalWriter) -> None: @dataclasses.dataclass(eq=False) class ReprTraceback(TerminalRepr): - reprentries: Sequence[Union["ReprEntry", "ReprEntryNative"]] - extraline: Optional[str] - style: "_TracebackStyle" + reprentries: Sequence[ReprEntry | ReprEntryNative] + extraline: str | None + style: TracebackStyle entrysep: ClassVar = "_ " @@ -1102,10 +1311,8 @@ def toterminal(self, tw: TerminalWriter) -> None: entry.toterminal(tw) if i < len(self.reprentries) - 1: next_entry = self.reprentries[i + 1] - if ( - entry.style == "long" - or entry.style == "short" - and next_entry.style == "long" + if entry.style == "long" or ( + entry.style == "short" and next_entry.style == "long" ): tw.sep(self.entrysep) @@ -1124,7 +1331,7 @@ def __init__(self, tblines: Sequence[str]) -> None: class ReprEntryNative(TerminalRepr): lines: Sequence[str] - style: ClassVar["_TracebackStyle"] = "native" + style: ClassVar[TracebackStyle] = "native" def toterminal(self, tw: TerminalWriter) -> None: tw.write("".join(self.lines)) @@ -1133,10 +1340,10 @@ def toterminal(self, tw: TerminalWriter) -> None: @dataclasses.dataclass(eq=False) class ReprEntry(TerminalRepr): lines: Sequence[str] - reprfuncargs: Optional["ReprFuncArgs"] - reprlocals: Optional["ReprLocals"] - reprfileloc: Optional["ReprFileLocation"] - style: "_TracebackStyle" + reprfuncargs: ReprFuncArgs | None + reprlocals: ReprLocals | None + reprfileloc: ReprFileLocation | None + style: TracebackStyle def _write_entry_lines(self, tw: TerminalWriter) -> None: """Write the source code portions of a list of traceback entries with syntax highlighting. @@ -1151,18 +1358,26 @@ def _write_entry_lines(self, tw: TerminalWriter) -> None: the "E" prefix) using syntax highlighting, taking care to not highlighting the ">" character, as doing so might break line continuations. """ - if not self.lines: return + if self.style == "value": + # Using tw.write instead of tw.line for testing purposes due to TWMock implementation; + # lines written with TWMock.line and TWMock._write_source cannot be distinguished + # from each other, whereas lines written with TWMock.write are marked with TWMock.WRITE + for line in self.lines: + tw.write(line) + tw.write("\n") + return + # separate indents and source lines that are not failures: we want to # highlight the code but not the indentation, which may contain markers # such as "> assert 0" fail_marker = f"{FormattedExcinfo.fail_marker} " indent_size = len(fail_marker) - indents: List[str] = [] - source_lines: List[str] = [] - failure_lines: List[str] = [] + indents: list[str] = [] + source_lines: list[str] = [] + failure_lines: list[str] = [] for index, line in enumerate(self.lines): is_failure_line = line.startswith(fail_marker) if is_failure_line: @@ -1170,11 +1385,8 @@ def _write_entry_lines(self, tw: TerminalWriter) -> None: failure_lines.extend(self.lines[index:]) break else: - if self.style == "value": - source_lines.append(line) - else: - indents.append(line[:indent_size]) - source_lines.append(line[indent_size:]) + indents.append(line[:indent_size]) + source_lines.append(line[indent_size:]) tw._write_source(source_lines, indents) @@ -1241,7 +1453,7 @@ def toterminal(self, tw: TerminalWriter, indent="") -> None: @dataclasses.dataclass(eq=False) class ReprFuncArgs(TerminalRepr): - args: Sequence[Tuple[str, object]] + args: Sequence[tuple[str, object]] def toterminal(self, tw: TerminalWriter) -> None: if self.args: @@ -1262,7 +1474,7 @@ def toterminal(self, tw: TerminalWriter) -> None: tw.line("") -def getfslineno(obj: object) -> Tuple[Union[str, Path], int]: +def getfslineno(obj: object) -> tuple[str | Path, int]: """Return source location (path, lineno) for the given object. If the source cannot be determined return ("", -1). @@ -1274,7 +1486,7 @@ def getfslineno(obj: object) -> Tuple[Union[str, Path], int]: # in 6ec13a2b9. It ("place_as") appears to be something very custom. obj = get_real_func(obj) if hasattr(obj, "place_as"): - obj = obj.place_as # type: ignore[attr-defined] + obj = obj.place_as try: code = Code.from_function(obj) @@ -1284,7 +1496,7 @@ def getfslineno(obj: object) -> Tuple[Union[str, Path], int]: except TypeError: return "", -1 - fspath = fn and absolutepath(fn) or "" + fspath = (fn and absolutepath(fn)) or "" lineno = -1 if fspath: try: @@ -1296,6 +1508,12 @@ def getfslineno(obj: object) -> Tuple[Union[str, Path], int]: return code.path, code.firstlineno +def _byte_offset_to_character_offset(str, offset): + """Converts a byte based offset in a string to a code-point.""" + as_utf8 = str.encode("utf-8") + return len(as_utf8[:offset].decode("utf-8", errors="replace")) + + # Relative paths that we use to filter traceback entries from appearing to the user; # see filter_traceback. # note: if we need to add more paths than what we have now we should probably use a list @@ -1335,3 +1553,15 @@ def filter_traceback(entry: TracebackEntry) -> bool: return False return True + + +def filter_excinfo_traceback( + tbfilter: TracebackFilter, excinfo: ExceptionInfo[BaseException] +) -> Traceback: + """Filter the exception traceback in ``excinfo`` according to ``tbfilter``.""" + if callable(tbfilter): + return tbfilter(excinfo) + elif tbfilter: + return excinfo.traceback.filter(excinfo) + else: + return excinfo.traceback diff --git a/src/_pytest/_code/source.py b/src/_pytest/_code/source.py index 208cfb80037..a8f7201a40f 100644 --- a/src/_pytest/_code/source.py +++ b/src/_pytest/_code/source.py @@ -1,17 +1,16 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + import ast +from bisect import bisect_right +from collections.abc import Iterable +from collections.abc import Iterator import inspect import textwrap import tokenize import types -import warnings -from bisect import bisect_right -from typing import Iterable -from typing import Iterator -from typing import List -from typing import Optional from typing import overload -from typing import Tuple -from typing import Union +import warnings class Source: @@ -22,13 +21,17 @@ class Source: def __init__(self, obj: object = None) -> None: if not obj: - self.lines: List[str] = [] + self.lines: list[str] = [] + self.raw_lines: list[str] = [] elif isinstance(obj, Source): self.lines = obj.lines + self.raw_lines = obj.raw_lines elif isinstance(obj, (tuple, list)): self.lines = deindent(x.rstrip("\n") for x in obj) + self.raw_lines = list(x.rstrip("\n") for x in obj) elif isinstance(obj, str): self.lines = deindent(obj.split("\n")) + self.raw_lines = obj.split("\n") else: try: rawcode = getrawcode(obj) @@ -36,6 +39,7 @@ def __init__(self, obj: object = None) -> None: except TypeError: src = inspect.getsource(obj) # type: ignore[arg-type] self.lines = deindent(src.split("\n")) + self.raw_lines = src.split("\n") def __eq__(self, other: object) -> bool: if not isinstance(other, Source): @@ -46,14 +50,12 @@ def __eq__(self, other: object) -> bool: __hash__ = None # type: ignore @overload - def __getitem__(self, key: int) -> str: - ... + def __getitem__(self, key: int) -> str: ... @overload - def __getitem__(self, key: slice) -> "Source": - ... + def __getitem__(self, key: slice) -> Source: ... - def __getitem__(self, key: Union[int, slice]) -> Union[str, "Source"]: + def __getitem__(self, key: int | slice) -> str | Source: if isinstance(key, int): return self.lines[key] else: @@ -61,6 +63,7 @@ def __getitem__(self, key: Union[int, slice]) -> Union[str, "Source"]: raise IndexError("cannot slice a Source with a step") newsource = Source() newsource.lines = self.lines[key.start : key.stop] + newsource.raw_lines = self.raw_lines[key.start : key.stop] return newsource def __iter__(self) -> Iterator[str]: @@ -69,7 +72,7 @@ def __iter__(self) -> Iterator[str]: def __len__(self) -> int: return len(self.lines) - def strip(self) -> "Source": + def strip(self) -> Source: """Return new Source object with trailing and leading blank lines removed.""" start, end = 0, len(self) while start < end and not self.lines[start].strip(): @@ -77,23 +80,25 @@ def strip(self) -> "Source": while end > start and not self.lines[end - 1].strip(): end -= 1 source = Source() + source.raw_lines = self.raw_lines source.lines[:] = self.lines[start:end] return source - def indent(self, indent: str = " " * 4) -> "Source": + def indent(self, indent: str = " " * 4) -> Source: """Return a copy of the source object with all lines indented by the given indent-string.""" newsource = Source() + newsource.raw_lines = self.raw_lines newsource.lines = [(indent + line) for line in self.lines] return newsource - def getstatement(self, lineno: int) -> "Source": + def getstatement(self, lineno: int) -> Source: """Return Source statement which contains the given linenumber (counted from 0).""" start, end = self.getstatementrange(lineno) return self[start:end] - def getstatementrange(self, lineno: int) -> Tuple[int, int]: + def getstatementrange(self, lineno: int) -> tuple[int, int]: """Return (start, end) tuple which spans the minimal statement region which containing the given lineno.""" if not (0 <= lineno < len(self)): @@ -101,10 +106,11 @@ def getstatementrange(self, lineno: int) -> Tuple[int, int]: ast, start, end = getstatementrange_ast(lineno, self) return start, end - def deindent(self) -> "Source": + def deindent(self) -> Source: """Return a new Source object deindented.""" newsource = Source() newsource.lines[:] = deindent(self.lines) + newsource.raw_lines = self.raw_lines return newsource def __str__(self) -> str: @@ -116,13 +122,14 @@ def __str__(self) -> str: # -def findsource(obj) -> Tuple[Optional[Source], int]: +def findsource(obj) -> tuple[Source | None, int]: try: sourcelines, lineno = inspect.findsource(obj) except Exception: return None, -1 source = Source() source.lines = [line.rstrip() for line in sourcelines] + source.raw_lines = sourcelines return source, lineno @@ -139,24 +146,23 @@ def getrawcode(obj: object, trycall: bool = True) -> types.CodeType: raise TypeError(f"could not get code object for {obj!r}") -def deindent(lines: Iterable[str]) -> List[str]: +def deindent(lines: Iterable[str]) -> list[str]: return textwrap.dedent("\n".join(lines)).splitlines() -def get_statement_startend2(lineno: int, node: ast.AST) -> Tuple[int, Optional[int]]: +def get_statement_startend2(lineno: int, node: ast.AST) -> tuple[int, int | None]: # Flatten all statements and except handlers into one lineno-list. # AST's line numbers start indexing at 1. - values: List[int] = [] + values: list[int] = [] for x in ast.walk(node): if isinstance(x, (ast.stmt, ast.ExceptHandler)): - # Before Python 3.8, the lineno of a decorated class or function pointed at the decorator. - # Since Python 3.8, the lineno points to the class/def, so need to include the decorators. + # The lineno points to the class/def, so need to include the decorators. if isinstance(x, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)): for d in x.decorator_list: values.append(d.lineno - 1) values.append(x.lineno - 1) for name in ("finalbody", "orelse"): - val: Optional[List[ast.stmt]] = getattr(x, name, None) + val: list[ast.stmt] | None = getattr(x, name, None) if val: # Treat the finally/orelse part as its own statement. values.append(val[0].lineno - 1 - 1) @@ -174,8 +180,8 @@ def getstatementrange_ast( lineno: int, source: Source, assertion: bool = False, - astnode: Optional[ast.AST] = None, -) -> Tuple[ast.AST, int, int]: + astnode: ast.AST | None = None, +) -> tuple[ast.AST, int, int]: if astnode is None: content = str(source) # See #4260: @@ -197,7 +203,9 @@ def getstatementrange_ast( # by using the BlockFinder helper used which inspect.getsource() uses itself. block_finder = inspect.BlockFinder() # If we start with an indented line, put blockfinder to "started" mode. - block_finder.started = source.lines[start][0].isspace() + block_finder.started = ( + bool(source.lines[start]) and source.lines[start][0].isspace() + ) it = ((x + "\n") for x in source.lines[start:end]) try: for tok in tokenize.generate_tokens(lambda: next(it)): diff --git a/src/_pytest/_io/__init__.py b/src/_pytest/_io/__init__.py index db001e918cb..b0155b18b60 100644 --- a/src/_pytest/_io/__init__.py +++ b/src/_pytest/_io/__init__.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from .terminalwriter import get_terminal_width from .terminalwriter import TerminalWriter diff --git a/src/_pytest/_io/pprint.py b/src/_pytest/_io/pprint.py new file mode 100644 index 00000000000..28f06909206 --- /dev/null +++ b/src/_pytest/_io/pprint.py @@ -0,0 +1,673 @@ +# mypy: allow-untyped-defs +# This module was imported from the cpython standard library +# (https://github.com/python/cpython/) at commit +# c5140945c723ae6c4b7ee81ff720ac8ea4b52cfd (python3.12). +# +# +# Original Author: Fred L. Drake, Jr. +# fdrake@acm.org +# +# This is a simple little module I wrote to make life easier. I didn't +# see anything quite like it in the library, though I may have overlooked +# something. I wrote this when I was trying to read some heavily nested +# tuples with fairly non-descriptive content. This is modeled very much +# after Lisp/Scheme - style pretty-printing of lists. If you find it +# useful, thank small children who sleep at night. +from __future__ import annotations + +import collections as _collections +from collections.abc import Callable +from collections.abc import Iterator +import dataclasses as _dataclasses +from io import StringIO as _StringIO +import re +import types as _types +from typing import Any +from typing import IO + + +class _safe_key: + """Helper function for key functions when sorting unorderable objects. + + The wrapped-object will fallback to a Py2.x style comparison for + unorderable types (sorting first comparing the type name and then by + the obj ids). Does not work recursively, so dict.items() must have + _safe_key applied to both the key and the value. + + """ + + __slots__ = ["obj"] + + def __init__(self, obj): + self.obj = obj + + def __lt__(self, other): + try: + return self.obj < other.obj + except TypeError: + return (str(type(self.obj)), id(self.obj)) < ( + str(type(other.obj)), + id(other.obj), + ) + + +def _safe_tuple(t): + """Helper function for comparing 2-tuples""" + return _safe_key(t[0]), _safe_key(t[1]) + + +class PrettyPrinter: + def __init__( + self, + indent: int = 4, + width: int = 80, + depth: int | None = None, + ) -> None: + """Handle pretty printing operations onto a stream using a set of + configured parameters. + + indent + Number of spaces to indent for each level of nesting. + + width + Attempted maximum number of columns in the output. + + depth + The maximum depth to print out nested structures. + + """ + if indent < 0: + raise ValueError("indent must be >= 0") + if depth is not None and depth <= 0: + raise ValueError("depth must be > 0") + if not width: + raise ValueError("width must be != 0") + self._depth = depth + self._indent_per_level = indent + self._width = width + + def pformat(self, object: Any) -> str: + sio = _StringIO() + self._format(object, sio, 0, 0, set(), 0) + return sio.getvalue() + + def _format( + self, + object: Any, + stream: IO[str], + indent: int, + allowance: int, + context: set[int], + level: int, + ) -> None: + objid = id(object) + if objid in context: + stream.write(_recursion(object)) + return + + p = self._dispatch.get(type(object).__repr__, None) + if p is not None: + context.add(objid) + p(self, object, stream, indent, allowance, context, level + 1) + context.remove(objid) + elif ( + _dataclasses.is_dataclass(object) + and not isinstance(object, type) + and object.__dataclass_params__.repr # type:ignore[attr-defined] + and + # Check dataclass has generated repr method. + hasattr(object.__repr__, "__wrapped__") + and "__create_fn__" in object.__repr__.__wrapped__.__qualname__ + ): + context.add(objid) + self._pprint_dataclass( + object, stream, indent, allowance, context, level + 1 + ) + context.remove(objid) + else: + stream.write(self._repr(object, context, level)) + + def _pprint_dataclass( + self, + object: Any, + stream: IO[str], + indent: int, + allowance: int, + context: set[int], + level: int, + ) -> None: + cls_name = object.__class__.__name__ + items = [ + (f.name, getattr(object, f.name)) + for f in _dataclasses.fields(object) + if f.repr + ] + stream.write(cls_name + "(") + self._format_namespace_items(items, stream, indent, allowance, context, level) + stream.write(")") + + _dispatch: dict[ + Callable[..., str], + Callable[[PrettyPrinter, Any, IO[str], int, int, set[int], int], None], + ] = {} + + def _pprint_dict( + self, + object: Any, + stream: IO[str], + indent: int, + allowance: int, + context: set[int], + level: int, + ) -> None: + write = stream.write + write("{") + items = sorted(object.items(), key=_safe_tuple) + self._format_dict_items(items, stream, indent, allowance, context, level) + write("}") + + _dispatch[dict.__repr__] = _pprint_dict + + def _pprint_ordered_dict( + self, + object: Any, + stream: IO[str], + indent: int, + allowance: int, + context: set[int], + level: int, + ) -> None: + if not len(object): + stream.write(repr(object)) + return + cls = object.__class__ + stream.write(cls.__name__ + "(") + self._pprint_dict(object, stream, indent, allowance, context, level) + stream.write(")") + + _dispatch[_collections.OrderedDict.__repr__] = _pprint_ordered_dict + + def _pprint_list( + self, + object: Any, + stream: IO[str], + indent: int, + allowance: int, + context: set[int], + level: int, + ) -> None: + stream.write("[") + self._format_items(object, stream, indent, allowance, context, level) + stream.write("]") + + _dispatch[list.__repr__] = _pprint_list + + def _pprint_tuple( + self, + object: Any, + stream: IO[str], + indent: int, + allowance: int, + context: set[int], + level: int, + ) -> None: + stream.write("(") + self._format_items(object, stream, indent, allowance, context, level) + stream.write(")") + + _dispatch[tuple.__repr__] = _pprint_tuple + + def _pprint_set( + self, + object: Any, + stream: IO[str], + indent: int, + allowance: int, + context: set[int], + level: int, + ) -> None: + if not len(object): + stream.write(repr(object)) + return + typ = object.__class__ + if typ is set: + stream.write("{") + endchar = "}" + else: + stream.write(typ.__name__ + "({") + endchar = "})" + object = sorted(object, key=_safe_key) + self._format_items(object, stream, indent, allowance, context, level) + stream.write(endchar) + + _dispatch[set.__repr__] = _pprint_set + _dispatch[frozenset.__repr__] = _pprint_set + + def _pprint_str( + self, + object: Any, + stream: IO[str], + indent: int, + allowance: int, + context: set[int], + level: int, + ) -> None: + write = stream.write + if not len(object): + write(repr(object)) + return + chunks = [] + lines = object.splitlines(True) + if level == 1: + indent += 1 + allowance += 1 + max_width1 = max_width = self._width - indent + for i, line in enumerate(lines): + rep = repr(line) + if i == len(lines) - 1: + max_width1 -= allowance + if len(rep) <= max_width1: + chunks.append(rep) + else: + # A list of alternating (non-space, space) strings + parts = re.findall(r"\S*\s*", line) + assert parts + assert not parts[-1] + parts.pop() # drop empty last part + max_width2 = max_width + current = "" + for j, part in enumerate(parts): + candidate = current + part + if j == len(parts) - 1 and i == len(lines) - 1: + max_width2 -= allowance + if len(repr(candidate)) > max_width2: + if current: + chunks.append(repr(current)) + current = part + else: + current = candidate + if current: + chunks.append(repr(current)) + if len(chunks) == 1: + write(rep) + return + if level == 1: + write("(") + for i, rep in enumerate(chunks): + if i > 0: + write("\n" + " " * indent) + write(rep) + if level == 1: + write(")") + + _dispatch[str.__repr__] = _pprint_str + + def _pprint_bytes( + self, + object: Any, + stream: IO[str], + indent: int, + allowance: int, + context: set[int], + level: int, + ) -> None: + write = stream.write + if len(object) <= 4: + write(repr(object)) + return + parens = level == 1 + if parens: + indent += 1 + allowance += 1 + write("(") + delim = "" + for rep in _wrap_bytes_repr(object, self._width - indent, allowance): + write(delim) + write(rep) + if not delim: + delim = "\n" + " " * indent + if parens: + write(")") + + _dispatch[bytes.__repr__] = _pprint_bytes + + def _pprint_bytearray( + self, + object: Any, + stream: IO[str], + indent: int, + allowance: int, + context: set[int], + level: int, + ) -> None: + write = stream.write + write("bytearray(") + self._pprint_bytes( + bytes(object), stream, indent + 10, allowance + 1, context, level + 1 + ) + write(")") + + _dispatch[bytearray.__repr__] = _pprint_bytearray + + def _pprint_mappingproxy( + self, + object: Any, + stream: IO[str], + indent: int, + allowance: int, + context: set[int], + level: int, + ) -> None: + stream.write("mappingproxy(") + self._format(object.copy(), stream, indent, allowance, context, level) + stream.write(")") + + _dispatch[_types.MappingProxyType.__repr__] = _pprint_mappingproxy + + def _pprint_simplenamespace( + self, + object: Any, + stream: IO[str], + indent: int, + allowance: int, + context: set[int], + level: int, + ) -> None: + if type(object) is _types.SimpleNamespace: + # The SimpleNamespace repr is "namespace" instead of the class + # name, so we do the same here. For subclasses; use the class name. + cls_name = "namespace" + else: + cls_name = object.__class__.__name__ + items = object.__dict__.items() + stream.write(cls_name + "(") + self._format_namespace_items(items, stream, indent, allowance, context, level) + stream.write(")") + + _dispatch[_types.SimpleNamespace.__repr__] = _pprint_simplenamespace + + def _format_dict_items( + self, + items: list[tuple[Any, Any]], + stream: IO[str], + indent: int, + allowance: int, + context: set[int], + level: int, + ) -> None: + if not items: + return + + write = stream.write + item_indent = indent + self._indent_per_level + delimnl = "\n" + " " * item_indent + for key, ent in items: + write(delimnl) + write(self._repr(key, context, level)) + write(": ") + self._format(ent, stream, item_indent, 1, context, level) + write(",") + + write("\n" + " " * indent) + + def _format_namespace_items( + self, + items: list[tuple[Any, Any]], + stream: IO[str], + indent: int, + allowance: int, + context: set[int], + level: int, + ) -> None: + if not items: + return + + write = stream.write + item_indent = indent + self._indent_per_level + delimnl = "\n" + " " * item_indent + for key, ent in items: + write(delimnl) + write(key) + write("=") + if id(ent) in context: + # Special-case representation of recursion to match standard + # recursive dataclass repr. + write("...") + else: + self._format( + ent, + stream, + item_indent + len(key) + 1, + 1, + context, + level, + ) + + write(",") + + write("\n" + " " * indent) + + def _format_items( + self, + items: list[Any], + stream: IO[str], + indent: int, + allowance: int, + context: set[int], + level: int, + ) -> None: + if not items: + return + + write = stream.write + item_indent = indent + self._indent_per_level + delimnl = "\n" + " " * item_indent + + for item in items: + write(delimnl) + self._format(item, stream, item_indent, 1, context, level) + write(",") + + write("\n" + " " * indent) + + def _repr(self, object: Any, context: set[int], level: int) -> str: + return self._safe_repr(object, context.copy(), self._depth, level) + + def _pprint_default_dict( + self, + object: Any, + stream: IO[str], + indent: int, + allowance: int, + context: set[int], + level: int, + ) -> None: + rdf = self._repr(object.default_factory, context, level) + stream.write(f"{object.__class__.__name__}({rdf}, ") + self._pprint_dict(object, stream, indent, allowance, context, level) + stream.write(")") + + _dispatch[_collections.defaultdict.__repr__] = _pprint_default_dict + + def _pprint_counter( + self, + object: Any, + stream: IO[str], + indent: int, + allowance: int, + context: set[int], + level: int, + ) -> None: + stream.write(object.__class__.__name__ + "(") + + if object: + stream.write("{") + items = object.most_common() + self._format_dict_items(items, stream, indent, allowance, context, level) + stream.write("}") + + stream.write(")") + + _dispatch[_collections.Counter.__repr__] = _pprint_counter + + def _pprint_chain_map( + self, + object: Any, + stream: IO[str], + indent: int, + allowance: int, + context: set[int], + level: int, + ) -> None: + if not len(object.maps) or (len(object.maps) == 1 and not len(object.maps[0])): + stream.write(repr(object)) + return + + stream.write(object.__class__.__name__ + "(") + self._format_items(object.maps, stream, indent, allowance, context, level) + stream.write(")") + + _dispatch[_collections.ChainMap.__repr__] = _pprint_chain_map + + def _pprint_deque( + self, + object: Any, + stream: IO[str], + indent: int, + allowance: int, + context: set[int], + level: int, + ) -> None: + stream.write(object.__class__.__name__ + "(") + if object.maxlen is not None: + stream.write(f"maxlen={object.maxlen}, ") + stream.write("[") + + self._format_items(object, stream, indent, allowance + 1, context, level) + stream.write("])") + + _dispatch[_collections.deque.__repr__] = _pprint_deque + + def _pprint_user_dict( + self, + object: Any, + stream: IO[str], + indent: int, + allowance: int, + context: set[int], + level: int, + ) -> None: + self._format(object.data, stream, indent, allowance, context, level - 1) + + _dispatch[_collections.UserDict.__repr__] = _pprint_user_dict + + def _pprint_user_list( + self, + object: Any, + stream: IO[str], + indent: int, + allowance: int, + context: set[int], + level: int, + ) -> None: + self._format(object.data, stream, indent, allowance, context, level - 1) + + _dispatch[_collections.UserList.__repr__] = _pprint_user_list + + def _pprint_user_string( + self, + object: Any, + stream: IO[str], + indent: int, + allowance: int, + context: set[int], + level: int, + ) -> None: + self._format(object.data, stream, indent, allowance, context, level - 1) + + _dispatch[_collections.UserString.__repr__] = _pprint_user_string + + def _safe_repr( + self, object: Any, context: set[int], maxlevels: int | None, level: int + ) -> str: + typ = type(object) + if typ in _builtin_scalars: + return repr(object) + + r = getattr(typ, "__repr__", None) + + if issubclass(typ, dict) and r is dict.__repr__: + if not object: + return "{}" + objid = id(object) + if maxlevels and level >= maxlevels: + return "{...}" + if objid in context: + return _recursion(object) + context.add(objid) + components: list[str] = [] + append = components.append + level += 1 + for k, v in sorted(object.items(), key=_safe_tuple): + krepr = self._safe_repr(k, context, maxlevels, level) + vrepr = self._safe_repr(v, context, maxlevels, level) + append(f"{krepr}: {vrepr}") + context.remove(objid) + return "{{{}}}".format(", ".join(components)) + + if (issubclass(typ, list) and r is list.__repr__) or ( + issubclass(typ, tuple) and r is tuple.__repr__ + ): + if issubclass(typ, list): + if not object: + return "[]" + format = "[%s]" + elif len(object) == 1: + format = "(%s,)" + else: + if not object: + return "()" + format = "(%s)" + objid = id(object) + if maxlevels and level >= maxlevels: + return format % "..." + if objid in context: + return _recursion(object) + context.add(objid) + components = [] + append = components.append + level += 1 + for o in object: + orepr = self._safe_repr(o, context, maxlevels, level) + append(orepr) + context.remove(objid) + return format % ", ".join(components) + + return repr(object) + + +_builtin_scalars = frozenset( + {str, bytes, bytearray, float, complex, bool, type(None), int} +) + + +def _recursion(object: Any) -> str: + return f"" + + +def _wrap_bytes_repr(object: Any, width: int, allowance: int) -> Iterator[str]: + current = b"" + last = len(object) // 4 * 4 + for i in range(0, len(object), 4): + part = object[i : i + 4] + candidate = current + part + if i == last: + width -= allowance + if len(repr(candidate)) > width: + if current: + yield repr(current) + current = part + else: + current = candidate + if current: + yield repr(current) diff --git a/src/_pytest/_io/saferepr.py b/src/_pytest/_io/saferepr.py index c701872238c..cee70e332f9 100644 --- a/src/_pytest/_io/saferepr.py +++ b/src/_pytest/_io/saferepr.py @@ -1,9 +1,7 @@ +from __future__ import annotations + import pprint import reprlib -from typing import Any -from typing import Dict -from typing import IO -from typing import Optional def _try_repr_or_str(obj: object) -> str: @@ -20,10 +18,10 @@ def _format_repr_exception(exc: BaseException, obj: object) -> str: exc_info = _try_repr_or_str(exc) except (KeyboardInterrupt, SystemExit): raise - except BaseException as exc: - exc_info = f"unpresentable exception ({_try_repr_or_str(exc)})" - return "<[{} raised in repr()] {} object at 0x{:x}>".format( - exc_info, type(obj).__name__, id(obj) + except BaseException as inner_exc: + exc_info = f"unpresentable exception ({_try_repr_or_str(inner_exc)})" + return ( + f"<[{exc_info} raised in repr()] {type(obj).__name__} object at 0x{id(obj):x}>" ) @@ -41,7 +39,7 @@ class SafeRepr(reprlib.Repr): information on exceptions raised during the call. """ - def __init__(self, maxsize: Optional[int], use_ascii: bool = False) -> None: + def __init__(self, maxsize: int | None, use_ascii: bool = False) -> None: """ :param maxsize: If not None, will truncate the resulting repr to that specific size, using ellipsis @@ -62,7 +60,6 @@ def repr(self, x: object) -> str: s = ascii(x) else: s = super().repr(x) - except (KeyboardInterrupt, SystemExit): raise except BaseException as exc: @@ -100,7 +97,7 @@ def safeformat(obj: object) -> str: def saferepr( - obj: object, maxsize: Optional[int] = DEFAULT_REPR_MAX_SIZE, use_ascii: bool = False + obj: object, maxsize: int | None = DEFAULT_REPR_MAX_SIZE, use_ascii: bool = False ) -> str: """Return a size-limited safe repr-string for the given object. @@ -111,7 +108,6 @@ def saferepr( This function is a wrapper around the Repr/reprlib functionality of the stdlib. """ - return SafeRepr(maxsize, use_ascii).repr(obj) @@ -132,49 +128,3 @@ def saferepr_unlimited(obj: object, use_ascii: bool = True) -> str: return repr(obj) except Exception as exc: return _format_repr_exception(exc, obj) - - -class AlwaysDispatchingPrettyPrinter(pprint.PrettyPrinter): - """PrettyPrinter that always dispatches (regardless of width).""" - - def _format( - self, - object: object, - stream: IO[str], - indent: int, - allowance: int, - context: Dict[int, Any], - level: int, - ) -> None: - # Type ignored because _dispatch is private. - p = self._dispatch.get(type(object).__repr__, None) # type: ignore[attr-defined] - - objid = id(object) - if objid in context or p is None: - # Type ignored because _format is private. - super()._format( # type: ignore[misc] - object, - stream, - indent, - allowance, - context, - level, - ) - return - - context[objid] = 1 - p(self, object, stream, indent, allowance, context, level + 1) - del context[objid] - - -def _pformat_dispatch( - object: object, - indent: int = 1, - width: int = 80, - depth: Optional[int] = None, - *, - compact: bool = False, -) -> str: - return AlwaysDispatchingPrettyPrinter( - indent=indent, width=width, depth=depth, compact=compact - ).pformat(object) diff --git a/src/_pytest/_io/terminalwriter.py b/src/_pytest/_io/terminalwriter.py index 379035d858c..fd808f8b3b7 100644 --- a/src/_pytest/_io/terminalwriter.py +++ b/src/_pytest/_io/terminalwriter.py @@ -1,13 +1,23 @@ """Helper functions for writing to terminals and files.""" + +from __future__ import annotations + +from collections.abc import Sequence import os import shutil import sys -from typing import Optional -from typing import Sequence +from typing import final +from typing import Literal from typing import TextIO +import pygments +from pygments.formatters.terminal import TerminalFormatter +from pygments.lexer import Lexer +from pygments.lexers.diff import DiffLexer +from pygments.lexers.python import PythonLexer + +from ..compat import assert_never from .wcwidth import wcswidth -from _pytest.compat import final # This code was initially copied from py 1.8.1, file _io/terminalwriter.py. @@ -28,9 +38,9 @@ def should_do_markup(file: TextIO) -> bool: return True if os.environ.get("PY_COLORS") == "0": return False - if "NO_COLOR" in os.environ: + if os.environ.get("NO_COLOR"): return False - if "FORCE_COLOR" in os.environ: + if os.environ.get("FORCE_COLOR"): return True return ( hasattr(file, "isatty") and file.isatty() and os.environ.get("TERM") != "dumb" @@ -62,7 +72,7 @@ class TerminalWriter: invert=7, ) - def __init__(self, file: Optional[TextIO] = None) -> None: + def __init__(self, file: TextIO | None = None) -> None: if file is None: file = sys.stdout if hasattr(file, "isatty") and file.isatty() and sys.platform == "win32": @@ -76,7 +86,7 @@ def __init__(self, file: Optional[TextIO] = None) -> None: self._file = file self.hasmarkup = should_do_markup(file) self._current_line = "" - self._terminal_width: Optional[int] = None + self._terminal_width: int | None = None self.code_highlight = True @property @@ -101,14 +111,14 @@ def markup(self, text: str, **markup: bool) -> str: if self.hasmarkup: esc = [self._esctable[name] for name, on in markup.items() if on] if esc: - text = "".join("\x1b[%sm" % cod for cod in esc) + text + "\x1b[0m" + text = "".join(f"\x1b[{cod}m" for cod in esc) + text + "\x1b[0m" return text def sep( self, sepchar: str, - title: Optional[str] = None, - fullwidth: Optional[int] = None, + title: str | None = None, + fullwidth: int | None = None, **markup: bool, ) -> None: if fullwidth is None: @@ -182,9 +192,7 @@ def _write_source(self, lines: Sequence[str], indents: Sequence[str] = ()) -> No """ if indents and len(indents) != len(lines): raise ValueError( - "indents size ({}) should have same size as lines ({})".format( - len(indents), len(lines) - ) + f"indents size ({len(indents)}) should have same size as lines ({len(lines)})" ) if not indents: indents = [""] * len(lines) @@ -193,41 +201,54 @@ def _write_source(self, lines: Sequence[str], indents: Sequence[str] = ()) -> No for indent, new_line in zip(indents, new_lines): self.line(indent + new_line) - def _highlight(self, source: str) -> str: - """Highlight the given source code if we have markup support.""" + def _get_pygments_lexer(self, lexer: Literal["python", "diff"]) -> Lexer: + if lexer == "python": + return PythonLexer() + elif lexer == "diff": + return DiffLexer() + else: + assert_never(lexer) + + def _get_pygments_formatter(self) -> TerminalFormatter: from _pytest.config.exceptions import UsageError - if not self.hasmarkup or not self.code_highlight: - return source + theme = os.getenv("PYTEST_THEME") + theme_mode = os.getenv("PYTEST_THEME_MODE", "dark") + try: - from pygments.formatters.terminal import TerminalFormatter - from pygments.lexers.python import PythonLexer - from pygments import highlight - import pygments.util - except ImportError: + return TerminalFormatter(bg=theme_mode, style=theme) + except pygments.util.ClassNotFound as e: + raise UsageError( + f"PYTEST_THEME environment variable has an invalid value: '{theme}'. " + "Hint: See available pygments styles with `pygmentize -L styles`." + ) from e + except pygments.util.OptionError as e: + raise UsageError( + f"PYTEST_THEME_MODE environment variable has an invalid value: '{theme_mode}'. " + "The allowed values are 'dark' (default) and 'light'." + ) from e + + def _highlight( + self, source: str, lexer: Literal["diff", "python"] = "python" + ) -> str: + """Highlight the given source if we have markup support.""" + if not source or not self.hasmarkup or not self.code_highlight: return source - else: - try: - highlighted: str = highlight( - source, - PythonLexer(), - TerminalFormatter( - bg=os.getenv("PYTEST_THEME_MODE", "dark"), - style=os.getenv("PYTEST_THEME"), - ), - ) - return highlighted - except pygments.util.ClassNotFound: - raise UsageError( - "PYTEST_THEME environment variable had an invalid value: '{}'. " - "Only valid pygment styles are allowed.".format( - os.getenv("PYTEST_THEME") - ) - ) - except pygments.util.OptionError: - raise UsageError( - "PYTEST_THEME_MODE environment variable had an invalid value: '{}'. " - "The only allowed values are 'dark' and 'light'.".format( - os.getenv("PYTEST_THEME_MODE") - ) - ) + + pygments_lexer = self._get_pygments_lexer(lexer) + pygments_formatter = self._get_pygments_formatter() + + highlighted: str = pygments.highlight( + source, pygments_lexer, pygments_formatter + ) + # pygments terminal formatter may add a newline when there wasn't one. + # We don't want this, remove. + if highlighted[-1] == "\n" and source[-1] != "\n": + highlighted = highlighted[:-1] + + # Some lexers will not set the initial color explicitly + # which may lead to the previous color being propagated to the + # start of the expression, so reset first. + highlighted = "\x1b[0m" + highlighted + + return highlighted diff --git a/src/_pytest/_io/wcwidth.py b/src/_pytest/_io/wcwidth.py index e5c7bf4d868..23886ff1581 100644 --- a/src/_pytest/_io/wcwidth.py +++ b/src/_pytest/_io/wcwidth.py @@ -1,5 +1,7 @@ -import unicodedata +from __future__ import annotations + from functools import lru_cache +import unicodedata @lru_cache(100) diff --git a/src/_pytest/_py/error.py b/src/_pytest/_py/error.py index 0b8f2d535ef..dace23764ff 100644 --- a/src/_pytest/_py/error.py +++ b/src/_pytest/_py/error.py @@ -1,13 +1,15 @@ """create errno-specific classes for IO or os calls.""" + from __future__ import annotations +from collections.abc import Callable import errno import os import sys -from typing import Callable from typing import TYPE_CHECKING from typing import TypeVar + if TYPE_CHECKING: from typing_extensions import ParamSpec @@ -39,7 +41,7 @@ def __str__(self) -> str: 3: errno.ENOENT, 17: errno.EEXIST, 18: errno.EXDEV, - 13: errno.EBUSY, # empty cd drive, but ENOMEDIUM seems unavailiable + 13: errno.EBUSY, # empty cd drive, but ENOMEDIUM seems unavailable 22: errno.ENOTDIR, 20: errno.ENOTDIR, 267: errno.ENOTDIR, @@ -67,7 +69,7 @@ def _geterrnoclass(self, eno: int) -> type[Error]: try: return self._errno2class[eno] except KeyError: - clsname = errno.errorcode.get(eno, "UnknownErrno%d" % (eno,)) + clsname = errno.errorcode.get(eno, f"UnknownErrno{eno}") errorcls = type( clsname, (Error,), @@ -88,15 +90,23 @@ def checked_call( except OSError as value: if not hasattr(value, "errno"): raise - errno = value.errno if sys.platform == "win32": try: - cls = self._geterrnoclass(_winerrnomap[errno]) + # error: Invalid index type "Optional[int]" for "dict[int, int]"; expected type "int" [index] + # OK to ignore because we catch the KeyError below. + cls = self._geterrnoclass(_winerrnomap[value.errno]) # type:ignore[index] except KeyError: raise value else: # we are not on Windows, or we got a proper OSError - cls = self._geterrnoclass(errno) + if value.errno is None: + cls = type( + "UnknownErrnoNone", + (Error,), + {"__module__": "py.error", "__doc__": None}, + ) + else: + cls = self._geterrnoclass(value.errno) raise cls(f"{func.__name__}{args!r}") diff --git a/src/_pytest/_py/path.py b/src/_pytest/_py/path.py index 73a070d19a8..e353c1a9b52 100644 --- a/src/_pytest/_py/path.py +++ b/src/_pytest/_py/path.py @@ -1,16 +1,15 @@ +# mypy: allow-untyped-defs """local path implementation.""" + from __future__ import annotations import atexit +from collections.abc import Callable +from contextlib import contextmanager import fnmatch import importlib.util import io import os -import posixpath -import sys -import uuid -import warnings -from contextlib import contextmanager from os.path import abspath from os.path import dirname from os.path import exists @@ -19,19 +18,21 @@ from os.path import isfile from os.path import islink from os.path import normpath +import posixpath from stat import S_ISDIR from stat import S_ISLNK from stat import S_ISREG +import sys from typing import Any -from typing import Callable from typing import cast +from typing import Literal from typing import overload from typing import TYPE_CHECKING +import uuid +import warnings from . import error -if TYPE_CHECKING: - from typing import Literal # Moved from local.py. iswin32 = sys.platform == "win32" or (getattr(os, "_name", False) == "nt") @@ -160,15 +161,13 @@ def gen(self, path): ) if not self.breadthfirst: for subdir in dirs: - for p in self.gen(subdir): - yield p + yield from self.gen(subdir) for p in self.optsort(entries): if self.fil is None or self.fil(p): yield p if self.breadthfirst: for subdir in dirs: - for p in self.gen(subdir): - yield p + yield from self.gen(subdir) class FNMatcher: @@ -205,12 +204,10 @@ class Stat: if TYPE_CHECKING: @property - def size(self) -> int: - ... + def size(self) -> int: ... @property - def mtime(self) -> float: - ... + def mtime(self) -> float: ... def __getattr__(self, name: str) -> Any: return getattr(self._osstatresult, "st_" + name) @@ -225,7 +222,7 @@ def owner(self): raise NotImplementedError("XXX win32") import pwd - entry = error.checked_call(pwd.getpwuid, self.uid) # type:ignore[attr-defined] + entry = error.checked_call(pwd.getpwuid, self.uid) # type:ignore[attr-defined,unused-ignore] return entry[0] @property @@ -235,7 +232,7 @@ def group(self): raise NotImplementedError("XXX win32") import grp - entry = error.checked_call(grp.getgrgid, self.gid) # type:ignore[attr-defined] + entry = error.checked_call(grp.getgrgid, self.gid) # type:ignore[attr-defined,unused-ignore] return entry[0] def isdir(self): @@ -253,7 +250,7 @@ def getuserid(user): import pwd if not isinstance(user, int): - user = pwd.getpwnam(user)[2] # type:ignore[attr-defined] + user = pwd.getpwnam(user)[2] # type:ignore[attr-defined,unused-ignore] return user @@ -261,7 +258,7 @@ def getgroupid(group): import grp if not isinstance(group, int): - group = grp.getgrnam(group)[2] # type:ignore[attr-defined] + group = grp.getgrnam(group)[2] # type:ignore[attr-defined,unused-ignore] return group @@ -318,7 +315,7 @@ def chown(self, user, group, rec=0): def readlink(self) -> str: """Return value of a symbolic link.""" # https://github.com/python/mypy/issues/12278 - return error.checked_call(os.readlink, self.strpath) # type: ignore[arg-type,return-value] + return error.checked_call(os.readlink, self.strpath) # type: ignore[arg-type,return-value,unused-ignore] def mklinkto(self, oldname): """Posix style hard link to another name.""" @@ -452,7 +449,7 @@ def relto(self, relpath): def ensure_dir(self, *args): """Ensure the path joined with args is a directory.""" - return self.ensure(*args, **{"dir": True}) + return self.ensure(*args, dir=True) def bestrelpath(self, dest): """Return a string which is a relative path from self @@ -660,7 +657,7 @@ def new(self, **kw): ) if "basename" in kw: if "purebasename" in kw or "ext" in kw: - raise ValueError("invalid specification %r" % kw) + raise ValueError(f"invalid specification {kw!r}") else: pb = kw.setdefault("purebasename", purebasename) try: @@ -677,7 +674,7 @@ def new(self, **kw): else: kw.setdefault("dirname", dirname) kw.setdefault("sep", self.sep) - obj.strpath = normpath("%(dirname)s%(sep)s%(basename)s" % kw) + obj.strpath = normpath("{dirname}{sep}{basename}".format(**kw)) return obj def _getbyspec(self, spec: str) -> list[str]: @@ -706,7 +703,7 @@ def _getbyspec(self, spec: str) -> list[str]: elif name == "ext": res.append(ext) else: - raise ValueError("invalid part specification %r" % name) + raise ValueError(f"invalid part specification {name!r}") return res def dirpath(self, *args, **kwargs): @@ -757,7 +754,12 @@ def open(self, mode="r", ensure=False, encoding=None): if ensure: self.dirpath().ensure(dir=1) if encoding: - return error.checked_call(io.open, self.strpath, mode, encoding=encoding) + return error.checked_call( + io.open, + self.strpath, + mode, + encoding=encoding, + ) return error.checked_call(open, self.strpath, mode) def _fastjoin(self, name): @@ -775,11 +777,11 @@ def check(self, **kw): valid checkers:: - file=1 # is a file - file=0 # is not a file (may not even exist) - dir=1 # is a dir - link=1 # is a link - exists=1 # exists + file = 1 # is a file + file = 0 # is not a file (may not even exist) + dir = 1 # is a dir + link = 1 # is a link + exists = 1 # exists You can specify multiple checker definitions, for example:: @@ -832,7 +834,7 @@ def mtime(self) -> float: def copy(self, target, mode=False, stat=False): """Copy path to target. - If mode is True, will copy copy permission from path to target. + If mode is True, will copy permission from path to target. If stat is True, copy permission, last modification time, last access time, and flags from path to target. """ @@ -957,12 +959,10 @@ def ensure(self, *args, **kwargs): return p @overload - def stat(self, raising: Literal[True] = ...) -> Stat: - ... + def stat(self, raising: Literal[True] = ...) -> Stat: ... @overload - def stat(self, raising: Literal[False]) -> Stat | None: - ... + def stat(self, raising: Literal[False]) -> Stat | None: ... def stat(self, raising: bool = True) -> Stat | None: """Return an os.stat() tuple.""" @@ -1024,7 +1024,7 @@ def atime(self): return self.stat().atime def __repr__(self): - return "local(%r)" % self.strpath + return f"local({self.strpath!r})" def __str__(self): """Return string representation of the Path.""" @@ -1045,7 +1045,7 @@ def chmod(self, mode, rec=0): def pypkgpath(self): """Return the Python package path by looking for the last directory upwards which still contains an __init__.py. - Return None if a pkgpath can not be determined. + Return None if a pkgpath cannot be determined. """ pkgpath = None for parent in self.parts(reverse=True): @@ -1096,9 +1096,7 @@ def pyimport(self, modname=None, ensuresyspath=True): modname = self.purebasename spec = importlib.util.spec_from_file_location(modname, str(self)) if spec is None or spec.loader is None: - raise ImportError( - f"Can't find module {modname} at location {str(self)}" - ) + raise ImportError(f"Can't find module {modname} at location {self!s}") mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) return mod @@ -1163,7 +1161,8 @@ def sysexec(self, *argv: os.PathLike[str], **popen_opts: Any) -> str: where the 'self' path points to executable. The process is directly invoked and not through a system shell. """ - from subprocess import Popen, PIPE + from subprocess import PIPE + from subprocess import Popen popen_opts.pop("stdout", None) popen_opts.pop("stderr", None) @@ -1263,13 +1262,14 @@ def get_temproot(cls): @classmethod def mkdtemp(cls, rootdir=None): """Return a Path object pointing to a fresh new temporary directory - (which we created ourself). + (which we created ourselves). """ import tempfile if rootdir is None: rootdir = cls.get_temproot() - return cls(error.checked_call(tempfile.mkdtemp, dir=str(rootdir))) + path = error.checked_call(tempfile.mkdtemp, dir=str(rootdir)) + return cls(path) @classmethod def make_numbered_dir( diff --git a/src/_pytest/assertion/__init__.py b/src/_pytest/assertion/__init__.py index a46e58136ba..22f3ca8e258 100644 --- a/src/_pytest/assertion/__init__.py +++ b/src/_pytest/assertion/__init__.py @@ -1,9 +1,12 @@ +# mypy: allow-untyped-defs """Support for presenting detailed information in failing assertions.""" + +from __future__ import annotations + +from collections.abc import Generator import sys from typing import Any -from typing import Generator -from typing import List -from typing import Optional +from typing import Protocol from typing import TYPE_CHECKING from _pytest.assertion import rewrite @@ -15,6 +18,7 @@ from _pytest.config.argparsing import Parser from _pytest.nodes import Item + if TYPE_CHECKING: from _pytest.main import Session @@ -43,6 +47,26 @@ def pytest_addoption(parser: Parser) -> None: "Make sure to delete any previously generated pyc cache files.", ) + parser.addini( + "truncation_limit_lines", + default=None, + help="Set threshold of LINES after which truncation will take effect", + ) + parser.addini( + "truncation_limit_chars", + default=None, + help=("Set threshold of CHARS after which truncation will take effect"), + ) + + Config._add_verbosity_ini( + parser, + Config.VERBOSITY_ASSERTIONS, + help=( + "Specify a verbosity level for assertions, overriding the main level. " + "Higher levels will provide more detailed explanation when an assertion fails." + ), + ) + def register_assert_rewrite(*names: str) -> None: """Register one or more module names to be rewritten on import. @@ -59,15 +83,18 @@ def register_assert_rewrite(*names: str) -> None: if not isinstance(name, str): msg = "expected module names as *args, got {0} instead" # type: ignore[unreachable] raise TypeError(msg.format(repr(names))) + rewrite_hook: RewriteHook for hook in sys.meta_path: if isinstance(hook, rewrite.AssertionRewritingHook): - importhook = hook + rewrite_hook = hook break else: - # TODO(typing): Add a protocol for mark_rewrite() and use it - # for importhook and for PytestPluginManager.rewrite_hook. - importhook = DummyRewriteHook() # type: ignore - importhook.mark_rewrite(*names) + rewrite_hook = DummyRewriteHook() + rewrite_hook.mark_rewrite(*names) + + +class RewriteHook(Protocol): + def mark_rewrite(self, *names: str) -> None: ... class DummyRewriteHook: @@ -83,7 +110,7 @@ class AssertionState: def __init__(self, config: Config, mode) -> None: self.mode = mode self.trace = config.trace.root.get("assertion") - self.hook: Optional[rewrite.AssertionRewritingHook] = None + self.hook: rewrite.AssertionRewritingHook | None = None def install_importhook(config: Config) -> rewrite.AssertionRewritingHook: @@ -102,7 +129,7 @@ def undo() -> None: return hook -def pytest_collection(session: "Session") -> None: +def pytest_collection(session: Session) -> None: # This hook is only called when test modules are collected # so for example not in the managing process of pytest-xdist # (which does not collect test modules). @@ -112,18 +139,17 @@ def pytest_collection(session: "Session") -> None: assertstate.hook.set_session(session) -@hookimpl(tryfirst=True, hookwrapper=True) -def pytest_runtest_protocol(item: Item) -> Generator[None, None, None]: +@hookimpl(wrapper=True, tryfirst=True) +def pytest_runtest_protocol(item: Item) -> Generator[None, object, object]: """Setup the pytest_assertrepr_compare and pytest_assertion_pass hooks. The rewrite module will use util._reprcompare if it exists to use custom reporting via the pytest_assertrepr_compare hook. This sets up this custom comparison for the test. """ - ihook = item.ihook - def callbinrepr(op, left: object, right: object) -> Optional[str]: + def callbinrepr(op, left: object, right: object) -> str | None: """Call the pytest_assertrepr_compare hook and prepare the result. This uses the first result from the hook and then ensures the @@ -162,13 +188,14 @@ def call_assertion_pass_hook(lineno: int, orig: str, expl: str) -> None: util._assertion_pass = call_assertion_pass_hook - yield - - util._reprcompare, util._assertion_pass = saved_assert_hooks - util._config = None + try: + return (yield) + finally: + util._reprcompare, util._assertion_pass = saved_assert_hooks + util._config = None -def pytest_sessionfinish(session: "Session") -> None: +def pytest_sessionfinish(session: Session) -> None: assertstate = session.config.stash.get(assertstate_key, None) if assertstate: if assertstate.hook is not None: @@ -177,5 +204,5 @@ def pytest_sessionfinish(session: "Session") -> None: def pytest_assertrepr_compare( config: Config, op: str, left: Any, right: Any -) -> Optional[List[str]]: +) -> list[str] | None: return util.assertrepr_compare(config=config, op=op, left=left, right=right) diff --git a/src/_pytest/assertion/rewrite.py b/src/_pytest/assertion/rewrite.py index ab83fee32b2..c4782c7c5a8 100644 --- a/src/_pytest/assertion/rewrite.py +++ b/src/_pytest/assertion/rewrite.py @@ -1,5 +1,13 @@ """Rewrite assertion AST to produce nice error messages.""" + +from __future__ import annotations + import ast +from collections import defaultdict +from collections.abc import Callable +from collections.abc import Iterable +from collections.abc import Iterator +from collections.abc import Sequence import errno import functools import importlib.abc @@ -9,60 +17,50 @@ import itertools import marshal import os +from pathlib import Path +from pathlib import PurePath import struct import sys import tokenize import types -from pathlib import Path -from pathlib import PurePath -from typing import Callable -from typing import Dict from typing import IO -from typing import Iterable -from typing import Iterator -from typing import List -from typing import Optional -from typing import Sequence -from typing import Set -from typing import Tuple from typing import TYPE_CHECKING -from typing import Union from _pytest._io.saferepr import DEFAULT_REPR_MAX_SIZE from _pytest._io.saferepr import saferepr +from _pytest._io.saferepr import saferepr_unlimited from _pytest._version import version from _pytest.assertion import util -from _pytest.assertion.util import ( # noqa: F401 - format_explanation as _format_explanation, -) from _pytest.config import Config +from _pytest.fixtures import FixtureFunctionDefinition from _pytest.main import Session from _pytest.pathlib import absolutepath from _pytest.pathlib import fnmatch_ex from _pytest.stash import StashKey + +# fmt: off +from _pytest.assertion.util import format_explanation as _format_explanation # noqa:F401, isort:skip +# fmt:on + if TYPE_CHECKING: from _pytest.assertion import AssertionState -if sys.version_info >= (3, 8): - namedExpr = ast.NamedExpr - astNameConstant = ast.Constant - astStr = ast.Constant - astNum = ast.Constant -else: - namedExpr = ast.Expr - astNameConstant = ast.NameConstant - astStr = ast.Str - astNum = ast.Num + +class Sentinel: + pass assertstate_key = StashKey["AssertionState"]() # pytest caches rewritten pycs in pycache dirs PYTEST_TAG = f"{sys.implementation.cache_tag}-pytest-{version}" -PYC_EXT = ".py" + (__debug__ and "c" or "o") +PYC_EXT = ".py" + ((__debug__ and "c") or "o") PYC_TAIL = "." + PYTEST_TAG + PYC_EXT +# Special marker that denotes we have just left a scope definition +_SCOPE_END_MARKER = Sentinel() + class AssertionRewritingHook(importlib.abc.MetaPathFinder, importlib.abc.Loader): """PEP302/PEP451 import hook which rewrites asserts.""" @@ -73,17 +71,17 @@ def __init__(self, config: Config) -> None: self.fnpats = config.getini("python_files") except ValueError: self.fnpats = ["test_*.py", "*_test.py"] - self.session: Optional[Session] = None - self._rewritten_names: Dict[str, Path] = {} - self._must_rewrite: Set[str] = set() + self.session: Session | None = None + self._rewritten_names: dict[str, Path] = {} + self._must_rewrite: set[str] = set() # flag to guard against trying to rewrite a pyc file while we are already writing another pyc file, # which might result in infinite recursion (#3506) self._writing_pyc = False self._basenames_to_check_rewrite = {"conftest"} - self._marked_for_rewrite_cache: Dict[str, bool] = {} + self._marked_for_rewrite_cache: dict[str, bool] = {} self._session_paths_checked = False - def set_session(self, session: Optional[Session]) -> None: + def set_session(self, session: Session | None) -> None: self.session = session self._session_paths_checked = False @@ -93,18 +91,28 @@ def set_session(self, session: Optional[Session]) -> None: def find_spec( self, name: str, - path: Optional[Sequence[Union[str, bytes]]] = None, - target: Optional[types.ModuleType] = None, - ) -> Optional[importlib.machinery.ModuleSpec]: + path: Sequence[str | bytes] | None = None, + target: types.ModuleType | None = None, + ) -> importlib.machinery.ModuleSpec | None: if self._writing_pyc: return None state = self.config.stash[assertstate_key] if self._early_rewrite_bailout(name, state): return None - state.trace("find_module called for: %s" % name) + state.trace(f"find_module called for: {name}") # Type ignored because mypy is confused about the `self` binding here. spec = self._find_spec(name, path) # type: ignore + + if spec is None and path is not None: + # With --import-mode=importlib, PathFinder cannot find spec without modifying `sys.path`, + # causing inability to assert rewriting (#12659). + # At this point, try using the file path to find the module spec. + for _path_str in path: + spec = importlib.util.spec_from_file_location(name, _path_str) + if spec is not None: + break + if ( # the import machinery could not find a file to import spec is None @@ -132,7 +140,7 @@ def find_spec( def create_module( self, spec: importlib.machinery.ModuleSpec - ) -> Optional[types.ModuleType]: + ) -> types.ModuleType | None: return None # default behaviour is fine def exec_module(self, module: types.ModuleType) -> None: @@ -177,7 +185,7 @@ def exec_module(self, module: types.ModuleType) -> None: state.trace(f"found cached rewritten pyc for {fn}") exec(co, module.__dict__) - def _early_rewrite_bailout(self, name: str, state: "AssertionState") -> bool: + def _early_rewrite_bailout(self, name: str, state: AssertionState) -> bool: """A fast way to get out of rewriting modules. Profiling has shown that the call to PathFinder.find_spec (inside of @@ -216,7 +224,7 @@ def _early_rewrite_bailout(self, name: str, state: "AssertionState") -> bool: state.trace(f"early skip of rewriting module: {name}") return True - def _should_rewrite(self, name: str, fn: str, state: "AssertionState") -> bool: + def _should_rewrite(self, name: str, fn: str, state: AssertionState) -> bool: # always rewrite conftest files if os.path.basename(fn) == "conftest.py": state.trace(f"rewriting conftest file: {fn!r}") @@ -237,7 +245,7 @@ def _should_rewrite(self, name: str, fn: str, state: "AssertionState") -> bool: return self._is_marked_for_rewrite(name, state) - def _is_marked_for_rewrite(self, name: str, state: "AssertionState") -> bool: + def _is_marked_for_rewrite(self, name: str, state: AssertionState) -> bool: try: return self._marked_for_rewrite_cache[name] except KeyError: @@ -273,12 +281,12 @@ def _warn_already_imported(self, name: str) -> None: self.config.issue_config_time_warning( PytestAssertRewriteWarning( - "Module already imported so cannot be rewritten: %s" % name + f"Module already imported so cannot be rewritten; {name}" ), stacklevel=5, ) - def get_data(self, pathname: Union[str, bytes]) -> bytes: + def get_data(self, pathname: str | bytes) -> bytes: """Optional PEP302 get_data API.""" with open(pathname, "rb") as f: return f.read() @@ -289,15 +297,13 @@ def get_data(self, pathname: Union[str, bytes]) -> bytes: else: from importlib.abc import TraversableResources - def get_resource_reader(self, name: str) -> TraversableResources: # type: ignore + def get_resource_reader(self, name: str) -> TraversableResources: if sys.version_info < (3, 11): from importlib.readers import FileReader else: from importlib.resources.readers import FileReader - return FileReader( # type:ignore[no-any-return] - types.SimpleNamespace(path=self._rewritten_names[name]) - ) + return FileReader(types.SimpleNamespace(path=self._rewritten_names[name])) def _write_pyc_fp( @@ -319,7 +325,7 @@ def _write_pyc_fp( def _write_pyc( - state: "AssertionState", + state: AssertionState, co: types.CodeType, source_stat: os.stat_result, pyc: Path, @@ -343,7 +349,7 @@ def _write_pyc( return True -def _rewrite_test(fn: Path, config: Config) -> Tuple[os.stat_result, types.CodeType]: +def _rewrite_test(fn: Path, config: Config) -> tuple[os.stat_result, types.CodeType]: """Read and rewrite *fn* and return the code object.""" stat = os.stat(fn) source = fn.read_bytes() @@ -356,7 +362,7 @@ def _rewrite_test(fn: Path, config: Config) -> Tuple[os.stat_result, types.CodeT def _read_pyc( source: Path, pyc: Path, trace: Callable[[str], None] = lambda x: None -) -> Optional[types.CodeType]: +) -> types.CodeType | None: """Possibly read a pytest pyc containing rewritten code. Return rewritten code if successful or None if not. @@ -376,21 +382,21 @@ def _read_pyc( return None # Check for invalid or out of date pyc file. if len(data) != (16): - trace("_read_pyc(%s): invalid pyc (too short)" % source) + trace(f"_read_pyc({source}): invalid pyc (too short)") return None if data[:4] != importlib.util.MAGIC_NUMBER: - trace("_read_pyc(%s): invalid pyc (bad magic number)" % source) + trace(f"_read_pyc({source}): invalid pyc (bad magic number)") return None if data[4:8] != b"\x00\x00\x00\x00": - trace("_read_pyc(%s): invalid pyc (unsupported flags)" % source) + trace(f"_read_pyc({source}): invalid pyc (unsupported flags)") return None mtime_data = data[8:12] if int.from_bytes(mtime_data, "little") != mtime & 0xFFFFFFFF: - trace("_read_pyc(%s): out of date" % source) + trace(f"_read_pyc({source}): out of date") return None size_data = data[12:16] if int.from_bytes(size_data, "little") != size & 0xFFFFFFFF: - trace("_read_pyc(%s): invalid pyc (incorrect size)" % source) + trace(f"_read_pyc({source}): invalid pyc (incorrect size)") return None try: co = marshal.load(fp) @@ -398,7 +404,7 @@ def _read_pyc( trace(f"_read_pyc({source}): marshal.load error {e}") return None if not isinstance(co, types.CodeType): - trace("_read_pyc(%s): not a code object" % source) + trace(f"_read_pyc({source}): not a code object") return None return co @@ -406,8 +412,8 @@ def _read_pyc( def rewrite_asserts( mod: ast.Module, source: bytes, - module_path: Optional[str] = None, - config: Optional[Config] = None, + module_path: str | None = None, + config: Config | None = None, ) -> None: """Rewrite the assert statements in mod.""" AssertionRewriter(module_path, config, source).run(mod) @@ -423,13 +429,22 @@ def _saferepr(obj: object) -> str: sequences, especially '\n{' and '\n}' are likely to be present in JSON reprs. """ + if isinstance(obj, types.MethodType): + # for bound methods, skip redundant information + return obj.__name__ + maxsize = _get_maxsize_for_saferepr(util._config) + if not maxsize: + return saferepr_unlimited(obj).replace("\n", "\\n") return saferepr(obj, maxsize=maxsize).replace("\n", "\\n") -def _get_maxsize_for_saferepr(config: Optional[Config]) -> Optional[int]: +def _get_maxsize_for_saferepr(config: Config | None) -> int | None: """Get `maxsize` configuration for saferepr based on the given config object.""" - verbosity = config.getoption("verbose") if config is not None else 0 + if config is None: + verbosity = 0 + else: + verbosity = config.get_verbosity(Config.VERBOSITY_ASSERTIONS) if verbosity >= 2: return None if verbosity >= 1: @@ -450,7 +465,7 @@ def _format_assertmsg(obj: object) -> str: # However in either case we want to preserve the newline. replaces = [("\n", "\n~"), ("%", "%%")] if not isinstance(obj, str): - obj = saferepr(obj) + obj = saferepr(obj, _get_maxsize_for_saferepr(util._config)) replaces.append(("\\n", "\n~")) for r1, r2 in replaces: @@ -461,7 +476,8 @@ def _format_assertmsg(obj: object) -> str: def _should_repr_global_name(obj: object) -> bool: if callable(obj): - return False + # For pytest fixtures the __repr__ method provides more information than the function name. + return isinstance(obj, FixtureFunctionDefinition) try: return not hasattr(obj, "__name__") @@ -470,7 +486,7 @@ def _should_repr_global_name(obj: object) -> bool: def _format_boolop(explanations: Iterable[str], is_or: bool) -> str: - explanation = "(" + (is_or and " or " or " and ").join(explanations) + ")" + explanation = "(" + ((is_or and " or ") or " and ").join(explanations) + ")" return explanation.replace("%", "%%") @@ -542,14 +558,14 @@ def traverse_node(node: ast.AST) -> Iterator[ast.AST]: @functools.lru_cache(maxsize=1) -def _get_assertion_exprs(src: bytes) -> Dict[int, str]: +def _get_assertion_exprs(src: bytes) -> dict[int, str]: """Return a mapping from {lineno: "assertion test expression"}.""" - ret: Dict[int, str] = {} + ret: dict[int, str] = {} depth = 0 - lines: List[str] = [] - assert_lineno: Optional[int] = None - seen_lines: Set[int] = set() + lines: list[str] = [] + assert_lineno: int | None = None + seen_lines: set[int] = set() def _write_and_reset() -> None: nonlocal depth, lines, assert_lineno, seen_lines @@ -583,7 +599,7 @@ def _write_and_reset() -> None: # multi-line assert with message elif lineno in seen_lines: lines[-1] = lines[-1][:offset] - # multi line assert with escapd newline before message + # multi line assert with escaped newline before message else: lines.append(line[:offset]) _write_and_reset() @@ -645,6 +661,8 @@ class AssertionRewriter(ast.NodeVisitor): .push_format_context() and .pop_format_context() which allows to build another %-formatted string while already building one. + :scope: A tuple containing the current scope used for variables_overwrite. + :variables_overwrite: A dict filled with references to variables that change value within an assert. This happens when a variable is reassigned with the walrus operator @@ -654,7 +672,7 @@ class AssertionRewriter(ast.NodeVisitor): """ def __init__( - self, module_path: Optional[str], config: Optional[Config], source: bytes + self, module_path: str | None, config: Config | None, source: bytes ) -> None: super().__init__() self.module_path = module_path @@ -666,7 +684,10 @@ def __init__( else: self.enable_assertion_pass_hook = False self.source = source - self.variables_overwrite: Dict[str, str] = {} + self.scope: tuple[ast.AST, ...] = () + self.variables_overwrite: defaultdict[tuple[ast.AST, ...], dict[str, str]] = ( + defaultdict(dict) + ) def run(self, mod: ast.Module) -> None: """Find all assert statements in *mod* and rewrite them.""" @@ -686,12 +707,10 @@ def run(self, mod: ast.Module) -> None: if ( expect_docstring and isinstance(item, ast.Expr) - and isinstance(item.value, astStr) + and isinstance(item.value, ast.Constant) + and isinstance(item.value.value, str) ): - if sys.version_info >= (3, 8): - doc = item.value.value - else: - doc = item.value.s + doc = item.value.value if self.is_rewrite_disabled(doc): return expect_docstring = False @@ -732,12 +751,20 @@ def run(self, mod: ast.Module) -> None: mod.body[pos:pos] = imports # Collect asserts. - nodes: List[ast.AST] = [mod] + self.scope = (mod,) + nodes: list[ast.AST | Sentinel] = [mod] while nodes: node = nodes.pop() + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + self.scope = tuple((*self.scope, node)) + nodes.append(_SCOPE_END_MARKER) + if node == _SCOPE_END_MARKER: + self.scope = self.scope[:-1] + continue + assert isinstance(node, ast.AST) for name, field in ast.iter_fields(node): if isinstance(field, list): - new: List[ast.AST] = [] + new: list[ast.AST] = [] for i, child in enumerate(field): if isinstance(child, ast.Assert): # Transform assert. @@ -770,7 +797,7 @@ def assign(self, expr: ast.expr) -> ast.Name: """Give *expr* a name.""" name = self.variable() self.statements.append(ast.Assign([ast.Name(name, ast.Store())], expr)) - return ast.Name(name, ast.Load()) + return ast.copy_location(ast.Name(name, ast.Load()), expr) def display(self, expr: ast.expr) -> ast.expr: """Call saferepr on the expression.""" @@ -809,7 +836,7 @@ def push_format_context(self) -> None: to format a string of %-formatted values as added by .explanation_param(). """ - self.explanation_specifiers: Dict[str, ast.expr] = {} + self.explanation_specifiers: dict[str, ast.expr] = {} self.stack.append(self.explanation_specifiers) def pop_format_context(self, expl_expr: ast.expr) -> ast.Name: @@ -823,7 +850,7 @@ def pop_format_context(self, expl_expr: ast.expr) -> ast.Name: current = self.stack.pop() if self.stack: self.explanation_specifiers = self.stack[-1] - keys = [astStr(key) for key in current.keys()] + keys: list[ast.expr | None] = [ast.Constant(key) for key in current.keys()] format_dict = ast.Dict(keys, list(current.values())) form = ast.BinOp(expl_expr, ast.Mod(), format_dict) name = "@py_format" + str(next(self.variable_counter)) @@ -832,13 +859,13 @@ def pop_format_context(self, expl_expr: ast.expr) -> ast.Name: self.expl_stmts.append(ast.Assign([ast.Name(name, ast.Store())], form)) return ast.Name(name, ast.Load()) - def generic_visit(self, node: ast.AST) -> Tuple[ast.Name, str]: + def generic_visit(self, node: ast.AST) -> tuple[ast.Name, str]: """Handle expressions we don't have custom code for.""" assert isinstance(node, ast.expr) res = self.assign(node) return res, self.explanation_param(self.display(res)) - def visit_Assert(self, assert_: ast.Assert) -> List[ast.stmt]: + def visit_Assert(self, assert_: ast.Assert) -> list[ast.stmt]: """Return the AST statements to replace the ast.Assert instance. This rewrites the test of an assertion to provide @@ -847,9 +874,10 @@ def visit_Assert(self, assert_: ast.Assert) -> List[ast.stmt]: the expression is false. """ if isinstance(assert_.test, ast.Tuple) and len(assert_.test.elts) >= 1: - from _pytest.warning_types import PytestAssertRewriteWarning import warnings + from _pytest.warning_types import PytestAssertRewriteWarning + # TODO: This assert should not be needed. assert self.module_path is not None warnings.warn_explicit( @@ -861,15 +889,15 @@ def visit_Assert(self, assert_: ast.Assert) -> List[ast.stmt]: lineno=assert_.lineno, ) - self.statements: List[ast.stmt] = [] - self.variables: List[str] = [] + self.statements: list[ast.stmt] = [] + self.variables: list[str] = [] self.variable_counter = itertools.count() if self.enable_assertion_pass_hook: - self.format_variables: List[str] = [] + self.format_variables: list[str] = [] - self.stack: List[Dict[str, ast.expr]] = [] - self.expl_stmts: List[ast.stmt] = [] + self.stack: list[dict[str, ast.expr]] = [] + self.expl_stmts: list[ast.stmt] = [] self.push_format_context() # Rewrite assert into a bunch of statements. top_condition, explanation = self.visit(assert_.test) @@ -877,16 +905,16 @@ def visit_Assert(self, assert_: ast.Assert) -> List[ast.stmt]: negation = ast.UnaryOp(ast.Not(), top_condition) if self.enable_assertion_pass_hook: # Experimental pytest_assertion_pass hook - msg = self.pop_format_context(astStr(explanation)) + msg = self.pop_format_context(ast.Constant(explanation)) # Failed if assert_.msg: assertmsg = self.helper("_format_assertmsg", assert_.msg) gluestr = "\n>assert " else: - assertmsg = astStr("") + assertmsg = ast.Constant("") gluestr = "assert " - err_explanation = ast.BinOp(astStr(gluestr), ast.Add(), msg) + err_explanation = ast.BinOp(ast.Constant(gluestr), ast.Add(), msg) err_msg = ast.BinOp(assertmsg, ast.Add(), err_explanation) err_name = ast.Name("AssertionError", ast.Load()) fmt = self.helper("_format_explanation", err_msg) @@ -902,27 +930,27 @@ def visit_Assert(self, assert_: ast.Assert) -> List[ast.stmt]: hook_call_pass = ast.Expr( self.helper( "_call_assertion_pass", - astNum(assert_.lineno), - astStr(orig), + ast.Constant(assert_.lineno), + ast.Constant(orig), fmt_pass, ) ) # If any hooks implement assert_pass hook hook_impl_test = ast.If( self.helper("_check_if_assertion_pass_impl"), - self.expl_stmts + [hook_call_pass], + [*self.expl_stmts, hook_call_pass], [], ) - statements_pass = [hook_impl_test] + statements_pass: list[ast.stmt] = [hook_impl_test] # Test for assertion condition main_test = ast.If(negation, statements_fail, statements_pass) self.statements.append(main_test) if self.format_variables: - variables = [ + variables: list[ast.expr] = [ ast.Name(name, ast.Store()) for name in self.format_variables ] - clear_format = ast.Assign(variables, astNameConstant(None)) + clear_format = ast.Assign(variables, ast.Constant(None)) self.statements.append(clear_format) else: # Original assertion rewriting @@ -933,9 +961,9 @@ def visit_Assert(self, assert_: ast.Assert) -> List[ast.stmt]: assertmsg = self.helper("_format_assertmsg", assert_.msg) explanation = "\n>assert " + explanation else: - assertmsg = astStr("") + assertmsg = ast.Constant("") explanation = "assert " + explanation - template = ast.BinOp(assertmsg, ast.Add(), astStr(explanation)) + template = ast.BinOp(assertmsg, ast.Add(), ast.Constant(explanation)) msg = self.pop_format_context(template) fmt = self.helper("_format_explanation", msg) err_name = ast.Name("AssertionError", ast.Load()) @@ -947,37 +975,40 @@ def visit_Assert(self, assert_: ast.Assert) -> List[ast.stmt]: # Clear temporary variables by setting them to None. if self.variables: variables = [ast.Name(name, ast.Store()) for name in self.variables] - clear = ast.Assign(variables, astNameConstant(None)) + clear = ast.Assign(variables, ast.Constant(None)) self.statements.append(clear) # Fix locations (line numbers/column offsets). for stmt in self.statements: for node in traverse_node(stmt): - ast.copy_location(node, assert_) + if getattr(node, "lineno", None) is None: + # apply the assertion location to all generated ast nodes without source location + # and preserve the location of existing nodes or generated nodes with an correct location. + ast.copy_location(node, assert_) return self.statements - def visit_NamedExpr(self, name: namedExpr) -> Tuple[namedExpr, str]: + def visit_NamedExpr(self, name: ast.NamedExpr) -> tuple[ast.NamedExpr, str]: # This method handles the 'walrus operator' repr of the target # name if it's a local variable or _should_repr_global_name() # thinks it's acceptable. locs = ast.Call(self.builtin("locals"), [], []) - target_id = name.target.id # type: ignore[attr-defined] - inlocs = ast.Compare(astStr(target_id), [ast.In()], [locs]) + target_id = name.target.id + inlocs = ast.Compare(ast.Constant(target_id), [ast.In()], [locs]) dorepr = self.helper("_should_repr_global_name", name) test = ast.BoolOp(ast.Or(), [inlocs, dorepr]) - expr = ast.IfExp(test, self.display(name), astStr(target_id)) + expr = ast.IfExp(test, self.display(name), ast.Constant(target_id)) return name, self.explanation_param(expr) - def visit_Name(self, name: ast.Name) -> Tuple[ast.Name, str]: + def visit_Name(self, name: ast.Name) -> tuple[ast.Name, str]: # Display the repr of the name if it's a local variable or # _should_repr_global_name() thinks it's acceptable. locs = ast.Call(self.builtin("locals"), [], []) - inlocs = ast.Compare(astStr(name.id), [ast.In()], [locs]) + inlocs = ast.Compare(ast.Constant(name.id), [ast.In()], [locs]) dorepr = self.helper("_should_repr_global_name", name) test = ast.BoolOp(ast.Or(), [inlocs, dorepr]) - expr = ast.IfExp(test, self.display(name), astStr(name.id)) + expr = ast.IfExp(test, self.display(name), ast.Constant(name.id)) return name, self.explanation_param(expr) - def visit_BoolOp(self, boolop: ast.BoolOp) -> Tuple[ast.Name, str]: + def visit_BoolOp(self, boolop: ast.BoolOp) -> tuple[ast.Name, str]: res_var = self.variable() expl_list = self.assign(ast.List([], ast.Load())) app = ast.Attribute(expl_list, "append", ast.Load()) @@ -989,14 +1020,14 @@ def visit_BoolOp(self, boolop: ast.BoolOp) -> Tuple[ast.Name, str]: # Process each operand, short-circuiting if needed. for i, v in enumerate(boolop.values): if i: - fail_inner: List[ast.stmt] = [] + fail_inner: list[ast.stmt] = [] # cond is set in a prior loop iteration below - self.expl_stmts.append(ast.If(cond, fail_inner, [])) # noqa + self.expl_stmts.append(ast.If(cond, fail_inner, [])) # noqa: F821 self.expl_stmts = fail_inner - # Check if the left operand is a namedExpr and the value has already been visited + # Check if the left operand is a ast.NamedExpr and the value has already been visited if ( isinstance(v, ast.Compare) - and isinstance(v.left, namedExpr) + and isinstance(v.left, ast.NamedExpr) and v.left.target.id in [ ast_expr.id @@ -1005,62 +1036,61 @@ def visit_BoolOp(self, boolop: ast.BoolOp) -> Tuple[ast.Name, str]: ] ): pytest_temp = self.variable() - self.variables_overwrite[ - v.left.target.id - ] = v.left # type:ignore[assignment] + self.variables_overwrite[self.scope][v.left.target.id] = v.left # type:ignore[assignment] v.left.target.id = pytest_temp self.push_format_context() res, expl = self.visit(v) body.append(ast.Assign([ast.Name(res_var, ast.Store())], res)) - expl_format = self.pop_format_context(astStr(expl)) + expl_format = self.pop_format_context(ast.Constant(expl)) call = ast.Call(app, [expl_format], []) self.expl_stmts.append(ast.Expr(call)) if i < levels: cond: ast.expr = res if is_or: cond = ast.UnaryOp(ast.Not(), cond) - inner: List[ast.stmt] = [] + inner: list[ast.stmt] = [] self.statements.append(ast.If(cond, inner, [])) self.statements = body = inner self.statements = save self.expl_stmts = fail_save - expl_template = self.helper("_format_boolop", expl_list, astNum(is_or)) + expl_template = self.helper("_format_boolop", expl_list, ast.Constant(is_or)) expl = self.pop_format_context(expl_template) return ast.Name(res_var, ast.Load()), self.explanation_param(expl) - def visit_UnaryOp(self, unary: ast.UnaryOp) -> Tuple[ast.Name, str]: + def visit_UnaryOp(self, unary: ast.UnaryOp) -> tuple[ast.Name, str]: pattern = UNARY_MAP[unary.op.__class__] operand_res, operand_expl = self.visit(unary.operand) - res = self.assign(ast.UnaryOp(unary.op, operand_res)) + res = self.assign(ast.copy_location(ast.UnaryOp(unary.op, operand_res), unary)) return res, pattern % (operand_expl,) - def visit_BinOp(self, binop: ast.BinOp) -> Tuple[ast.Name, str]: + def visit_BinOp(self, binop: ast.BinOp) -> tuple[ast.Name, str]: symbol = BINOP_MAP[binop.op.__class__] left_expr, left_expl = self.visit(binop.left) right_expr, right_expl = self.visit(binop.right) explanation = f"({left_expl} {symbol} {right_expl})" - res = self.assign(ast.BinOp(left_expr, binop.op, right_expr)) + res = self.assign( + ast.copy_location(ast.BinOp(left_expr, binop.op, right_expr), binop) + ) return res, explanation - def visit_Call(self, call: ast.Call) -> Tuple[ast.Name, str]: + def visit_Call(self, call: ast.Call) -> tuple[ast.Name, str]: new_func, func_expl = self.visit(call.func) arg_expls = [] new_args = [] new_kwargs = [] for arg in call.args: - if isinstance(arg, ast.Name) and arg.id in self.variables_overwrite: - arg = self.variables_overwrite[arg.id] # type:ignore[assignment] + if isinstance(arg, ast.Name) and arg.id in self.variables_overwrite.get( + self.scope, {} + ): + arg = self.variables_overwrite[self.scope][arg.id] # type:ignore[assignment] res, expl = self.visit(arg) arg_expls.append(expl) new_args.append(res) for keyword in call.keywords: - if ( - isinstance(keyword.value, ast.Name) - and keyword.value.id in self.variables_overwrite - ): - keyword.value = self.variables_overwrite[ - keyword.value.id - ] # type:ignore[assignment] + if isinstance( + keyword.value, ast.Name + ) and keyword.value.id in self.variables_overwrite.get(self.scope, {}): + keyword.value = self.variables_overwrite[self.scope][keyword.value.id] # type:ignore[assignment] res, expl = self.visit(keyword.value) new_kwargs.append(ast.keyword(keyword.arg, res)) if keyword.arg: @@ -1069,68 +1099,66 @@ def visit_Call(self, call: ast.Call) -> Tuple[ast.Name, str]: arg_expls.append("**" + expl) expl = "{}({})".format(func_expl, ", ".join(arg_expls)) - new_call = ast.Call(new_func, new_args, new_kwargs) + new_call = ast.copy_location(ast.Call(new_func, new_args, new_kwargs), call) res = self.assign(new_call) res_expl = self.explanation_param(self.display(res)) outer_expl = f"{res_expl}\n{{{res_expl} = {expl}\n}}" return res, outer_expl - def visit_Starred(self, starred: ast.Starred) -> Tuple[ast.Starred, str]: + def visit_Starred(self, starred: ast.Starred) -> tuple[ast.Starred, str]: # A Starred node can appear in a function call. res, expl = self.visit(starred.value) new_starred = ast.Starred(res, starred.ctx) return new_starred, "*" + expl - def visit_Attribute(self, attr: ast.Attribute) -> Tuple[ast.Name, str]: + def visit_Attribute(self, attr: ast.Attribute) -> tuple[ast.Name, str]: if not isinstance(attr.ctx, ast.Load): return self.generic_visit(attr) value, value_expl = self.visit(attr.value) - res = self.assign(ast.Attribute(value, attr.attr, ast.Load())) + res = self.assign( + ast.copy_location(ast.Attribute(value, attr.attr, ast.Load()), attr) + ) res_expl = self.explanation_param(self.display(res)) pat = "%s\n{%s = %s.%s\n}" expl = pat % (res_expl, res_expl, value_expl, attr.attr) return res, expl - def visit_Compare(self, comp: ast.Compare) -> Tuple[ast.expr, str]: + def visit_Compare(self, comp: ast.Compare) -> tuple[ast.expr, str]: self.push_format_context() # We first check if we have overwritten a variable in the previous assert - if isinstance(comp.left, ast.Name) and comp.left.id in self.variables_overwrite: - comp.left = self.variables_overwrite[ - comp.left.id - ] # type:ignore[assignment] - if isinstance(comp.left, namedExpr): - self.variables_overwrite[ - comp.left.target.id - ] = comp.left # type:ignore[assignment] + if isinstance( + comp.left, ast.Name + ) and comp.left.id in self.variables_overwrite.get(self.scope, {}): + comp.left = self.variables_overwrite[self.scope][comp.left.id] # type:ignore[assignment] + if isinstance(comp.left, ast.NamedExpr): + self.variables_overwrite[self.scope][comp.left.target.id] = comp.left # type:ignore[assignment] left_res, left_expl = self.visit(comp.left) if isinstance(comp.left, (ast.Compare, ast.BoolOp)): left_expl = f"({left_expl})" res_variables = [self.variable() for i in range(len(comp.ops))] - load_names = [ast.Name(v, ast.Load()) for v in res_variables] + load_names: list[ast.expr] = [ast.Name(v, ast.Load()) for v in res_variables] store_names = [ast.Name(v, ast.Store()) for v in res_variables] it = zip(range(len(comp.ops)), comp.ops, comp.comparators) - expls = [] - syms = [] + expls: list[ast.expr] = [] + syms: list[ast.expr] = [] results = [left_res] for i, op, next_operand in it: if ( - isinstance(next_operand, namedExpr) + isinstance(next_operand, ast.NamedExpr) and isinstance(left_res, ast.Name) and next_operand.target.id == left_res.id ): next_operand.target.id = self.variable() - self.variables_overwrite[ - left_res.id - ] = next_operand # type:ignore[assignment] + self.variables_overwrite[self.scope][left_res.id] = next_operand # type:ignore[assignment] next_res, next_expl = self.visit(next_operand) if isinstance(next_operand, (ast.Compare, ast.BoolOp)): next_expl = f"({next_expl})" results.append(next_res) sym = BINOP_MAP[op.__class__] - syms.append(astStr(sym)) + syms.append(ast.Constant(sym)) expl = f"{left_expl} {sym} {next_expl}" - expls.append(astStr(expl)) - res_expr = ast.Compare(left_res, [op], [next_res]) + expls.append(ast.Constant(expl)) + res_expr = ast.copy_location(ast.Compare(left_res, [op], [next_res]), comp) self.statements.append(ast.Assign([store_names[i]], res_expr)) left_res, left_expl = next_res, next_expl # Use pytest.assertion.util._reprcompare if that's available. @@ -1165,7 +1193,10 @@ def try_makedirs(cache_dir: Path) -> bool: return False except OSError as e: # as of now, EROFS doesn't have an equivalent OSError-subclass - if e.errno == errno.EROFS: + # + # squashfuse_ll returns ENOSYS "OSError: [Errno 38] Function not + # implemented" for a read-only error + if e.errno in {errno.EROFS, errno.ENOSYS}: return False raise return True @@ -1173,7 +1204,7 @@ def try_makedirs(cache_dir: Path) -> bool: def get_cache_dir(file_path: Path) -> Path: """Return the cache directory to write .pyc files for the given .py file path.""" - if sys.version_info >= (3, 8) and sys.pycache_prefix: + if sys.pycache_prefix: # given: # prefix = '/tmp/pycs' # path = '/home/user/proj/test_app.py' diff --git a/src/_pytest/assertion/truncate.py b/src/_pytest/assertion/truncate.py index dfd6f65d281..4854a62ba6b 100644 --- a/src/_pytest/assertion/truncate.py +++ b/src/_pytest/assertion/truncate.py @@ -1,51 +1,65 @@ """Utilities for truncating assertion output. Current default behaviour is to truncate assertion explanations at -~8 terminal lines, unless running in "-vv" mode or running on CI. +terminal lines, unless running with an assertions verbosity level of at least 2 or running on CI. """ -from typing import List -from typing import Optional + +from __future__ import annotations from _pytest.assertion import util +from _pytest.config import Config from _pytest.nodes import Item DEFAULT_MAX_LINES = 8 -DEFAULT_MAX_CHARS = 8 * 80 +DEFAULT_MAX_CHARS = DEFAULT_MAX_LINES * 80 USAGE_MSG = "use '-vv' to show" -def truncate_if_required( - explanation: List[str], item: Item, max_length: Optional[int] = None -) -> List[str]: +def truncate_if_required(explanation: list[str], item: Item) -> list[str]: """Truncate this assertion explanation if the given test item is eligible.""" - if _should_truncate_item(item): - return _truncate_explanation(explanation) + should_truncate, max_lines, max_chars = _get_truncation_parameters(item) + if should_truncate: + return _truncate_explanation( + explanation, + max_lines=max_lines, + max_chars=max_chars, + ) return explanation -def _should_truncate_item(item: Item) -> bool: - """Whether or not this test item is eligible for truncation.""" - verbose = item.config.option.verbose - return verbose < 2 and not util.running_on_ci() +def _get_truncation_parameters(item: Item) -> tuple[bool, int, int]: + """Return the truncation parameters related to the given item, as (should truncate, max lines, max chars).""" + # We do not need to truncate if one of conditions is met: + # 1. Verbosity level is 2 or more; + # 2. Test is being run in CI environment; + # 3. Both truncation_limit_lines and truncation_limit_chars + # .ini parameters are set to 0 explicitly. + max_lines = item.config.getini("truncation_limit_lines") + max_lines = int(max_lines if max_lines is not None else DEFAULT_MAX_LINES) + + max_chars = item.config.getini("truncation_limit_chars") + max_chars = int(max_chars if max_chars is not None else DEFAULT_MAX_CHARS) + + verbose = item.config.get_verbosity(Config.VERBOSITY_ASSERTIONS) + + should_truncate = verbose < 2 and not util.running_on_ci() + should_truncate = should_truncate and (max_lines > 0 or max_chars > 0) + + return should_truncate, max_lines, max_chars def _truncate_explanation( - input_lines: List[str], - max_lines: Optional[int] = None, - max_chars: Optional[int] = None, -) -> List[str]: + input_lines: list[str], + max_lines: int, + max_chars: int, +) -> list[str]: """Truncate given list of strings that makes up the assertion explanation. - Truncates to either 8 lines, or 640 characters - whichever the input reaches + Truncates to either max_lines, or max_chars - whichever the input reaches first, taking the truncation explanation into account. The remaining lines will be replaced by a usage message. """ - if max_lines is None: - max_lines = DEFAULT_MAX_LINES - if max_chars is None: - max_chars = DEFAULT_MAX_CHARS - # Check if truncation required input_char_count = len("".join(input_lines)) # The length of the truncation explanation depends on the number of lines @@ -70,16 +84,23 @@ def _truncate_explanation( ): return input_lines # Truncate first to max_lines, and then truncate to max_chars if necessary - truncated_explanation = input_lines[:max_lines] + if max_lines > 0: + truncated_explanation = input_lines[:max_lines] + else: + truncated_explanation = input_lines truncated_char = True # We reevaluate the need to truncate chars following removal of some lines - if len("".join(truncated_explanation)) > tolerable_max_chars: + if len("".join(truncated_explanation)) > tolerable_max_chars and max_chars > 0: truncated_explanation = _truncate_by_char_count( truncated_explanation, max_chars ) else: truncated_char = False + if truncated_explanation == input_lines: + # No truncation happened, so we do not need to add any explanations + return truncated_explanation + truncated_line_count = len(input_lines) - len(truncated_explanation) if truncated_explanation[-1]: # Add ellipsis and take into account part-truncated final line @@ -90,14 +111,15 @@ def _truncate_explanation( else: # Add proper ellipsis when we were able to fit a full line exactly truncated_explanation[-1] = "..." - return truncated_explanation + [ + return [ + *truncated_explanation, "", f"...Full output truncated ({truncated_line_count} line" f"{'' if truncated_line_count == 1 else 's'} hidden), {USAGE_MSG}", ] -def _truncate_by_char_count(input_lines: List[str], max_chars: int) -> List[str]: +def _truncate_by_char_count(input_lines: list[str], max_chars: int) -> list[str]: # Find point at which input length exceeds total allowed length iterated_char_count = 0 for iterated_index, input_line in enumerate(input_lines): diff --git a/src/_pytest/assertion/util.py b/src/_pytest/assertion/util.py index fc5dfdbd5ba..c545e6cd20c 100644 --- a/src/_pytest/assertion/util.py +++ b/src/_pytest/assertion/util.py @@ -1,36 +1,54 @@ +# mypy: allow-untyped-defs """Utilities for assertion debugging.""" + +from __future__ import annotations + import collections.abc +from collections.abc import Callable +from collections.abc import Iterable +from collections.abc import Mapping +from collections.abc import Sequence +from collections.abc import Set as AbstractSet import os import pprint -from typing import AbstractSet from typing import Any -from typing import Callable -from typing import Iterable -from typing import List -from typing import Mapping -from typing import Optional -from typing import Sequence +from typing import Literal +from typing import Protocol from unicodedata import normalize -import _pytest._code from _pytest import outcomes -from _pytest._io.saferepr import _pformat_dispatch +import _pytest._code +from _pytest._io.pprint import PrettyPrinter from _pytest._io.saferepr import saferepr from _pytest._io.saferepr import saferepr_unlimited from _pytest.config import Config + # The _reprcompare attribute on the util module is used by the new assertion # interpretation code and assertion rewriter to detect this plugin was # loaded and in turn call the hooks defined here as part of the # DebugInterpreter. -_reprcompare: Optional[Callable[[str, object, object], Optional[str]]] = None +_reprcompare: Callable[[str, object, object], str | None] | None = None # Works similarly as _reprcompare attribute. Is populated with the hook call # when pytest_runtest_setup is called. -_assertion_pass: Optional[Callable[[int, str, str], None]] = None +_assertion_pass: Callable[[int, str, str], None] | None = None # Config object which is assigned during pytest_runtest_protocol. -_config: Optional[Config] = None +_config: Config | None = None + + +class _HighlightFunc(Protocol): + def __call__(self, source: str, lexer: Literal["diff", "python"] = "python") -> str: + """Apply highlighting to the given source.""" + + +def dummy_highlighter(source: str, lexer: Literal["diff", "python"] = "python") -> str: + """Dummy highlighter that returns the text unprocessed. + + Needed for _notin_text, as the diff gets post-processed to only show the "+" part. + """ + return source def format_explanation(explanation: str) -> str: @@ -48,7 +66,7 @@ def format_explanation(explanation: str) -> str: return "\n".join(result) -def _split_explanation(explanation: str) -> List[str]: +def _split_explanation(explanation: str) -> list[str]: r"""Return a list of individual lines in the explanation. This will return a list of lines split on '\n{', '\n}' and '\n~'. @@ -65,7 +83,7 @@ def _split_explanation(explanation: str) -> List[str]: return lines -def _format_lines(lines: Sequence[str]) -> List[str]: +def _format_lines(lines: Sequence[str]) -> list[str]: """Format the individual lines. This will replace the '{', '}' and '~' characters of our mini formatting @@ -132,7 +150,7 @@ def isiterable(obj: Any) -> bool: try: iter(obj) return not istext(obj) - except TypeError: + except Exception: return False @@ -151,7 +169,7 @@ def has_default_eq( code_filename = obj.__eq__.__code__.co_filename if isattrs(obj): - return "attrs generated eq" in code_filename + return "attrs generated " in code_filename return code_filename == "" # data class return True @@ -159,9 +177,9 @@ def has_default_eq( def assertrepr_compare( config, op: str, left: Any, right: Any, use_ascii: bool = False -) -> Optional[List[str]]: +) -> list[str] | None: """Return specialised explanations for some operators/operands.""" - verbose = config.getoption("verbose") + verbose = config.get_verbosity(Config.VERBOSITY_ASSERTIONS) # Strings which normalize equal are often hard to distinguish when printed; use ascii() to make this easier. # See issue #3246. @@ -185,34 +203,54 @@ def assertrepr_compare( right_repr = saferepr(right, maxsize=maxsize, use_ascii=use_ascii) summary = f"{left_repr} {op} {right_repr}" + highlighter = config.get_terminal_writer()._highlight explanation = None try: if op == "==": - explanation = _compare_eq_any(left, right, verbose) + explanation = _compare_eq_any(left, right, highlighter, verbose) elif op == "not in": if istext(left) and istext(right): explanation = _notin_text(left, right, verbose) + elif op == "!=": + if isset(left) and isset(right): + explanation = ["Both sets are equal"] + elif op == ">=": + if isset(left) and isset(right): + explanation = _compare_gte_set(left, right, highlighter, verbose) + elif op == "<=": + if isset(left) and isset(right): + explanation = _compare_lte_set(left, right, highlighter, verbose) + elif op == ">": + if isset(left) and isset(right): + explanation = _compare_gt_set(left, right, highlighter, verbose) + elif op == "<": + if isset(left) and isset(right): + explanation = _compare_lt_set(left, right, highlighter, verbose) + except outcomes.Exit: raise except Exception: + repr_crash = _pytest._code.ExceptionInfo.from_current()._getreprcrash() explanation = [ - "(pytest_assertion plugin: representation of details failed: {}.".format( - _pytest._code.ExceptionInfo.from_current()._getreprcrash() - ), + f"(pytest_assertion plugin: representation of details failed: {repr_crash}.", " Probably an object has a faulty __repr__.)", ] if not explanation: return None - return [summary] + explanation + if explanation[0] != "": + explanation = ["", *explanation] + return [summary, *explanation] -def _compare_eq_any(left: Any, right: Any, verbose: int = 0) -> List[str]: +def _compare_eq_any( + left: Any, right: Any, highlighter: _HighlightFunc, verbose: int = 0 +) -> list[str]: explanation = [] if istext(left) and istext(right): - explanation = _diff_text(left, right, verbose) + explanation = _diff_text(left, right, highlighter, verbose) else: from _pytest.python_api import ApproxBase @@ -222,29 +260,31 @@ def _compare_eq_any(left: Any, right: Any, verbose: int = 0) -> List[str]: other_side = right if isinstance(left, ApproxBase) else left explanation = approx_side._repr_compare(other_side) - elif type(left) == type(right) and ( + elif type(left) is type(right) and ( isdatacls(left) or isattrs(left) or isnamedtuple(left) ): # Note: unlike dataclasses/attrs, namedtuples compare only the # field values, not the type or field names. But this branch # intentionally only handles the same-type case, which was often # used in older code bases before dataclasses/attrs were available. - explanation = _compare_eq_cls(left, right, verbose) + explanation = _compare_eq_cls(left, right, highlighter, verbose) elif issequence(left) and issequence(right): - explanation = _compare_eq_sequence(left, right, verbose) + explanation = _compare_eq_sequence(left, right, highlighter, verbose) elif isset(left) and isset(right): - explanation = _compare_eq_set(left, right, verbose) + explanation = _compare_eq_set(left, right, highlighter, verbose) elif isdict(left) and isdict(right): - explanation = _compare_eq_dict(left, right, verbose) + explanation = _compare_eq_dict(left, right, highlighter, verbose) if isiterable(left) and isiterable(right): - expl = _compare_eq_iterable(left, right, verbose) + expl = _compare_eq_iterable(left, right, highlighter, verbose) explanation.extend(expl) return explanation -def _diff_text(left: str, right: str, verbose: int = 0) -> List[str]: +def _diff_text( + left: str, right: str, highlighter: _HighlightFunc, verbose: int = 0 +) -> list[str]: """Return the explanation for the diff between text. Unless --verbose is used this will skip leading and trailing @@ -252,7 +292,7 @@ def _diff_text(left: str, right: str, verbose: int = 0) -> List[str]: """ from difflib import ndiff - explanation: List[str] = [] + explanation: list[str] = [] if verbose < 1: i = 0 # just in case left or right has zero length @@ -262,7 +302,7 @@ def _diff_text(left: str, right: str, verbose: int = 0) -> List[str]: if i > 42: i -= 10 # Provide some context explanation = [ - "Skipping %s identical leading characters in diff, use -v to show" % i + f"Skipping {i} identical leading characters in diff, use -v to show" ] left = left[i:] right = right[i:] @@ -273,8 +313,8 @@ def _diff_text(left: str, right: str, verbose: int = 0) -> List[str]: if i > 42: i -= 10 # Provide some context explanation += [ - "Skipping {} identical trailing " - "characters in diff, use -v to show".format(i) + f"Skipping {i} identical trailing " + "characters in diff, use -v to show" ] left = left[:-i] right = right[:-i] @@ -285,61 +325,55 @@ def _diff_text(left: str, right: str, verbose: int = 0) -> List[str]: explanation += ["Strings contain only whitespace, escaping them using repr()"] # "right" is the expected base against which we compare "left", # see https://github.com/pytest-dev/pytest/issues/3333 - explanation += [ - line.strip("\n") - for line in ndiff(right.splitlines(keepends), left.splitlines(keepends)) - ] + explanation.extend( + highlighter( + "\n".join( + line.strip("\n") + for line in ndiff(right.splitlines(keepends), left.splitlines(keepends)) + ), + lexer="diff", + ).splitlines() + ) return explanation -def _surrounding_parens_on_own_lines(lines: List[str]) -> None: - """Move opening/closing parenthesis/bracket to own lines.""" - opening = lines[0][:1] - if opening in ["(", "[", "{"]: - lines[0] = " " + lines[0][1:] - lines[:] = [opening] + lines - closing = lines[-1][-1:] - if closing in [")", "]", "}"]: - lines[-1] = lines[-1][:-1] + "," - lines[:] = lines + [closing] - - def _compare_eq_iterable( - left: Iterable[Any], right: Iterable[Any], verbose: int = 0 -) -> List[str]: + left: Iterable[Any], + right: Iterable[Any], + highlighter: _HighlightFunc, + verbose: int = 0, +) -> list[str]: if verbose <= 0 and not running_on_ci(): return ["Use -v to get more diff"] # dynamic import to speedup pytest import difflib - left_formatting = pprint.pformat(left).splitlines() - right_formatting = pprint.pformat(right).splitlines() - - # Re-format for different output lengths. - lines_left = len(left_formatting) - lines_right = len(right_formatting) - if lines_left != lines_right: - left_formatting = _pformat_dispatch(left).splitlines() - right_formatting = _pformat_dispatch(right).splitlines() + left_formatting = PrettyPrinter().pformat(left).splitlines() + right_formatting = PrettyPrinter().pformat(right).splitlines() - if lines_left > 1 or lines_right > 1: - _surrounding_parens_on_own_lines(left_formatting) - _surrounding_parens_on_own_lines(right_formatting) - - explanation = ["Full diff:"] + explanation = ["", "Full diff:"] # "right" is the expected base against which we compare "left", # see https://github.com/pytest-dev/pytest/issues/3333 explanation.extend( - line.rstrip() for line in difflib.ndiff(right_formatting, left_formatting) + highlighter( + "\n".join( + line.rstrip() + for line in difflib.ndiff(right_formatting, left_formatting) + ), + lexer="diff", + ).splitlines() ) return explanation def _compare_eq_sequence( - left: Sequence[Any], right: Sequence[Any], verbose: int = 0 -) -> List[str]: + left: Sequence[Any], + right: Sequence[Any], + highlighter: _HighlightFunc, + verbose: int = 0, +) -> list[str]: comparing_bytes = isinstance(left, bytes) and isinstance(right, bytes) - explanation: List[str] = [] + explanation: list[str] = [] len_left = len(left) len_right = len(right) for i in range(min(len_left, len_right)): @@ -359,7 +393,10 @@ def _compare_eq_sequence( left_value = left[i] right_value = right[i] - explanation += [f"At index {i} diff: {left_value!r} != {right_value!r}"] + explanation.append( + f"At index {i} diff:" + f" {highlighter(repr(left_value))} != {highlighter(repr(right_value))}" + ) break if comparing_bytes: @@ -379,74 +416,134 @@ def _compare_eq_sequence( extra = saferepr(right[len_left]) if len_diff == 1: - explanation += [f"{dir_with_more} contains one more item: {extra}"] + explanation += [ + f"{dir_with_more} contains one more item: {highlighter(extra)}" + ] else: explanation += [ - "%s contains %d more items, first extra item: %s" - % (dir_with_more, len_diff, extra) + f"{dir_with_more} contains {len_diff} more items, first extra item: {highlighter(extra)}" ] return explanation def _compare_eq_set( - left: AbstractSet[Any], right: AbstractSet[Any], verbose: int = 0 -) -> List[str]: + left: AbstractSet[Any], + right: AbstractSet[Any], + highlighter: _HighlightFunc, + verbose: int = 0, +) -> list[str]: explanation = [] - diff_left = left - right - diff_right = right - left - if diff_left: - explanation.append("Extra items in the left set:") - for item in diff_left: - explanation.append(saferepr(item)) - if diff_right: - explanation.append("Extra items in the right set:") - for item in diff_right: - explanation.append(saferepr(item)) + explanation.extend(_set_one_sided_diff("left", left, right, highlighter)) + explanation.extend(_set_one_sided_diff("right", right, left, highlighter)) + return explanation + + +def _compare_gt_set( + left: AbstractSet[Any], + right: AbstractSet[Any], + highlighter: _HighlightFunc, + verbose: int = 0, +) -> list[str]: + explanation = _compare_gte_set(left, right, highlighter) + if not explanation: + return ["Both sets are equal"] + return explanation + + +def _compare_lt_set( + left: AbstractSet[Any], + right: AbstractSet[Any], + highlighter: _HighlightFunc, + verbose: int = 0, +) -> list[str]: + explanation = _compare_lte_set(left, right, highlighter) + if not explanation: + return ["Both sets are equal"] + return explanation + + +def _compare_gte_set( + left: AbstractSet[Any], + right: AbstractSet[Any], + highlighter: _HighlightFunc, + verbose: int = 0, +) -> list[str]: + return _set_one_sided_diff("right", right, left, highlighter) + + +def _compare_lte_set( + left: AbstractSet[Any], + right: AbstractSet[Any], + highlighter: _HighlightFunc, + verbose: int = 0, +) -> list[str]: + return _set_one_sided_diff("left", left, right, highlighter) + + +def _set_one_sided_diff( + posn: str, + set1: AbstractSet[Any], + set2: AbstractSet[Any], + highlighter: _HighlightFunc, +) -> list[str]: + explanation = [] + diff = set1 - set2 + if diff: + explanation.append(f"Extra items in the {posn} set:") + for item in diff: + explanation.append(highlighter(saferepr(item))) return explanation def _compare_eq_dict( - left: Mapping[Any, Any], right: Mapping[Any, Any], verbose: int = 0 -) -> List[str]: - explanation: List[str] = [] + left: Mapping[Any, Any], + right: Mapping[Any, Any], + highlighter: _HighlightFunc, + verbose: int = 0, +) -> list[str]: + explanation: list[str] = [] set_left = set(left) set_right = set(right) common = set_left.intersection(set_right) same = {k: left[k] for k in common if left[k] == right[k]} if same and verbose < 2: - explanation += ["Omitting %s identical items, use -vv to show" % len(same)] + explanation += [f"Omitting {len(same)} identical items, use -vv to show"] elif same: explanation += ["Common items:"] - explanation += pprint.pformat(same).splitlines() + explanation += highlighter(pprint.pformat(same)).splitlines() diff = {k for k in common if left[k] != right[k]} if diff: explanation += ["Differing items:"] for k in diff: - explanation += [saferepr({k: left[k]}) + " != " + saferepr({k: right[k]})] + explanation += [ + highlighter(saferepr({k: left[k]})) + + " != " + + highlighter(saferepr({k: right[k]})) + ] extra_left = set_left - set_right len_extra_left = len(extra_left) if len_extra_left: explanation.append( - "Left contains %d more item%s:" - % (len_extra_left, "" if len_extra_left == 1 else "s") + f"Left contains {len_extra_left} more item{'' if len_extra_left == 1 else 's'}:" ) explanation.extend( - pprint.pformat({k: left[k] for k in extra_left}).splitlines() + highlighter(pprint.pformat({k: left[k] for k in extra_left})).splitlines() ) extra_right = set_right - set_left len_extra_right = len(extra_right) if len_extra_right: explanation.append( - "Right contains %d more item%s:" - % (len_extra_right, "" if len_extra_right == 1 else "s") + f"Right contains {len_extra_right} more item{'' if len_extra_right == 1 else 's'}:" ) explanation.extend( - pprint.pformat({k: right[k] for k in extra_right}).splitlines() + highlighter(pprint.pformat({k: right[k] for k in extra_right})).splitlines() ) return explanation -def _compare_eq_cls(left: Any, right: Any, verbose: int) -> List[str]: +def _compare_eq_cls( + left: Any, right: Any, highlighter: _HighlightFunc, verbose: int +) -> list[str]: if not has_default_eq(left): return [] if isdatacls(left): @@ -475,35 +572,37 @@ def _compare_eq_cls(left: Any, right: Any, verbose: int) -> List[str]: if same or diff: explanation += [""] if same and verbose < 2: - explanation.append("Omitting %s identical items, use -vv to show" % len(same)) + explanation.append(f"Omitting {len(same)} identical items, use -vv to show") elif same: explanation += ["Matching attributes:"] - explanation += pprint.pformat(same).splitlines() + explanation += highlighter(pprint.pformat(same)).splitlines() if diff: explanation += ["Differing attributes:"] - explanation += pprint.pformat(diff).splitlines() + explanation += highlighter(pprint.pformat(diff)).splitlines() for field in diff: field_left = getattr(left, field) field_right = getattr(right, field) explanation += [ "", - "Drill down into differing attribute %s:" % field, - ("%s%s: %r != %r") % (indent, field, field_left, field_right), + f"Drill down into differing attribute {field}:", + f"{indent}{field}: {highlighter(repr(field_left))} != {highlighter(repr(field_right))}", ] explanation += [ indent + line - for line in _compare_eq_any(field_left, field_right, verbose) + for line in _compare_eq_any( + field_left, field_right, highlighter, verbose + ) ] return explanation -def _notin_text(term: str, text: str, verbose: int = 0) -> List[str]: +def _notin_text(term: str, text: str, verbose: int = 0) -> list[str]: index = text.find(term) head = text[:index] tail = text[index + len(term) :] correct_text = head + tail - diff = _diff_text(text, correct_text, verbose) - newdiff = ["%s is contained here:" % saferepr(term, maxsize=42)] + diff = _diff_text(text, correct_text, dummy_highlighter, verbose) + newdiff = [f"{saferepr(term, maxsize=42)} is contained here:"] for line in diff: if line.startswith("Skipping"): continue diff --git a/src/_pytest/cacheprovider.py b/src/_pytest/cacheprovider.py index 855716d8199..dea60109b51 100755 --- a/src/_pytest/cacheprovider.py +++ b/src/_pytest/cacheprovider.py @@ -1,24 +1,25 @@ +# mypy: allow-untyped-defs """Implementation of the cache provider.""" + # This plugin was not named "cache" to avoid conflicts with the external # pytest-cache version. +from __future__ import annotations + +from collections.abc import Generator +from collections.abc import Iterable import dataclasses +import errno import json import os from pathlib import Path -from typing import Dict -from typing import Generator -from typing import Iterable -from typing import List -from typing import Optional -from typing import Set -from typing import Union +import tempfile +from typing import final from .pathlib import resolve_from_str from .pathlib import rm_rf from .reports import CollectReport from _pytest import nodes from _pytest._io import TerminalWriter -from _pytest.compat import final from _pytest.config import Config from _pytest.config import ExitCode from _pytest.config import hookimpl @@ -27,10 +28,11 @@ from _pytest.fixtures import fixture from _pytest.fixtures import FixtureRequest from _pytest.main import Session +from _pytest.nodes import Directory from _pytest.nodes import File -from _pytest.python import Package from _pytest.reports import TestReport + README_CONTENT = """\ # pytest cache directory # @@ -72,7 +74,7 @@ def __init__( self._config = config @classmethod - def for_config(cls, config: Config, *, _ispytest: bool = False) -> "Cache": + def for_config(cls, config: Config, *, _ispytest: bool = False) -> Cache: """Create the Cache instance for a Config. :meta private: @@ -111,6 +113,7 @@ def warn(self, fmt: str, *, _ispytest: bool = False, **args: object) -> None: """ check_ispytest(_ispytest) import warnings + from _pytest.warning_types import PytestCacheWarning warnings.warn( @@ -119,6 +122,10 @@ def warn(self, fmt: str, *, _ispytest: bool = False, **args: object) -> None: stacklevel=3, ) + def _mkdir(self, path: Path) -> None: + self._ensure_cache_dir_and_supporting_files() + path.mkdir(exist_ok=True, parents=True) + def mkdir(self, name: str) -> Path: """Return a directory path object with the given name. @@ -137,7 +144,7 @@ def mkdir(self, name: str) -> Path: if len(path.parts) > 1: raise ValueError("name is not allowed to contain path separators") res = self._cachedir.joinpath(self._CACHE_PREFIX_DIRS, path) - res.mkdir(exist_ok=True, parents=True) + self._mkdir(res) return res def _getvaluepath(self, key: str) -> Path: @@ -174,19 +181,13 @@ def set(self, key: str, value: object) -> None: """ path = self._getvaluepath(key) try: - if path.parent.is_dir(): - cache_dir_exists_already = True - else: - cache_dir_exists_already = self._cachedir.exists() - path.parent.mkdir(exist_ok=True, parents=True) + self._mkdir(path.parent) except OSError as exc: self.warn( f"could not create cache path {path}: {exc}", _ispytest=True, ) return - if not cache_dir_exists_already: - self._ensure_supporting_files() data = json.dumps(value, ensure_ascii=False, indent=2) try: f = path.open("w", encoding="UTF-8") @@ -199,60 +200,85 @@ def set(self, key: str, value: object) -> None: with f: f.write(data) - def _ensure_supporting_files(self) -> None: - """Create supporting files in the cache dir that are not really part of the cache.""" - readme_path = self._cachedir / "README.md" - readme_path.write_text(README_CONTENT, encoding="UTF-8") - - gitignore_path = self._cachedir.joinpath(".gitignore") - msg = "# Created by pytest automatically.\n*\n" - gitignore_path.write_text(msg, encoding="UTF-8") + def _ensure_cache_dir_and_supporting_files(self) -> None: + """Create the cache dir and its supporting files.""" + if self._cachedir.is_dir(): + return - cachedir_tag_path = self._cachedir.joinpath("CACHEDIR.TAG") - cachedir_tag_path.write_bytes(CACHEDIR_TAG_CONTENT) + self._cachedir.parent.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory( + prefix="pytest-cache-files-", + dir=self._cachedir.parent, + ) as newpath: + path = Path(newpath) + + # Reset permissions to the default, see #12308. + # Note: there's no way to get the current umask atomically, eek. + umask = os.umask(0o022) + os.umask(umask) + path.chmod(0o777 - umask) + + with open(path.joinpath("README.md"), "x", encoding="UTF-8") as f: + f.write(README_CONTENT) + with open(path.joinpath(".gitignore"), "x", encoding="UTF-8") as f: + f.write("# Created by pytest automatically.\n*\n") + with open(path.joinpath("CACHEDIR.TAG"), "xb") as f: + f.write(CACHEDIR_TAG_CONTENT) + + try: + path.rename(self._cachedir) + except OSError as e: + # If 2 concurrent pytests both race to the rename, the loser + # gets "Directory not empty" from the rename. In this case, + # everything is handled so just continue (while letting the + # temporary directory be cleaned up). + # On Windows, the error is a FileExistsError which translates to EEXIST. + if e.errno not in (errno.ENOTEMPTY, errno.EEXIST): + raise + else: + # Create a directory in place of the one we just moved so that + # `TemporaryDirectory`'s cleanup doesn't complain. + # + # TODO: pass ignore_cleanup_errors=True when we no longer support python < 3.10. + # See https://github.com/python/cpython/issues/74168. Note that passing + # delete=False would do the wrong thing in case of errors and isn't supported + # until python 3.12. + path.mkdir() class LFPluginCollWrapper: - def __init__(self, lfplugin: "LFPlugin") -> None: + def __init__(self, lfplugin: LFPlugin) -> None: self.lfplugin = lfplugin self._collected_at_least_one_failure = False - @hookimpl(hookwrapper=True) - def pytest_make_collect_report(self, collector: nodes.Collector): - if isinstance(collector, (Session, Package)): - out = yield - res: CollectReport = out.get_result() - + @hookimpl(wrapper=True) + def pytest_make_collect_report( + self, collector: nodes.Collector + ) -> Generator[None, CollectReport, CollectReport]: + res = yield + if isinstance(collector, (Session, Directory)): # Sort any lf-paths to the beginning. lf_paths = self.lfplugin._last_failed_paths - # Use stable sort to priorize last failed. - def sort_key(node: Union[nodes.Item, nodes.Collector]) -> bool: - # Package.path is the __init__.py file, we need the directory. - if isinstance(node, Package): - path = node.path.parent - else: - path = node.path - return path in lf_paths + # Use stable sort to prioritize last failed. + def sort_key(node: nodes.Item | nodes.Collector) -> bool: + return node.path in lf_paths res.result = sorted( res.result, key=sort_key, reverse=True, ) - return elif isinstance(collector, File): if collector.path in self.lfplugin._last_failed_paths: - out = yield - res = out.get_result() result = res.result lastfailed = self.lfplugin.lastfailed # Only filter with known failures. if not self._collected_at_least_one_failure: if not any(x.nodeid in lastfailed for x in result): - return + return res self.lfplugin.config.pluginmanager.register( LFPluginCollSkipfiles(self.lfplugin), "lfplugin-collskip" ) @@ -268,21 +294,19 @@ def sort_key(node: Union[nodes.Item, nodes.Collector]) -> bool: # Keep all sub-collectors. or isinstance(x, nodes.Collector) ] - return - yield + + return res class LFPluginCollSkipfiles: - def __init__(self, lfplugin: "LFPlugin") -> None: + def __init__(self, lfplugin: LFPlugin) -> None: self.lfplugin = lfplugin @hookimpl def pytest_make_collect_report( self, collector: nodes.Collector - ) -> Optional[CollectReport]: - # Packages are Files, but we only want to skip test-bearing Files, - # so don't filter Packages. - if isinstance(collector, File) and not isinstance(collector, Package): + ) -> CollectReport | None: + if isinstance(collector, File): if collector.path not in self.lfplugin._last_failed_paths: self.lfplugin._skipped_files += 1 @@ -300,9 +324,9 @@ def __init__(self, config: Config) -> None: active_keys = "lf", "failedfirst" self.active = any(config.getoption(key) for key in active_keys) assert config.cache - self.lastfailed: Dict[str, bool] = config.cache.get("cache/lastfailed", {}) - self._previously_failed_count: Optional[int] = None - self._report_status: Optional[str] = None + self.lastfailed: dict[str, bool] = config.cache.get("cache/lastfailed", {}) + self._previously_failed_count: int | None = None + self._report_status: str | None = None self._skipped_files = 0 # count skipped files during collection due to --lf if config.getoption("lf"): @@ -311,7 +335,7 @@ def __init__(self, config: Config) -> None: LFPluginCollWrapper(self), "lfplugin-collwrapper" ) - def get_last_failed_paths(self) -> Set[Path]: + def get_last_failed_paths(self) -> set[Path]: """Return a set with all Paths of the previously failed nodeids and their parents.""" rootpath = self.config.rootpath @@ -322,9 +346,9 @@ def get_last_failed_paths(self) -> Set[Path]: result.update(path.parents) return {x for x in result if x.exists()} - def pytest_report_collectionfinish(self) -> Optional[str]: - if self.active and self.config.getoption("verbose") >= 0: - return "run-last-failure: %s" % self._report_status + def pytest_report_collectionfinish(self) -> str | None: + if self.active and self.config.get_verbosity() >= 0: + return f"run-last-failure: {self._report_status}" return None def pytest_runtest_logreport(self, report: TestReport) -> None: @@ -342,14 +366,14 @@ def pytest_collectreport(self, report: CollectReport) -> None: else: self.lastfailed[report.nodeid] = True - @hookimpl(hookwrapper=True, tryfirst=True) + @hookimpl(wrapper=True, tryfirst=True) def pytest_collection_modifyitems( - self, config: Config, items: List[nodes.Item] - ) -> Generator[None, None, None]: - yield + self, config: Config, items: list[nodes.Item] + ) -> Generator[None]: + res = yield if not self.active: - return + return res if self.lastfailed: previously_failed = [] @@ -364,8 +388,8 @@ def pytest_collection_modifyitems( if not previously_failed: # Running a subset of all tests with recorded failures # only outside of it. - self._report_status = "%d known failures not in selected tests" % ( - len(self.lastfailed), + self._report_status = ( + f"{len(self.lastfailed)} known failures not in selected tests" ) else: if self.config.getoption("lf"): @@ -376,15 +400,13 @@ def pytest_collection_modifyitems( noun = "failure" if self._previously_failed_count == 1 else "failures" suffix = " first" if self.config.getoption("failedfirst") else "" - self._report_status = "rerun previous {count} {noun}{suffix}".format( - count=self._previously_failed_count, suffix=suffix, noun=noun + self._report_status = ( + f"rerun previous {self._previously_failed_count} {noun}{suffix}" ) if self._skipped_files > 0: files_noun = "file" if self._skipped_files == 1 else "files" - self._report_status += " (skipped {files} {files_noun})".format( - files=self._skipped_files, files_noun=files_noun - ) + self._report_status += f" (skipped {self._skipped_files} {files_noun})" else: self._report_status = "no previously failed tests, " if self.config.getoption("last_failed_no_failures") == "none": @@ -394,6 +416,8 @@ def pytest_collection_modifyitems( else: self._report_status += "not deselecting items." + return res + def pytest_sessionfinish(self, session: Session) -> None: config = self.config if config.getoption("cacheshow") or hasattr(config, "workerinput"): @@ -414,15 +438,13 @@ def __init__(self, config: Config) -> None: assert config.cache is not None self.cached_nodeids = set(config.cache.get("cache/nodeids", [])) - @hookimpl(hookwrapper=True, tryfirst=True) - def pytest_collection_modifyitems( - self, items: List[nodes.Item] - ) -> Generator[None, None, None]: - yield + @hookimpl(wrapper=True, tryfirst=True) + def pytest_collection_modifyitems(self, items: list[nodes.Item]) -> Generator[None]: + res = yield if self.active: - new_items: Dict[str, nodes.Item] = {} - other_items: Dict[str, nodes.Item] = {} + new_items: dict[str, nodes.Item] = {} + other_items: dict[str, nodes.Item] = {} for item in items: if item.nodeid not in self.cached_nodeids: new_items[item.nodeid] = item @@ -436,8 +458,10 @@ def pytest_collection_modifyitems( else: self.cached_nodeids.update(item.nodeid for item in items) - def _get_increasing_order(self, items: Iterable[nodes.Item]) -> List[nodes.Item]: - return sorted(items, key=lambda item: item.path.stat().st_mtime, reverse=True) # type: ignore[no-any-return] + return res + + def _get_increasing_order(self, items: Iterable[nodes.Item]) -> list[nodes.Item]: + return sorted(items, key=lambda item: item.path.stat().st_mtime, reverse=True) def pytest_sessionfinish(self) -> None: config = self.config @@ -458,8 +482,7 @@ def pytest_addoption(parser: Parser) -> None: "--last-failed", action="store_true", dest="lf", - help="Rerun only the tests that failed " - "at the last run (or all if none failed)", + help="Rerun only the tests that failed at the last run (or all if none failed)", ) group.addoption( "--ff", @@ -505,11 +528,15 @@ def pytest_addoption(parser: Parser) -> None: dest="last_failed_no_failures", choices=("all", "none"), default="all", - help="Which tests to run with no previously (known) failures", + help="With ``--lf``, determines whether to execute tests when there " + "are no previously (known) failures or when no " + "cached ``lastfailed`` data was found. " + "``all`` (the default) runs the full test suite again. " + "``none`` just emits a message about no known failures and exits successfully.", ) -def pytest_cmdline_main(config: Config) -> Optional[Union[int, ExitCode]]: +def pytest_cmdline_main(config: Config) -> int | ExitCode | None: if config.option.cacheshow and not config.option.help: from _pytest.main import wrap_session @@ -540,7 +567,7 @@ def cache(request: FixtureRequest) -> Cache: return request.config.cache -def pytest_report_header(config: Config) -> Optional[str]: +def pytest_report_header(config: Config) -> str | None: """Display cachedir with --cache-show and if non-default.""" if config.option.verbose > 0 or config.getini("cache_dir") != ".pytest_cache": assert config.cache is not None @@ -574,25 +601,25 @@ def cacheshow(config: Config, session: Session) -> int: dummy = object() basedir = config.cache._cachedir vdir = basedir / Cache._CACHE_PREFIX_VALUES - tw.sep("-", "cache values for %r" % glob) + tw.sep("-", f"cache values for {glob!r}") for valpath in sorted(x for x in vdir.rglob(glob) if x.is_file()): key = str(valpath.relative_to(vdir)) val = config.cache.get(key, dummy) if val is dummy: - tw.line("%s contains unreadable content, will be ignored" % key) + tw.line(f"{key} contains unreadable content, will be ignored") else: - tw.line("%s contains:" % key) + tw.line(f"{key} contains:") for line in pformat(val).splitlines(): tw.line(" " + line) ddir = basedir / Cache._CACHE_PREFIX_DIRS if ddir.is_dir(): contents = sorted(ddir.rglob(glob)) - tw.sep("-", "cache directories for %r" % glob) + tw.sep("-", f"cache directories for {glob!r}") for p in contents: # if p.is_dir(): # print("%s/" % p.relative_to(basedir)) if p.is_file(): key = str(p.relative_to(basedir)) - tw.line(f"{key} is a file of length {p.stat().st_size:d}") + tw.line(f"{key} is a file of length {p.stat().st_size}") return 0 diff --git a/src/_pytest/capture.py b/src/_pytest/capture.py index a8ca0869f33..6d98676be5f 100644 --- a/src/_pytest/capture.py +++ b/src/_pytest/capture.py @@ -1,30 +1,36 @@ +# mypy: allow-untyped-defs """Per-test stdout/stderr capturing mechanism.""" + +from __future__ import annotations + import abc import collections +from collections.abc import Generator +from collections.abc import Iterable +from collections.abc import Iterator import contextlib import io +from io import UnsupportedOperation import os import sys -from io import UnsupportedOperation from tempfile import TemporaryFile from types import TracebackType from typing import Any from typing import AnyStr from typing import BinaryIO -from typing import Generator +from typing import cast +from typing import Final +from typing import final from typing import Generic -from typing import Iterable -from typing import Iterator -from typing import List +from typing import Literal from typing import NamedTuple -from typing import Optional from typing import TextIO -from typing import Tuple -from typing import Type from typing import TYPE_CHECKING -from typing import Union -from _pytest.compat import final + +if TYPE_CHECKING: + from typing_extensions import Self + from _pytest.config import Config from _pytest.config import hookimpl from _pytest.config.argparsing import Parser @@ -34,17 +40,15 @@ from _pytest.nodes import Collector from _pytest.nodes import File from _pytest.nodes import Item +from _pytest.reports import CollectReport -if TYPE_CHECKING: - from typing_extensions import Final - from typing_extensions import Literal - _CaptureMethod = Literal["fd", "sys", "no", "tee-sys"] +_CaptureMethod = Literal["fd", "sys", "no", "tee-sys"] def pytest_addoption(parser: Parser) -> None: group = parser.getgroup("general") - group._addoption( + group.addoption( "--capture", action="store", default="fd", @@ -52,7 +56,7 @@ def pytest_addoption(parser: Parser) -> None: choices=["fd", "sys", "no", "tee-sys"], help="Per-test capturing method: one of fd|sys|no|tee-sys", ) - group._addoption( + group._addoption( # private to use reserved lower-case short option "-s", action="store_const", const="no", @@ -76,6 +80,23 @@ def _colorama_workaround() -> None: pass +def _readline_workaround() -> None: + """Ensure readline is imported early so it attaches to the correct stdio handles. + + This isn't a problem with the default GNU readline implementation, but in + some configurations, Python uses libedit instead (on macOS, and for prebuilt + binaries such as used by uv). + + In theory this is only needed if readline.backend == "libedit", but the + workaround consists of importing readline here, so we already worked around + the issue by the time we could check if we need to. + """ + try: + import readline # noqa: F401 + except ImportError: + pass + + def _windowsconsoleio_workaround(stream: TextIO) -> None: """Workaround for Windows Unicode console handling. @@ -104,17 +125,16 @@ def _windowsconsoleio_workaround(stream: TextIO) -> None: return # Bail out if ``stream`` doesn't seem like a proper ``io`` stream (#2666). - if not hasattr(stream, "buffer"): # type: ignore[unreachable] + if not hasattr(stream, "buffer"): # type: ignore[unreachable,unused-ignore] return - buffered = hasattr(stream.buffer, "raw") - raw_stdout = stream.buffer.raw if buffered else stream.buffer # type: ignore[attr-defined] + raw_stdout = stream.buffer.raw if hasattr(stream.buffer, "raw") else stream.buffer - if not isinstance(raw_stdout, io._WindowsConsoleIO): # type: ignore[attr-defined] + if not isinstance(raw_stdout, io._WindowsConsoleIO): # type: ignore[attr-defined,unused-ignore] return def _reopen_stdio(f, mode): - if not buffered and mode[0] == "w": + if not hasattr(stream.buffer, "raw") and mode[0] == "w": buffering = 0 else: buffering = -1 @@ -132,12 +152,13 @@ def _reopen_stdio(f, mode): sys.stderr = _reopen_stdio(sys.stderr, "wb") -@hookimpl(hookwrapper=True) -def pytest_load_initial_conftests(early_config: Config): +@hookimpl(wrapper=True) +def pytest_load_initial_conftests(early_config: Config) -> Generator[None]: ns = early_config.known_args_namespace if ns.capture == "fd": _windowsconsoleio_workaround(sys.stdout) _colorama_workaround() + _readline_workaround() pluginmanager = early_config.pluginmanager capman = CaptureManager(ns.capture) pluginmanager.register(capman, "capturemanager") @@ -147,12 +168,16 @@ def pytest_load_initial_conftests(early_config: Config): # Finally trigger conftest loading but while capturing (issue #93). capman.start_global_capturing() - outcome = yield - capman.suspend_global_capture() - if outcome.excinfo is not None: + try: + try: + yield + finally: + capman.suspend_global_capture() + except BaseException: out, err = capman.read_global_capture() sys.stdout.write(out) sys.stderr.write(err) + raise # IO Helpers. @@ -171,7 +196,8 @@ def name(self) -> str: def mode(self) -> str: # TextIOWrapper doesn't expose a mode, but at least some of our # tests check it. - return self.buffer.mode.replace("b", "") + assert hasattr(self.buffer, "mode") + return cast(str, self.buffer.mode.replace("b", "")) class CaptureIO(io.TextIOWrapper): @@ -196,6 +222,7 @@ def write(self, s: str) -> int: class DontReadFromInput(TextIO): @property def encoding(self) -> str: + assert sys.__stdin__ is not None return sys.__stdin__.encoding def read(self, size: int = -1) -> str: @@ -208,7 +235,7 @@ def read(self, size: int = -1) -> str: def __next__(self) -> str: return self.readline() - def readlines(self, hint: Optional[int] = -1) -> List[str]: + def readlines(self, hint: int | None = -1) -> list[str]: raise OSError( "pytest: reading from stdin while output is captured! Consider using `-s`." ) @@ -240,7 +267,7 @@ def seekable(self) -> bool: def tell(self) -> int: raise UnsupportedOperation("redirected stdin is pseudofile, has no tell()") - def truncate(self, size: Optional[int] = None) -> int: + def truncate(self, size: int | None = None) -> int: raise UnsupportedOperation("cannot truncate stdin") def write(self, data: str) -> int: @@ -252,14 +279,14 @@ def writelines(self, lines: Iterable[str]) -> None: def writable(self) -> bool: return False - def __enter__(self) -> "DontReadFromInput": + def __enter__(self) -> Self: return self def __exit__( self, - type: Optional[Type[BaseException]], - value: Optional[BaseException], - traceback: Optional[TracebackType], + type: type[BaseException] | None, + value: BaseException | None, + traceback: TracebackType | None, ) -> None: pass @@ -334,7 +361,7 @@ def writeorg(self, data: str) -> None: class SysCaptureBase(CaptureBase[AnyStr]): def __init__( - self, fd: int, tmpfile: Optional[TextIO] = None, *, tee: bool = False + self, fd: int, tmpfile: TextIO | None = None, *, tee: bool = False ) -> None: name = patchsysdict[fd] self._old: TextIO = getattr(sys, name) @@ -351,7 +378,7 @@ def repr(self, class_name: str) -> str: return "<{} {} _old={} _state={!r} tmpfile={!r}>".format( class_name, self.name, - hasattr(self, "_old") and repr(self._old) or "", + (hasattr(self, "_old") and repr(self._old)) or "", self._state, self.tmpfile, ) @@ -360,16 +387,16 @@ def __repr__(self) -> str: return "<{} {} _old={} _state={!r} tmpfile={!r}>".format( self.__class__.__name__, self.name, - hasattr(self, "_old") and repr(self._old) or "", + (hasattr(self, "_old") and repr(self._old)) or "", self._state, self.tmpfile, ) - def _assert_state(self, op: str, states: Tuple[str, ...]) -> None: - assert ( - self._state in states - ), "cannot {} in state {!r}: expected one of {}".format( - op, self._state, ", ".join(states) + def _assert_state(self, op: str, states: tuple[str, ...]) -> None: + assert self._state in states, ( + "cannot {} in state {!r}: expected one of {}".format( + op, self._state, ", ".join(states) + ) ) def start(self) -> None: @@ -452,7 +479,7 @@ def __init__(self, targetfd: int) -> None: # Further complications are the need to support suspend() and the # possibility of FD reuse (e.g. the tmpfile getting the very same # target FD). The following approach is robust, I believe. - self.targetfd_invalid: Optional[int] = os.open(os.devnull, os.O_RDWR) + self.targetfd_invalid: int | None = os.open(os.devnull, os.O_RDWR) os.dup2(self.targetfd_invalid, targetfd) else: self.targetfd_invalid = None @@ -477,19 +504,16 @@ def __init__(self, targetfd: int) -> None: self._state = "initialized" def __repr__(self) -> str: - return "<{} {} oldfd={} _state={!r} tmpfile={!r}>".format( - self.__class__.__name__, - self.targetfd, - self.targetfd_save, - self._state, - self.tmpfile, + return ( + f"<{self.__class__.__name__} {self.targetfd} oldfd={self.targetfd_save} " + f"_state={self._state!r} tmpfile={self.tmpfile!r}>" ) - def _assert_state(self, op: str, states: Tuple[str, ...]) -> None: - assert ( - self._state in states - ), "cannot {} in state {!r}: expected one of {}".format( - op, self._state, ", ".join(states) + def _assert_state(self, op: str, states: tuple[str, ...]) -> None: + assert self._state in states, ( + "cannot {} in state {!r}: expected one of {}".format( + op, self._state, ", ".join(states) + ) ) def start(self) -> None: @@ -546,7 +570,7 @@ def snap(self) -> bytes: res = self.tmpfile.buffer.read() self.tmpfile.seek(0) self.tmpfile.truncate() - return res + return res # type: ignore[return-value] def writeorg(self, data: bytes) -> None: """Write to original file descriptor.""" @@ -585,7 +609,7 @@ def writeorg(self, data: str) -> None: @final class CaptureResult(NamedTuple, Generic[AnyStr]): - """The result of :method:`CaptureFixture.readouterr`.""" + """The result of :method:`caplog.readouterr() `.""" out: AnyStr err: AnyStr @@ -593,9 +617,10 @@ class CaptureResult(NamedTuple, Generic[AnyStr]): else: class CaptureResult( - collections.namedtuple("CaptureResult", ["out", "err"]), Generic[AnyStr] + collections.namedtuple("CaptureResult", ["out", "err"]), # noqa: PYI024 + Generic[AnyStr], ): - """The result of :method:`CaptureFixture.readouterr`.""" + """The result of :method:`caplog.readouterr() `.""" __slots__ = () @@ -606,21 +631,18 @@ class MultiCapture(Generic[AnyStr]): def __init__( self, - in_: Optional[CaptureBase[AnyStr]], - out: Optional[CaptureBase[AnyStr]], - err: Optional[CaptureBase[AnyStr]], + in_: CaptureBase[AnyStr] | None, + out: CaptureBase[AnyStr] | None, + err: CaptureBase[AnyStr] | None, ) -> None: - self.in_: Optional[CaptureBase[AnyStr]] = in_ - self.out: Optional[CaptureBase[AnyStr]] = out - self.err: Optional[CaptureBase[AnyStr]] = err + self.in_: CaptureBase[AnyStr] | None = in_ + self.out: CaptureBase[AnyStr] | None = out + self.err: CaptureBase[AnyStr] | None = err def __repr__(self) -> str: - return "".format( - self.out, - self.err, - self.in_, - self._state, - self._in_suspended, + return ( + f"" ) def start_capturing(self) -> None: @@ -632,7 +654,7 @@ def start_capturing(self) -> None: if self.err: self.err.start() - def pop_outerr_to_orig(self) -> Tuple[AnyStr, AnyStr]: + def pop_outerr_to_orig(self) -> tuple[AnyStr, AnyStr]: """Pop current snapshot out/err capture and flush to orig streams.""" out, err = self.readouterr() if out: @@ -687,7 +709,7 @@ def readouterr(self) -> CaptureResult[AnyStr]: return CaptureResult(out, err) # type: ignore[arg-type] -def _get_multicapture(method: "_CaptureMethod") -> MultiCapture[str]: +def _get_multicapture(method: _CaptureMethod) -> MultiCapture[str]: if method == "fd": return MultiCapture(in_=FDCapture(0), out=FDCapture(1), err=FDCapture(2)) elif method == "sys": @@ -723,21 +745,22 @@ class CaptureManager: needed to ensure the fixtures take precedence over the global capture. """ - def __init__(self, method: "_CaptureMethod") -> None: + def __init__(self, method: _CaptureMethod) -> None: self._method: Final = method - self._global_capturing: Optional[MultiCapture[str]] = None - self._capture_fixture: Optional[CaptureFixture[Any]] = None + self._global_capturing: MultiCapture[str] | None = None + self._capture_fixture: CaptureFixture[Any] | None = None def __repr__(self) -> str: - return "".format( - self._method, self._global_capturing, self._capture_fixture + return ( + f"" ) - def is_capturing(self) -> Union[str, bool]: + def is_capturing(self) -> str | bool: if self.is_globally_capturing(): return "global" if self._capture_fixture: - return "fixture %s" % self._capture_fixture.request.fixturename + return f"fixture {self._capture_fixture.request.fixturename}" return False # Global capturing control @@ -781,14 +804,12 @@ def read_global_capture(self) -> CaptureResult[str]: # Fixture Control - def set_fixture(self, capture_fixture: "CaptureFixture[Any]") -> None: + def set_fixture(self, capture_fixture: CaptureFixture[Any]) -> None: if self._capture_fixture: current_fixture = self._capture_fixture.request.fixturename requested_fixture = capture_fixture.request.fixturename capture_fixture.request.raiseerror( - "cannot use {} and {} at the same time".format( - requested_fixture, current_fixture - ) + f"cannot use {requested_fixture} and {current_fixture} at the same time" ) self._capture_fixture = capture_fixture @@ -817,7 +838,7 @@ def resume_fixture(self) -> None: # Helper context managers @contextlib.contextmanager - def global_and_fixture_disabled(self) -> Generator[None, None, None]: + def global_and_fixture_disabled(self) -> Generator[None]: """Context manager to temporarily disable global and current fixture capturing.""" do_fixture = self._capture_fixture and self._capture_fixture._is_started() if do_fixture: @@ -834,7 +855,7 @@ def global_and_fixture_disabled(self) -> Generator[None, None, None]: self.resume_fixture() @contextlib.contextmanager - def item_capture(self, when: str, item: Item) -> Generator[None, None, None]: + def item_capture(self, when: str, item: Item) -> Generator[None]: self.resume_global_capture() self.activate_fixture() try: @@ -843,41 +864,45 @@ def item_capture(self, when: str, item: Item) -> Generator[None, None, None]: self.deactivate_fixture() self.suspend_global_capture(in_=False) - out, err = self.read_global_capture() - item.add_report_section(when, "stdout", out) - item.add_report_section(when, "stderr", err) + out, err = self.read_global_capture() + item.add_report_section(when, "stdout", out) + item.add_report_section(when, "stderr", err) # Hooks - @hookimpl(hookwrapper=True) - def pytest_make_collect_report(self, collector: Collector): + @hookimpl(wrapper=True) + def pytest_make_collect_report( + self, collector: Collector + ) -> Generator[None, CollectReport, CollectReport]: if isinstance(collector, File): self.resume_global_capture() - outcome = yield - self.suspend_global_capture() + try: + rep = yield + finally: + self.suspend_global_capture() out, err = self.read_global_capture() - rep = outcome.get_result() if out: rep.sections.append(("Captured stdout", out)) if err: rep.sections.append(("Captured stderr", err)) else: - yield + rep = yield + return rep - @hookimpl(hookwrapper=True) - def pytest_runtest_setup(self, item: Item) -> Generator[None, None, None]: + @hookimpl(wrapper=True) + def pytest_runtest_setup(self, item: Item) -> Generator[None]: with self.item_capture("setup", item): - yield + return (yield) - @hookimpl(hookwrapper=True) - def pytest_runtest_call(self, item: Item) -> Generator[None, None, None]: + @hookimpl(wrapper=True) + def pytest_runtest_call(self, item: Item) -> Generator[None]: with self.item_capture("call", item): - yield + return (yield) - @hookimpl(hookwrapper=True) - def pytest_runtest_teardown(self, item: Item) -> Generator[None, None, None]: + @hookimpl(wrapper=True) + def pytest_runtest_teardown(self, item: Item) -> Generator[None]: with self.item_capture("teardown", item): - yield + return (yield) @hookimpl(tryfirst=True) def pytest_keyboard_interrupt(self) -> None: @@ -894,15 +919,17 @@ class CaptureFixture(Generic[AnyStr]): def __init__( self, - captureclass: Type[CaptureBase[AnyStr]], + captureclass: type[CaptureBase[AnyStr]], request: SubRequest, *, + config: dict[str, Any] | None = None, _ispytest: bool = False, ) -> None: check_ispytest(_ispytest) - self.captureclass: Type[CaptureBase[AnyStr]] = captureclass + self.captureclass: type[CaptureBase[AnyStr]] = captureclass self.request = request - self._capture: Optional[MultiCapture[AnyStr]] = None + self._config = config if config else {} + self._capture: MultiCapture[AnyStr] | None = None self._captured_out: AnyStr = self.captureclass.EMPTY_BUFFER self._captured_err: AnyStr = self.captureclass.EMPTY_BUFFER @@ -910,8 +937,8 @@ def _start(self) -> None: if self._capture is None: self._capture = MultiCapture( in_=None, - out=self.captureclass(1), - err=self.captureclass(2), + out=self.captureclass(1, **self._config), + err=self.captureclass(2, **self._config), ) self._capture.start_capturing() @@ -957,7 +984,7 @@ def _is_started(self) -> bool: return False @contextlib.contextmanager - def disabled(self) -> Generator[None, None, None]: + def disabled(self) -> Generator[None]: """Temporarily disable capturing while inside the ``with`` block.""" capmanager: CaptureManager = self.request.config.pluginmanager.getplugin( "capturemanager" @@ -970,7 +997,7 @@ def disabled(self) -> Generator[None, None, None]: @fixture -def capsys(request: SubRequest) -> Generator[CaptureFixture[str], None, None]: +def capsys(request: SubRequest) -> Generator[CaptureFixture[str]]: r"""Enable text capturing of writes to ``sys.stdout`` and ``sys.stderr``. The captured output is made available via ``capsys.readouterr()`` method @@ -998,7 +1025,42 @@ def test_output(capsys): @fixture -def capsysbinary(request: SubRequest) -> Generator[CaptureFixture[bytes], None, None]: +def capteesys(request: SubRequest) -> Generator[CaptureFixture[str]]: + r"""Enable simultaneous text capturing and pass-through of writes + to ``sys.stdout`` and ``sys.stderr`` as defined by ``--capture=``. + + + The captured output is made available via ``capteesys.readouterr()`` method + calls, which return a ``(out, err)`` namedtuple. + ``out`` and ``err`` will be ``text`` objects. + + The output is also passed-through, allowing it to be "live-printed", + reported, or both as defined by ``--capture=``. + + Returns an instance of :class:`CaptureFixture[str] `. + + Example: + + .. code-block:: python + + def test_output(capteesys): + print("hello") + captured = capteesys.readouterr() + assert captured.out == "hello\n" + """ + capman: CaptureManager = request.config.pluginmanager.getplugin("capturemanager") + capture_fixture = CaptureFixture( + SysCapture, request, config=dict(tee=True), _ispytest=True + ) + capman.set_fixture(capture_fixture) + capture_fixture._start() + yield capture_fixture + capture_fixture.close() + capman.unset_fixture() + + +@fixture +def capsysbinary(request: SubRequest) -> Generator[CaptureFixture[bytes]]: r"""Enable bytes capturing of writes to ``sys.stdout`` and ``sys.stderr``. The captured output is made available via ``capsysbinary.readouterr()`` @@ -1026,7 +1088,7 @@ def test_output(capsysbinary): @fixture -def capfd(request: SubRequest) -> Generator[CaptureFixture[str], None, None]: +def capfd(request: SubRequest) -> Generator[CaptureFixture[str]]: r"""Enable text capturing of writes to file descriptors ``1`` and ``2``. The captured output is made available via ``capfd.readouterr()`` method @@ -1054,7 +1116,7 @@ def test_system_echo(capfd): @fixture -def capfdbinary(request: SubRequest) -> Generator[CaptureFixture[bytes], None, None]: +def capfdbinary(request: SubRequest) -> Generator[CaptureFixture[bytes]]: r"""Enable bytes capturing of writes to file descriptors ``1`` and ``2``. The captured output is made available via ``capfd.readouterr()`` method diff --git a/src/_pytest/compat.py b/src/_pytest/compat.py index 352211de8aa..bef8c317bb9 100644 --- a/src/_pytest/compat.py +++ b/src/_pytest/compat.py @@ -1,40 +1,27 @@ +# mypy: allow-untyped-defs """Python version compatibility code.""" + from __future__ import annotations -import dataclasses +from collections.abc import Callable import enum import functools import inspect -import os -import sys from inspect import Parameter -from inspect import signature +from inspect import Signature +import os from pathlib import Path +import sys from typing import Any -from typing import Callable -from typing import Generic +from typing import Final from typing import NoReturn -from typing import TYPE_CHECKING -from typing import TypeVar import py -# fmt: off -# Workaround for https://github.com/sphinx-doc/sphinx/issues/10351. -# If `overload` is imported from `compat` instead of from `typing`, -# Sphinx doesn't recognize it as `overload` and the API docs for -# overloaded functions look good again. But type checkers handle -# it fine. -# fmt: on -if True: - from typing import overload as overload - -if TYPE_CHECKING: - from typing_extensions import Final +if sys.version_info >= (3, 14): + from annotationlib import Format -_T = TypeVar("_T") -_S = TypeVar("_S") #: constant to prepare valuing pylib path replacements/lazy proxies later on # intended for removal in pytest 8.0 or 9.0 @@ -55,32 +42,16 @@ def legacy_path(path: str | os.PathLike[str]) -> LEGACY_PATH: # https://www.python.org/dev/peps/pep-0484/#support-for-singleton-types-in-unions class NotSetType(enum.Enum): token = 0 -NOTSET: Final = NotSetType.token # noqa: E305 +NOTSET: Final = NotSetType.token # fmt: on -if sys.version_info >= (3, 8): - import importlib.metadata - - importlib_metadata = importlib.metadata -else: - import importlib_metadata as importlib_metadata # noqa: F401 - - -def _format_args(func: Callable[..., Any]) -> str: - return str(signature(func)) - - -def is_generator(func: object) -> bool: - genfunc = inspect.isgeneratorfunction(func) - return genfunc and not iscoroutinefunction(func) - def iscoroutinefunction(func: object) -> bool: """Return True if func is a coroutine function (a function defined with async def syntax, and doesn't contain yield), or a function decorated with @asyncio.coroutine. - Note: copied and modified from Python 3.5's builtin couroutines.py to avoid + Note: copied and modified from Python 3.5's builtin coroutines.py to avoid importing asyncio directly, which in turns also initializes the "logging" module as a side-effect (see issue #8). """ @@ -93,7 +64,14 @@ def is_async_function(func: object) -> bool: return iscoroutinefunction(func) or inspect.isasyncgenfunction(func) -def getlocation(function, curdir: str | None = None) -> str: +def signature(obj: Callable[..., Any]) -> Signature: + """Return signature without evaluating annotations.""" + if sys.version_info >= (3, 14): + return inspect.signature(obj, annotation_format=Format.STRING) + return inspect.signature(obj) + + +def getlocation(function, curdir: str | os.PathLike[str] | None = None) -> str: function = get_real_func(function) fn = Path(inspect.getfile(function)) lineno = function.__code__.co_firstlineno @@ -103,8 +81,8 @@ def getlocation(function, curdir: str | None = None) -> str: except ValueError: pass else: - return "%s:%d" % (relfn, lineno + 1) - return "%s:%d" % (fn, lineno + 1) + return f"{relfn}:{lineno + 1}" + return f"{fn}:{lineno + 1}" def num_mock_patch_args(function) -> int: @@ -127,10 +105,9 @@ def num_mock_patch_args(function) -> int: def getfuncargnames( - function: Callable[..., Any], + function: Callable[..., object], *, name: str = "", - is_method: bool = False, cls: type | None = None, ) -> tuple[str, ...]: """Return the names of a function's mandatory arguments. @@ -141,9 +118,8 @@ def getfuncargnames( * Aren't bound with functools.partial. * Aren't replaced with mocks. - The is_method and cls arguments indicate that the function should - be treated as a bound method even though it's not unless, only in - the case of cls, the function is a static method. + The cls arguments indicate that the function should be treated as a bound + method even though it's not unless the function is a static method. The name parameter should be the original name in which the function was collected. """ @@ -157,7 +133,7 @@ def getfuncargnames( # creates a tuple of the names of the parameters that don't have # defaults. try: - parameters = signature(function).parameters + parameters = signature(function).parameters.values() except (ValueError, TypeError) as e: from _pytest.outcomes import fail @@ -168,7 +144,7 @@ def getfuncargnames( arg_names = tuple( p.name - for p in parameters.values() + for p in parameters if ( p.kind is Parameter.POSITIONAL_OR_KEYWORD or p.kind is Parameter.KEYWORD_ONLY @@ -179,9 +155,9 @@ def getfuncargnames( name = function.__name__ # If this function should be treated as a bound method even though - # it's passed as an unbound method or function, remove the first - # parameter name. - if is_method or ( + # it's passed as an unbound method or function, and its first parameter + # wasn't defined as positional only, remove the first parameter name. + if not any(p.kind is Parameter.POSITIONAL_ONLY for p in parameters) and ( # Not using `getattr` because we don't want to resolve the staticmethod. # Not using `cls.__dict__` because we want to check the entire MRO. cls @@ -216,25 +192,13 @@ def get_default_arg_names(function: Callable[..., Any]) -> tuple[str, ...]: ) -def _translate_non_printable(s: str) -> str: - return s.translate(_non_printable_ascii_translate_table) - - -STRING_TYPES = bytes, str - - -def _bytes_to_ascii(val: bytes) -> str: - return val.decode("ascii", "backslashreplace") - - def ascii_escaped(val: bytes | str) -> str: r"""If val is pure ASCII, return it as an str, otherwise, escape bytes objects into a sequence of escaped bytes: b'\xc3\xb4\xc5\xd6' -> r'\xc3\xb4\xc5\xd6' - and escapes unicode objects into a sequence of escaped unicode - ids, e.g.: + and escapes strings into a sequence of escaped unicode ids, e.g.: r'4\nV\U00043efa\x0eMXWB\x1e\u3028\u15fd\xcd\U0007d944' @@ -245,67 +209,22 @@ def ascii_escaped(val: bytes | str) -> str: a UTF-8 string. """ if isinstance(val, bytes): - ret = _bytes_to_ascii(val) + ret = val.decode("ascii", "backslashreplace") else: ret = val.encode("unicode_escape").decode("ascii") - return _translate_non_printable(ret) - - -@dataclasses.dataclass -class _PytestWrapper: - """Dummy wrapper around a function object for internal use only. - - Used to correctly unwrap the underlying function object when we are - creating fixtures, because we wrap the function object ourselves with a - decorator to issue warnings when the fixture function is called directly. - """ - - obj: Any + return ret.translate(_non_printable_ascii_translate_table) def get_real_func(obj): """Get the real function object of the (possibly) wrapped object by - functools.wraps or functools.partial.""" - start_obj = obj - for i in range(100): - # __pytest_wrapped__ is set by @pytest.fixture when wrapping the fixture function - # to trigger a warning if it gets called directly instead of by pytest: we don't - # want to unwrap further than this otherwise we lose useful wrappings like @mock.patch (#3774) - new_obj = getattr(obj, "__pytest_wrapped__", None) - if isinstance(new_obj, _PytestWrapper): - obj = new_obj.obj - break - new_obj = getattr(obj, "__wrapped__", None) - if new_obj is None: - break - obj = new_obj - else: - from _pytest._io.saferepr import saferepr + :func:`functools.wraps`, or :func:`functools.partial`.""" + obj = inspect.unwrap(obj) - raise ValueError( - ("could not find real function of {start}\nstopped at {current}").format( - start=saferepr(start_obj), current=saferepr(obj) - ) - ) if isinstance(obj, functools.partial): obj = obj.func return obj -def get_real_method(obj, holder): - """Attempt to obtain the real function object that might be wrapping - ``obj``, while at the same time returning a bound method to ``holder`` if - the original object was a bound method.""" - try: - is_method = hasattr(obj, "__func__") - obj = get_real_func(obj) - except Exception: # pragma: no cover - return obj - if is_method and hasattr(obj, "__get__") and callable(obj.__get__): - obj = obj.__get__(holder) - return obj - - def getimfunc(func): try: return func.__func__ @@ -338,57 +257,25 @@ def safe_isclass(obj: object) -> bool: return False -if TYPE_CHECKING: - if sys.version_info >= (3, 8): - from typing import final as final - else: - from typing_extensions import final as final -elif sys.version_info >= (3, 8): - from typing import final as final -else: - - def final(f): - return f - - -if sys.version_info >= (3, 8): - from functools import cached_property as cached_property -else: - - class cached_property(Generic[_S, _T]): - __slots__ = ("func", "__doc__") - - def __init__(self, func: Callable[[_S], _T]) -> None: - self.func = func - self.__doc__ = func.__doc__ - - @overload - def __get__( - self, instance: None, owner: type[_S] | None = ... - ) -> cached_property[_S, _T]: - ... - - @overload - def __get__(self, instance: _S, owner: type[_S] | None = ...) -> _T: - ... - - def __get__(self, instance, owner=None): - if instance is None: - return self - value = instance.__dict__[self.func.__name__] = self.func(instance) - return value - - def get_user_id() -> int | None: - """Return the current user id, or None if we cannot get it reliably on the current platform.""" - # win32 does not have a getuid() function. - # On Emscripten, getuid() is a stub that always returns 0. - if sys.platform in ("win32", "emscripten"): + """Return the current process's real user id or None if it could not be + determined. + + :return: The user id or None if it could not be determined. + """ + # mypy follows the version and platform checking expectation of PEP 484: + # https://mypy.readthedocs.io/en/stable/common_issues.html?highlight=platform#python-version-and-system-platform-checks + # Containment checks are too complex for mypy v1.5.0 and cause failure. + if sys.platform == "win32" or sys.platform == "emscripten": + # win32 does not have a getuid() function. + # Emscripten has a return 0 stub. return None - # getuid shouldn't fail, but cpython defines such a case. - # Let's hope for the best. - uid = os.getuid() - return uid if uid != -1 else None + else: + # On other platforms, a return value of -1 is assumed to indicate that + # the current process's real user id could not be determined. + ERROR = -1 + uid = os.getuid() + return uid if uid != ERROR else None # Perform exhaustiveness checking. @@ -424,3 +311,23 @@ def get_user_id() -> int | None: # This also work for Enums (if you use `is` to compare) and Literals. def assert_never(value: NoReturn) -> NoReturn: assert False, f"Unhandled value: {value} ({type(value).__name__})" + + +class CallableBool: + """ + A bool-like object that can also be called, returning its true/false value. + + Used for backwards compatibility in cases where something was supposed to be a method + but was implemented as a simple attribute by mistake (see `TerminalReporter.isatty`). + + Do not use in new code. + """ + + def __init__(self, value: bool) -> None: + self._value = value + + def __bool__(self) -> bool: + return self._value + + def __call__(self) -> bool: + return self._value diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index c9a4b7f63cb..468018fadc0 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -1,55 +1,61 @@ +# mypy: allow-untyped-defs """Command line options, ini-file and conftest.py processing.""" + +from __future__ import annotations + import argparse import collections.abc +from collections.abc import Callable +from collections.abc import Generator +from collections.abc import Iterable +from collections.abc import Iterator +from collections.abc import Sequence +import contextlib import copy import dataclasses import enum +from functools import lru_cache import glob +import importlib.metadata import inspect import os +import pathlib import re import shlex import sys -import types -import warnings -from functools import lru_cache -from pathlib import Path from textwrap import dedent +import types from types import FunctionType -from types import TracebackType from typing import Any -from typing import Callable from typing import cast -from typing import Dict -from typing import Generator +from typing import Final +from typing import final from typing import IO -from typing import Iterable -from typing import Iterator -from typing import List -from typing import Optional -from typing import Sequence -from typing import Set from typing import TextIO -from typing import Tuple -from typing import Type from typing import TYPE_CHECKING -from typing import Union +import warnings +import pluggy from pluggy import HookimplMarker +from pluggy import HookimplOpts from pluggy import HookspecMarker +from pluggy import HookspecOpts from pluggy import PluginManager -import _pytest._code -import _pytest.deprecated -import _pytest.hookspec +from .compat import PathAwareHookProxy from .exceptions import PrintHelp as PrintHelp from .exceptions import UsageError as UsageError from .findpaths import determine_setup +from _pytest import __version__ +import _pytest._code from _pytest._code import ExceptionInfo from _pytest._code import filter_traceback +from _pytest._code.code import TracebackStyle from _pytest._io import TerminalWriter -from _pytest.compat import final -from _pytest.compat import importlib_metadata # type: ignore[attr-defined] +from _pytest.config.argparsing import Argument +from _pytest.config.argparsing import Parser +import _pytest.deprecated +import _pytest.hookspec from _pytest.outcomes import fail from _pytest.outcomes import Skipped from _pytest.pathlib import absolutepath @@ -57,15 +63,16 @@ from _pytest.pathlib import import_path from _pytest.pathlib import ImportMode from _pytest.pathlib import resolve_package_path +from _pytest.pathlib import safe_exists from _pytest.stash import Stash from _pytest.warning_types import PytestConfigWarning from _pytest.warning_types import warn_explicit_for + if TYPE_CHECKING: - from _pytest._code.code import _TracebackStyle + from _pytest.assertion.rewrite import AssertionRewritingHook + from _pytest.cacheprovider import Cache from _pytest.terminal import TerminalReporter - from .argparsing import Argument - _PluggyPlugin = object """A type to represent plugin objects. @@ -107,17 +114,15 @@ class ExitCode(enum.IntEnum): class ConftestImportFailure(Exception): def __init__( self, - path: Path, - excinfo: Tuple[Type[Exception], Exception, TracebackType], + path: pathlib.Path, + *, + cause: Exception, ) -> None: - super().__init__(path, excinfo) self.path = path - self.excinfo = excinfo + self.cause = cause def __str__(self) -> str: - return "{}: {} (from {})".format( - self.excinfo[0].__name__, self.excinfo[1], self.path - ) + return f"{type(self.cause).__name__}: {self.cause} (from {self.path})" def filter_traceback_for_conftest_import_failure( @@ -132,21 +137,25 @@ def filter_traceback_for_conftest_import_failure( def main( - args: Optional[Union[List[str], "os.PathLike[str]"]] = None, - plugins: Optional[Sequence[Union[str, _PluggyPlugin]]] = None, -) -> Union[int, ExitCode]: + args: list[str] | os.PathLike[str] | None = None, + plugins: Sequence[str | _PluggyPlugin] | None = None, +) -> int | ExitCode: """Perform an in-process test run. - :param args: List of command line arguments. + :param args: + List of command line arguments. If `None` or not given, defaults to reading + arguments directly from the process command line (:data:`sys.argv`). :param plugins: List of plugin objects to be auto-registered during initialization. :returns: An exit code. """ + old_pytest_version = os.environ.get("PYTEST_VERSION") try: + os.environ["PYTEST_VERSION"] = __version__ try: config = _prepareconfig(args, plugins) except ConftestImportFailure as e: - exc_info = ExceptionInfo.from_exc_info(e.excinfo) + exc_info = ExceptionInfo.from_exception(e.cause) tw = TerminalWriter(sys.stderr) tw.line(f"ImportError while loading conftest '{e.path}'.", red=True) exc_info.traceback = exc_info.traceback.filter( @@ -163,9 +172,7 @@ def main( return ExitCode.USAGE_ERROR else: try: - ret: Union[ExitCode, int] = config.hook.pytest_cmdline_main( - config=config - ) + ret: ExitCode | int = config.hook.pytest_cmdline_main(config=config) try: return ExitCode(ret) except ValueError: @@ -177,6 +184,11 @@ def main( for msg in e.args: tw.line(f"ERROR: {msg}\n", red=True) return ExitCode.USAGE_ERROR + finally: + if old_pytest_version is None: + os.environ.pop("PYTEST_VERSION", None) + else: + os.environ["PYTEST_VERSION"] = old_pytest_version def console_main() -> int: @@ -232,7 +244,8 @@ def directory_arg(path: str, optname: str) -> str: "helpconfig", # Provides -p. ) -default_plugins = essential_plugins + ( +default_plugins = ( + *essential_plugins, "python", "terminal", "debugging", @@ -244,7 +257,6 @@ def directory_arg(path: str, optname: str) -> str: "monkeypatch", "recwarn", "pastebin", - "nose", "assertion", "junitxml", "doctest", @@ -253,23 +265,25 @@ def directory_arg(path: str, optname: str) -> str: "setuponly", "setupplan", "stepwise", + "unraisableexception", + "threadexception", "warnings", "logging", "reports", - "python_path", - *(["unraisableexception", "threadexception"] if sys.version_info >= (3, 8) else []), "faulthandler", ) -builtin_plugins = set(default_plugins) -builtin_plugins.add("pytester") -builtin_plugins.add("pytester_assertions") +builtin_plugins = { + *default_plugins, + "pytester", + "pytester_assertions", +} def get_config( - args: Optional[List[str]] = None, - plugins: Optional[Sequence[Union[str, _PluggyPlugin]]] = None, -) -> "Config": + args: list[str] | None = None, + plugins: Sequence[str | _PluggyPlugin] | None = None, +) -> Config: # subsequent calls to main will create a fresh instance pluginmanager = PytestPluginManager() config = Config( @@ -277,7 +291,7 @@ def get_config( invocation_params=Config.InvocationParams( args=args or (), plugins=plugins, - dir=Path.cwd(), + dir=pathlib.Path.cwd(), ), ) @@ -291,7 +305,7 @@ def get_config( return config -def get_plugin_manager() -> "PytestPluginManager": +def get_plugin_manager() -> PytestPluginManager: """Obtain a new instance of the :py:class:`pytest.PytestPluginManager`, with default plugins already loaded. @@ -303,9 +317,9 @@ def get_plugin_manager() -> "PytestPluginManager": def _prepareconfig( - args: Optional[Union[List[str], "os.PathLike[str]"]] = None, - plugins: Optional[Sequence[Union[str, _PluggyPlugin]]] = None, -) -> "Config": + args: list[str] | os.PathLike[str] | None = None, + plugins: Sequence[str | _PluggyPlugin] | None = None, +) -> Config: if args is None: args = sys.argv[1:] elif isinstance(args, os.PathLike): @@ -334,7 +348,7 @@ def _prepareconfig( raise -def _get_directory(path: Path) -> Path: +def _get_directory(path: pathlib.Path) -> pathlib.Path: """Get the directory of a path - itself if already a directory.""" if path.is_file(): return path.parent @@ -345,10 +359,10 @@ def _get_directory(path: Path) -> Path: def _get_legacy_hook_marks( method: Any, hook_type: str, - opt_names: Tuple[str, ...], -) -> Dict[str, bool]: + opt_names: tuple[str, ...], +) -> dict[str, bool]: if TYPE_CHECKING: - # abuse typeguard from importlib to avoid massive method type union thats lacking a alias + # abuse typeguard from importlib to avoid massive method type union that's lacking an alias assert inspect.isroutine(method) known_marks: set[str] = {m.name for m in getattr(method, "pytestmark", [])} must_warn: list[str] = [] @@ -385,19 +399,20 @@ class PytestPluginManager(PluginManager): """ def __init__(self) -> None: - import _pytest.assertion + from _pytest.assertion import DummyRewriteHook + from _pytest.assertion import RewriteHook super().__init__("pytest") # -- State related to local conftest plugins. # All loaded conftest modules. - self._conftest_plugins: Set[types.ModuleType] = set() + self._conftest_plugins: set[types.ModuleType] = set() # All conftest modules applicable for a directory. # This includes the directory's own conftest modules as well # as those of its parent directories. - self._dirpath2confmods: Dict[Path, List[types.ModuleType]] = {} + self._dirpath2confmods: dict[pathlib.Path, list[types.ModuleType]] = {} # Cutoff directory above which conftests are no longer discovered. - self._confcutdir: Optional[Path] = None + self._confcutdir: pathlib.Path | None = None # If set, conftest loading is skipped. self._noconftest = False @@ -406,14 +421,12 @@ def __init__(self) -> None: # session (#9478), often with the same path, so cache it. self._get_directory = lru_cache(256)(_get_directory) - self._duplicatepaths: Set[Path] = set() - # plugins that were explicitly skipped with pytest.skip # list of (module name, skip reason) # previously we would issue a warning when a plugin was skipped, but # since we refactored warnings as first citizens of Config, they are # just stored here to be used later. - self.skipped_plugins: List[Tuple[str, str]] = [] + self.skipped_plugins: list[tuple[str, str]] = [] self.add_hookspecs(_pytest.hookspec) self.register(self) @@ -433,19 +446,22 @@ def __init__(self) -> None: self.enable_tracing() # Config._consider_importhook will set a real object if required. - self.rewrite_hook = _pytest.assertion.DummyRewriteHook() + self.rewrite_hook: RewriteHook = DummyRewriteHook() # Used to know when we are importing conftests after the pytest_configure stage. self._configured = False - def parse_hookimpl_opts(self, plugin: _PluggyPlugin, name: str): + def parse_hookimpl_opts( + self, plugin: _PluggyPlugin, name: str + ) -> HookimplOpts | None: + """:meta private:""" # pytest hooks are always prefixed with "pytest_", # so we avoid accessing possibly non-readable attributes # (see issue #1073). if not name.startswith("pytest_"): - return - # Ignore names which can not be hooks. + return None + # Ignore names which cannot be hooks. if name == "pytest_plugins": - return + return None opts = super().parse_hookimpl_opts(plugin, name) if opts is not None: @@ -454,27 +470,26 @@ def parse_hookimpl_opts(self, plugin: _PluggyPlugin, name: str): method = getattr(plugin, name) # Consider only actual functions for hooks (#3775). if not inspect.isroutine(method): - return + return None # Collect unmarked hooks as long as they have the `pytest_' prefix. - return _get_legacy_hook_marks( + legacy = _get_legacy_hook_marks( method, "impl", ("tryfirst", "trylast", "optionalhook", "hookwrapper") ) + return cast(HookimplOpts, legacy) - def parse_hookspec_opts(self, module_or_class, name: str): + def parse_hookspec_opts(self, module_or_class, name: str) -> HookspecOpts | None: + """:meta private:""" opts = super().parse_hookspec_opts(module_or_class, name) if opts is None: method = getattr(module_or_class, name) if name.startswith("pytest_"): - opts = _get_legacy_hook_marks( - method, - "spec", - ("firstresult", "historic"), + legacy = _get_legacy_hook_marks( + method, "spec", ("firstresult", "historic") ) + opts = cast(HookspecOpts, legacy) return opts - def register( - self, plugin: _PluggyPlugin, name: Optional[str] = None - ) -> Optional[str]: + def register(self, plugin: _PluggyPlugin, name: str | None = None) -> str | None: if name in _pytest.deprecated.DEPRECATED_EXTERNAL_PLUGINS: warnings.warn( PytestConfigWarning( @@ -485,26 +500,30 @@ def register( ) ) return None - ret: Optional[str] = super().register(plugin, name) - if ret: + plugin_name = super().register(plugin, name) + if plugin_name is not None: self.hook.pytest_plugin_registered.call_historic( - kwargs=dict(plugin=plugin, manager=self) + kwargs=dict( + plugin=plugin, + plugin_name=plugin_name, + manager=self, + ) ) if isinstance(plugin, types.ModuleType): self.consider_module(plugin) - return ret + return plugin_name def getplugin(self, name: str): # Support deprecated naming because plugins (xdist e.g.) use it. - plugin: Optional[_PluggyPlugin] = self.get_plugin(name) + plugin: _PluggyPlugin | None = self.get_plugin(name) return plugin def hasplugin(self, name: str) -> bool: """Return whether a plugin with the given name is registered.""" return bool(self.get_plugin(name)) - def pytest_configure(self, config: "Config") -> None: + def pytest_configure(self, config: Config) -> None: """:meta private:""" # XXX now that the pluginmanager exposes hookimpl(tryfirst...) # we should remove tryfirst/trylast as markers. @@ -527,12 +546,15 @@ def pytest_configure(self, config: "Config") -> None: # def _set_initial_conftests( self, - args: Sequence[Union[str, Path]], + args: Sequence[str | pathlib.Path], pyargs: bool, noconftest: bool, - rootpath: Path, - confcutdir: Optional[Path], - importmode: Union[ImportMode, str], + rootpath: pathlib.Path, + confcutdir: pathlib.Path | None, + invocation_dir: pathlib.Path, + importmode: ImportMode | str, + *, + consider_namespace_packages: bool, ) -> None: """Load initial conftest files given a preparsed "namespace". @@ -541,85 +563,120 @@ def _set_initial_conftests( All builtin and 3rd party plugins will have been loaded, however, so common options will not confuse our logic here. """ - current = Path.cwd() - self._confcutdir = absolutepath(current / confcutdir) if confcutdir else None + self._confcutdir = ( + absolutepath(invocation_dir / confcutdir) if confcutdir else None + ) self._noconftest = noconftest self._using_pyargs = pyargs foundanchor = False - for intitial_path in args: - path = str(intitial_path) + for initial_path in args: + path = str(initial_path) # remove node-id syntax i = path.find("::") if i != -1: path = path[:i] - anchor = absolutepath(current / path) + anchor = absolutepath(invocation_dir / path) # Ensure we do not break if what appears to be an anchor - # is in fact a very long option (#10169). - try: - anchor_exists = anchor.exists() - except OSError: # pragma: no cover - anchor_exists = False - if anchor_exists: - self._try_load_conftest(anchor, importmode, rootpath) + # is in fact a very long option (#10169, #11394). + if safe_exists(anchor): + self._try_load_conftest( + anchor, + importmode, + rootpath, + consider_namespace_packages=consider_namespace_packages, + ) foundanchor = True if not foundanchor: - self._try_load_conftest(current, importmode, rootpath) - - def _is_in_confcutdir(self, path: Path) -> bool: - """Whether a path is within the confcutdir. + self._try_load_conftest( + invocation_dir, + importmode, + rootpath, + consider_namespace_packages=consider_namespace_packages, + ) - When false, should not load conftest. - """ + def _is_in_confcutdir(self, path: pathlib.Path) -> bool: + """Whether to consider the given path to load conftests from.""" if self._confcutdir is None: return True + # The semantics here are literally: + # Do not load a conftest if it is found upwards from confcut dir. + # But this is *not* the same as: + # Load only conftests from confcutdir or below. + # At first glance they might seem the same thing, however we do support use cases where + # we want to load conftests that are not found in confcutdir or below, but are found + # in completely different directory hierarchies like packages installed + # in out-of-source trees. + # (see #9767 for a regression where the logic was inverted). return path not in self._confcutdir.parents def _try_load_conftest( - self, anchor: Path, importmode: Union[str, ImportMode], rootpath: Path + self, + anchor: pathlib.Path, + importmode: str | ImportMode, + rootpath: pathlib.Path, + *, + consider_namespace_packages: bool, ) -> None: - self._getconftestmodules(anchor, importmode, rootpath) + self._loadconftestmodules( + anchor, + importmode, + rootpath, + consider_namespace_packages=consider_namespace_packages, + ) # let's also consider test* subdirs if anchor.is_dir(): for x in anchor.glob("test*"): if x.is_dir(): - self._getconftestmodules(x, importmode, rootpath) + self._loadconftestmodules( + x, + importmode, + rootpath, + consider_namespace_packages=consider_namespace_packages, + ) - def _getconftestmodules( - self, path: Path, importmode: Union[str, ImportMode], rootpath: Path - ) -> Sequence[types.ModuleType]: + def _loadconftestmodules( + self, + path: pathlib.Path, + importmode: str | ImportMode, + rootpath: pathlib.Path, + *, + consider_namespace_packages: bool, + ) -> None: if self._noconftest: - return [] + return directory = self._get_directory(path) # Optimization: avoid repeated searches in the same directory. # Assumes always called with same importmode and rootpath. - existing_clist = self._dirpath2confmods.get(directory) - if existing_clist is not None: - return existing_clist + if directory in self._dirpath2confmods: + return - # XXX these days we may rather want to use config.rootpath - # and allow users to opt into looking into the rootdir parent - # directories instead of requiring to specify confcutdir. clist = [] for parent in reversed((directory, *directory.parents)): if self._is_in_confcutdir(parent): conftestpath = parent / "conftest.py" if conftestpath.is_file(): - mod = self._importconftest(conftestpath, importmode, rootpath) + mod = self._importconftest( + conftestpath, + importmode, + rootpath, + consider_namespace_packages=consider_namespace_packages, + ) clist.append(mod) self._dirpath2confmods[directory] = clist - return clist + + def _getconftestmodules(self, path: pathlib.Path) -> Sequence[types.ModuleType]: + directory = self._get_directory(path) + return self._dirpath2confmods.get(directory, ()) def _rget_with_confmod( self, name: str, - path: Path, - importmode: Union[str, ImportMode], - rootpath: Path, - ) -> Tuple[types.ModuleType, Any]: - modules = self._getconftestmodules(path, importmode, rootpath=rootpath) + path: pathlib.Path, + ) -> tuple[types.ModuleType, Any]: + modules = self._getconftestmodules(path) for mod in reversed(modules): try: return mod, getattr(mod, name) @@ -628,22 +685,39 @@ def _rget_with_confmod( raise KeyError(name) def _importconftest( - self, conftestpath: Path, importmode: Union[str, ImportMode], rootpath: Path + self, + conftestpath: pathlib.Path, + importmode: str | ImportMode, + rootpath: pathlib.Path, + *, + consider_namespace_packages: bool, ) -> types.ModuleType: - existing = self.get_plugin(str(conftestpath)) + conftestpath_plugin_name = str(conftestpath) + existing = self.get_plugin(conftestpath_plugin_name) if existing is not None: return cast(types.ModuleType, existing) + # conftest.py files there are not in a Python package all have module + # name "conftest", and thus conflict with each other. Clear the existing + # before loading the new one, otherwise the existing one will be + # returned from the module cache. pkgpath = resolve_package_path(conftestpath) if pkgpath is None: - _ensure_removed_sysmodule(conftestpath.stem) + try: + del sys.modules[conftestpath.stem] + except KeyError: + pass try: - mod = import_path(conftestpath, mode=importmode, root=rootpath) + mod = import_path( + conftestpath, + mode=importmode, + root=rootpath, + consider_namespace_packages=consider_namespace_packages, + ) except Exception as e: assert e.__traceback__ is not None - exc_info = (type(e), e, e.__traceback__) - raise ConftestImportFailure(conftestpath, exc_info) from e + raise ConftestImportFailure(conftestpath, cause=e) from e self._check_non_top_pytest_plugins(mod, conftestpath) @@ -652,16 +726,21 @@ def _importconftest( if dirpath in self._dirpath2confmods: for path, mods in self._dirpath2confmods.items(): if dirpath in path.parents or path == dirpath: - assert mod not in mods + if mod in mods: + raise AssertionError( + f"While trying to load conftest path {conftestpath!s}, " + f"found that the module {mod} is already loaded with path {mod.__file__}. " + "This is not supposed to happen. Please report this issue to pytest." + ) mods.append(mod) self.trace(f"loading conftestmodule {mod!r}") - self.consider_conftest(mod) + self.consider_conftest(mod, registration_name=conftestpath_plugin_name) return mod def _check_non_top_pytest_plugins( self, mod: types.ModuleType, - conftestpath: Path, + conftestpath: pathlib.Path, ) -> None: if ( hasattr(mod, "pytest_plugins") @@ -714,7 +793,7 @@ def consider_pluginarg(self, arg: str) -> None: if arg.startswith("no:"): name = arg[3:] if name in essential_plugins: - raise UsageError("plugin %s cannot be disabled" % name) + raise UsageError(f"plugin {name} cannot be disabled") # PR #4304: remove stepwise if cacheprovider is blocked. if name == "cacheprovider": @@ -726,18 +805,17 @@ def consider_pluginarg(self, arg: str) -> None: self.set_blocked("pytest_" + name) else: name = arg - # Unblock the plugin. None indicates that it has been blocked. - # There is no interface with pluggy for this. - if self._name2plugin.get(name, -1) is None: - del self._name2plugin[name] + # Unblock the plugin. + self.unblock(name) if not name.startswith("pytest_"): - if self._name2plugin.get("pytest_" + name, -1) is None: - del self._name2plugin["pytest_" + name] + self.unblock("pytest_" + name) self.import_plugin(arg, consider_entry_points=True) - def consider_conftest(self, conftestmodule: types.ModuleType) -> None: + def consider_conftest( + self, conftestmodule: types.ModuleType, registration_name: str + ) -> None: """:meta private:""" - self.register(conftestmodule, name=conftestmodule.__file__) + self.register(conftestmodule, name=registration_name) def consider_env(self) -> None: """:meta private:""" @@ -748,7 +826,7 @@ def consider_module(self, mod: types.ModuleType) -> None: self._import_plugin_specs(getattr(mod, "pytest_plugins", [])) def _import_plugin_specs( - self, spec: Union[None, types.ModuleType, str, Sequence[str]] + self, spec: None | types.ModuleType | str | Sequence[str] ) -> None: plugins = _get_plugin_specs_as_list(spec) for import_spec in plugins: @@ -765,7 +843,7 @@ def import_plugin(self, modname: str, consider_entry_points: bool = False) -> No # basename for historic purposes but must be imported with the # _pytest prefix. assert isinstance(modname, str), ( - "module name as text required, got %r" % modname + f"module name as text required, got {modname!r}" ) if self.is_blocked(modname) or self.get_plugin(modname) is not None: return @@ -793,8 +871,8 @@ def import_plugin(self, modname: str, consider_entry_points: bool = False) -> No def _get_plugin_specs_as_list( - specs: Union[None, types.ModuleType, str, Sequence[str]] -) -> List[str]: + specs: None | types.ModuleType | str | Sequence[str], +) -> list[str]: """Parse a plugins specification into a list of plugin names.""" # None means empty. if specs is None: @@ -809,18 +887,10 @@ def _get_plugin_specs_as_list( if isinstance(specs, collections.abc.Sequence): return list(specs) raise UsageError( - "Plugins may be specified as a sequence or a ','-separated string of plugin names. Got: %r" - % specs + f"Plugins may be specified as a sequence or a ','-separated string of plugin names. Got: {specs!r}" ) -def _ensure_removed_sysmodule(modname: str) -> None: - try: - del sys.modules[modname] - except KeyError: - pass - - class Notset: def __repr__(self): return "" @@ -923,19 +993,19 @@ class InvocationParams: Plugins accessing ``InvocationParams`` must be aware of that. """ - args: Tuple[str, ...] + args: tuple[str, ...] """The command-line arguments as passed to :func:`pytest.main`.""" - plugins: Optional[Sequence[Union[str, _PluggyPlugin]]] + plugins: Sequence[str | _PluggyPlugin] | None """Extra plugins, might be `None`.""" - dir: Path - """The directory from which :func:`pytest.main` was invoked.""" + dir: pathlib.Path + """The directory from which :func:`pytest.main` was invoked. :type: pathlib.Path""" def __init__( self, *, args: Iterable[str], - plugins: Optional[Sequence[Union[str, _PluggyPlugin]]], - dir: Path, + plugins: Sequence[str | _PluggyPlugin] | None, + dir: pathlib.Path, ) -> None: object.__setattr__(self, "args", tuple(args)) object.__setattr__(self, "plugins", plugins) @@ -950,21 +1020,26 @@ class ArgsSource(enum.Enum): #: Command line arguments. ARGS = enum.auto() #: Invocation directory. - INCOVATION_DIR = enum.auto() + INVOCATION_DIR = enum.auto() + INCOVATION_DIR = INVOCATION_DIR # backwards compatibility alias #: 'testpaths' configuration value. TESTPATHS = enum.auto() + # Set by cacheprovider plugin. + cache: Cache + def __init__( self, pluginmanager: PytestPluginManager, *, - invocation_params: Optional[InvocationParams] = None, + invocation_params: InvocationParams | None = None, ) -> None: - from .argparsing import Parser, FILE_OR_DIR + from .argparsing import FILE_OR_DIR + from .argparsing import Parser if invocation_params is None: invocation_params = self.InvocationParams( - args=(), plugins=None, dir=Path.cwd() + args=(), plugins=None, dir=pathlib.Path.cwd() ) self.option = argparse.Namespace() @@ -1000,29 +1075,22 @@ def __init__( # Deprecated alias. Was never public. Can be removed in a few releases. self._store = self.stash - from .compat import PathAwareHookProxy - self.trace = self.pluginmanager.trace.root.get("config") - self.hook = PathAwareHookProxy(self.pluginmanager.hook) - self._inicache: Dict[str, Any] = {} + self.hook: pluggy.HookRelay = PathAwareHookProxy(self.pluginmanager.hook) # type: ignore[assignment] + self._inicache: dict[str, Any] = {} self._override_ini: Sequence[str] = () - self._opt2dest: Dict[str, str] = {} - self._cleanup: List[Callable[[], None]] = [] + self._opt2dest: dict[str, str] = {} + self._cleanup_stack = contextlib.ExitStack() self.pluginmanager.register(self, "pytestconfig") self._configured = False self.hook.pytest_addoption.call_historic( kwargs=dict(parser=self._parser, pluginmanager=self.pluginmanager) ) self.args_source = Config.ArgsSource.ARGS - self.args: List[str] = [] - - if TYPE_CHECKING: - from _pytest.cacheprovider import Cache - - self.cache: Optional[Cache] = None + self.args: list[str] = [] @property - def rootpath(self) -> Path: + def rootpath(self) -> pathlib.Path: """The path to the :ref:`rootdir `. :type: pathlib.Path @@ -1032,45 +1100,48 @@ def rootpath(self) -> Path: return self._rootpath @property - def inipath(self) -> Optional[Path]: + def inipath(self) -> pathlib.Path | None: """The path to the :ref:`configfile `. - :type: Optional[pathlib.Path] - .. versionadded:: 6.1 """ return self._inipath def add_cleanup(self, func: Callable[[], None]) -> None: """Add a function to be called when the config object gets out of - use (usually coinciding with pytest_unconfigure).""" - self._cleanup.append(func) + use (usually coinciding with pytest_unconfigure). + """ + self._cleanup_stack.callback(func) def _do_configure(self) -> None: assert not self._configured self._configured = True - with warnings.catch_warnings(): - warnings.simplefilter("default") - self.hook.pytest_configure.call_historic(kwargs=dict(config=self)) + self.hook.pytest_configure.call_historic(kwargs=dict(config=self)) def _ensure_unconfigure(self) -> None: - if self._configured: - self._configured = False - self.hook.pytest_unconfigure(config=self) - self.hook.pytest_configure._call_history = [] - while self._cleanup: - fin = self._cleanup.pop() - fin() + try: + if self._configured: + self._configured = False + try: + self.hook.pytest_unconfigure(config=self) + finally: + self.hook.pytest_configure._call_history = [] + finally: + try: + self._cleanup_stack.close() + finally: + self._cleanup_stack = contextlib.ExitStack() def get_terminal_writer(self) -> TerminalWriter: - terminalreporter: TerminalReporter = self.pluginmanager.get_plugin( + terminalreporter: TerminalReporter | None = self.pluginmanager.get_plugin( "terminalreporter" ) + assert terminalreporter is not None return terminalreporter._tw def pytest_cmdline_parse( - self, pluginmanager: PytestPluginManager, args: List[str] - ) -> "Config": + self, pluginmanager: PytestPluginManager, args: list[str] + ) -> Config: try: self.parse(args) except UsageError: @@ -1096,10 +1167,10 @@ def pytest_cmdline_parse( def notify_exception( self, excinfo: ExceptionInfo[BaseException], - option: Optional[argparse.Namespace] = None, + option: argparse.Namespace | None = None, ) -> None: if option and getattr(option, "fulltrace", False): - style: _TracebackStyle = "long" + style: TracebackStyle = "long" else: style = "native" excrepr = excinfo.getrepr( @@ -1108,18 +1179,22 @@ def notify_exception( res = self.hook.pytest_internalerror(excrepr=excrepr, excinfo=excinfo) if not any(res): for line in str(excrepr).split("\n"): - sys.stderr.write("INTERNALERROR> %s\n" % line) + sys.stderr.write(f"INTERNALERROR> {line}\n") sys.stderr.flush() def cwd_relative_nodeid(self, nodeid: str) -> str: # nodeid's are relative to the rootpath, compute relative to cwd. if self.invocation_params.dir != self.rootpath: - fullpath = self.rootpath / nodeid - nodeid = bestrelpath(self.invocation_params.dir, fullpath) + base_path_part, *nodeid_part = nodeid.split("::") + # Only process path part + fullpath = self.rootpath / base_path_part + relative_path = bestrelpath(self.invocation_params.dir, fullpath) + + nodeid = "::".join([relative_path, *nodeid_part]) return nodeid @classmethod - def fromdictargs(cls, option_dict, args) -> "Config": + def fromdictargs(cls, option_dict, args) -> Config: """Constructor usable for subprocesses.""" config = get_config(args) config.option.__dict__.update(option_dict) @@ -1128,7 +1203,7 @@ def fromdictargs(cls, option_dict, args) -> "Config": config.pluginmanager.consider_pluginarg(x) return config - def _processopt(self, opt: "Argument") -> None: + def _processopt(self, opt: Argument) -> None: for name in opt._short_opts + opt._long_opts: self._opt2dest[name] = opt.dest @@ -1137,7 +1212,7 @@ def _processopt(self, opt: "Argument") -> None: setattr(self.option, opt.dest, opt.default) @hookimpl(trylast=True) - def pytest_load_initial_conftests(self, early_config: "Config") -> None: + def pytest_load_initial_conftests(self, early_config: Config) -> None: # We haven't fully parsed the command line arguments yet, so # early_config.args it not set yet. But we need it for # discovering the initial conftests. So "pre-run" the logic here. @@ -1156,7 +1231,11 @@ def pytest_load_initial_conftests(self, early_config: "Config") -> None: noconftest=early_config.known_args_namespace.noconftest, rootpath=early_config.rootpath, confcutdir=early_config.known_args_namespace.confcutdir, + invocation_dir=early_config.invocation_params.dir, importmode=early_config.known_args_namespace.importmode, + consider_namespace_packages=early_config.getini( + "consider_namespace_packages" + ), ) def _initini(self, args: Sequence[str]) -> None: @@ -1164,10 +1243,10 @@ def _initini(self, args: Sequence[str]) -> None: args, namespace=copy.copy(self.option) ) rootpath, inipath, inicfg = determine_setup( - ns.inifilename, - ns.file_or_dir + unknown_args, + inifile=ns.inifilename, + args=ns.file_or_dir + unknown_args, rootdir_cmd_arg=ns.rootdir or None, - config=self, + invocation_dir=self.invocation_params.dir, ) self._rootpath = rootpath self._inipath = inipath @@ -1176,6 +1255,9 @@ def _initini(self, args: Sequence[str]) -> None: self._parser.extra_info["inifile"] = str(self.inipath) self._parser.addini("addopts", "Extra command line options", "args") self._parser.addini("minversion", "Minimally required pytest version") + self._parser.addini( + "pythonpath", type="paths", help="Add paths to sys.path", default=[] + ) self._parser.addini( "required_plugins", "Plugins that must be present for pytest to run", @@ -1193,6 +1275,10 @@ def _consider_importhook(self, args: Sequence[str]) -> None: """ ns, unknown_args = self._parser.parse_known_and_unknown_args(args) mode = getattr(ns, "assertmode", "plain") + + disable_autoload = getattr(ns, "disable_plugin_autoload", False) or bool( + os.environ.get("PYTEST_DISABLE_PLUGIN_AUTOLOAD") + ) if mode == "rewrite": import _pytest.assertion @@ -1201,22 +1287,25 @@ def _consider_importhook(self, args: Sequence[str]) -> None: except SystemError: mode = "plain" else: - self._mark_plugins_for_rewrite(hook) + self._mark_plugins_for_rewrite(hook, disable_autoload) self._warn_about_missing_assertion(mode) - def _mark_plugins_for_rewrite(self, hook) -> None: + def _mark_plugins_for_rewrite( + self, hook: AssertionRewritingHook, disable_autoload: bool + ) -> None: """Given an importhook, mark for rewrite any top-level modules or packages in the distribution package for all pytest plugins.""" self.pluginmanager.rewrite_hook = hook - if os.environ.get("PYTEST_DISABLE_PLUGIN_AUTOLOAD"): - # We don't autoload from setuptools entry points, no need to continue. + if disable_autoload: + # We don't autoload from distribution package entry points, + # no need to continue. return package_files = ( str(file) - for dist in importlib_metadata.distributions() + for dist in importlib.metadata.distributions() if any(ep.group == "pytest11" for ep in dist.entry_points) for file in dist.files or [] ) @@ -1224,7 +1313,19 @@ def _mark_plugins_for_rewrite(self, hook) -> None: for name in _iter_rewritable_modules(package_files): hook.mark_rewrite(name) - def _validate_args(self, args: List[str], via: str) -> List[str]: + def _configure_python_path(self) -> None: + # `pythonpath = a b` will set `sys.path` to `[a, b, x, y, z, ...]` + for path in reversed(self.getini("pythonpath")): + sys.path.insert(0, str(path)) + self.add_cleanup(self._unconfigure_python_path) + + def _unconfigure_python_path(self) -> None: + for path in self.getini("pythonpath"): + path_str = str(path) + if path_str in sys.path: + sys.path.remove(path_str) + + def _validate_args(self, args: list[str], via: str) -> list[str]: """Validate known args.""" self._parser._config_source_hint = via # type: ignore try: @@ -1239,16 +1340,18 @@ def _validate_args(self, args: List[str], via: str) -> List[str]: def _decide_args( self, *, - args: List[str], - pyargs: List[str], - testpaths: List[str], - invocation_dir: Path, - rootpath: Path, + args: list[str], + pyargs: bool, + testpaths: list[str], + invocation_dir: pathlib.Path, + rootpath: pathlib.Path, warn: bool, - ) -> Tuple[List[str], ArgsSource]: + ) -> tuple[list[str], ArgsSource]: """Decide the args (initial paths/nodeids) to use given the relevant inputs. :param warn: Whether can issue warnings. + + :returns: The args and the args source. Guaranteed to be non-empty. """ if args: source = Config.ArgsSource.ARGS @@ -1275,11 +1378,11 @@ def _decide_args( else: result = [] if not result: - source = Config.ArgsSource.INCOVATION_DIR + source = Config.ArgsSource.INVOCATION_DIR result = [str(invocation_dir)] return result, source - def _preparse(self, args: List[str], addopts: bool = True) -> None: + def _preparse(self, args: list[str], addopts: bool = True) -> None: if addopts: env_addopts = os.environ.get("PYTEST_ADDOPTS", "") if len(env_addopts): @@ -1298,11 +1401,17 @@ def _preparse(self, args: List[str], addopts: bool = True) -> None: ) self._checkversion() self._consider_importhook(args) + self._configure_python_path() self.pluginmanager.consider_preparse(args, exclude_only=False) - if not os.environ.get("PYTEST_DISABLE_PLUGIN_AUTOLOAD"): - # Don't autoload from setuptools entry point. Only explicitly specified - # plugins are going to be loaded. + if ( + not os.environ.get("PYTEST_DISABLE_PLUGIN_AUTOLOAD") + and not self.known_args_namespace.disable_plugin_autoload + ): + # Autoloading from distribution package entry point has + # not been disabled. self.pluginmanager.load_setuptools_entrypoints("pytest11") + # Otherwise only plugins explicitly specified in PYTEST_PLUGINS + # are going to be loaded. self.pluginmanager.consider_env() self.known_args_namespace = self._parser.parse_known_args( @@ -1312,11 +1421,6 @@ def _preparse(self, args: List[str], addopts: bool = True) -> None: self._validate_plugins() self._warn_about_skipped_plugins() - if self.known_args_namespace.strict: - self.issue_config_time_warning( - _pytest.deprecated.STRICT_OPTION, stacklevel=2 - ) - if self.known_args_namespace.confcutdir is None: if self.inipath is not None: confcutdir = str(self.inipath.parent) @@ -1330,7 +1434,7 @@ def _preparse(self, args: List[str], addopts: bool = True) -> None: except ConftestImportFailure as e: if self.known_args_namespace.help or self.known_args_namespace.version: # we don't want to prevent --help/--version to work - # so just let is pass and print a warning at the end + # so just let it pass and print a warning at the end self.issue_config_time_warning( PytestConfigWarning(f"could not load initial conftests: {e.path}"), stacklevel=2, @@ -1338,12 +1442,14 @@ def _preparse(self, args: List[str], addopts: bool = True) -> None: else: raise - @hookimpl(hookwrapper=True) - def pytest_collection(self) -> Generator[None, None, None]: + @hookimpl(wrapper=True) + def pytest_collection(self) -> Generator[None, object, object]: # Validate invalid ini keys after collection is done so we take in account # options added by late-loading conftest files. - yield - self._validate_config_options() + try: + return (yield) + finally: + self._validate_config_options() def _checkversion(self) -> None: import pytest @@ -1355,17 +1461,12 @@ def _checkversion(self) -> None: if not isinstance(minver, str): raise pytest.UsageError( - "%s: 'minversion' must be a single value" % self.inipath + f"{self.inipath}: 'minversion' must be a single value" ) if Version(minver) > Version(pytest.__version__): raise pytest.UsageError( - "%s: 'minversion' requires pytest-%s, actual pytest-%s'" - % ( - self.inipath, - minver, - pytest.__version__, - ) + f"{self.inipath}: 'minversion' requires pytest-{minver}, actual pytest-{pytest.__version__}'" ) def _validate_config_options(self) -> None: @@ -1378,8 +1479,9 @@ def _validate_plugins(self) -> None: return # Imported lazily to improve start-up time. + from packaging.requirements import InvalidRequirement + from packaging.requirements import Requirement from packaging.version import Version - from packaging.requirements import InvalidRequirement, Requirement plugin_info = self.pluginmanager.list_plugin_distinfo() plugin_dist_info = {dist.project_name: dist.version for _, dist in plugin_info} @@ -1410,21 +1512,19 @@ def _warn_or_fail_if_strict(self, message: str) -> None: self.issue_config_time_warning(PytestConfigWarning(message), stacklevel=3) - def _get_unknown_ini_keys(self) -> List[str]: + def _get_unknown_ini_keys(self) -> list[str]: parser_inicfg = self._parser._inidict return [name for name in self.inicfg if name not in parser_inicfg] - def parse(self, args: List[str], addopts: bool = True) -> None: + def parse(self, args: list[str], addopts: bool = True) -> None: # Parse given cmdline arguments into this config object. - assert ( - self.args == [] - ), "can only parse cmdline args at most once per Config object" + assert self.args == [], ( + "can only parse cmdline args at most once per Config object" + ) self.hook.pytest_addhooks.call_historic( kwargs=dict(pluginmanager=self.pluginmanager) ) self._preparse(args, addopts=addopts) - # XXX deprecated hook: - self.hook.pytest_cmdline_preparse(config=self, args=args) self._parser.after_preparse = True # type: ignore try: args = self._parser.parse_setoption( @@ -1445,7 +1545,7 @@ def issue_config_time_warning(self, warning: Warning, stacklevel: int) -> None: """Issue and handle a warning during the "configure" stage. During ``pytest_configure`` we can't capture warnings using the ``catch_warnings_for_item`` - function because it is not possible to have hookwrappers around ``pytest_configure``. + function because it is not possible to have hook wrappers around ``pytest_configure``. This function is mainly intended for plugins that need to issue warnings during ``pytest_configure`` (or similar stages). @@ -1484,9 +1584,32 @@ def addinivalue_line(self, name: str, line: str) -> None: assert isinstance(x, list) x.append(line) # modifies the cached list inline - def getini(self, name: str): + def getini(self, name: str) -> Any: """Return configuration value from an :ref:`ini file `. + If a configuration value is not defined in an + :ref:`ini file `, then the ``default`` value provided while + registering the configuration through + :func:`parser.addini ` will be returned. + Please note that you can even provide ``None`` as a valid + default value. + + If ``default`` is not provided while registering using + :func:`parser.addini `, then a default value + based on the ``type`` parameter passed to + :func:`parser.addini ` will be returned. + The default values based on ``type`` are: + ``paths``, ``pathlist``, ``args`` and ``linelist`` : empty list ``[]`` + ``bool`` : ``False`` + ``string`` : empty string ``""`` + ``int`` : ``0`` + ``float`` : ``0.0`` + + If neither the ``default`` nor the ``type`` parameter is passed + while registering the configuration through + :func:`parser.addini `, then the configuration + is treated as a string and a default empty string '' is returned. + If the specified name hasn't been registered through a prior :func:`parser.addini ` call (usually from a plugin), a ValueError is raised. @@ -1499,9 +1622,11 @@ def getini(self, name: str): # Meant for easy monkeypatching by legacypath plugin. # Can be inlined back (with no cover removed) once legacypath is gone. - def _getini_unknown_type(self, name: str, type: str, value: Union[str, List[str]]): - msg = f"unknown configuration type: {type}" - raise ValueError(msg, value) # pragma: no cover + def _getini_unknown_type(self, name: str, type: str, value: object): + msg = ( + f"Option {name} has unknown configuration type {type} with value {value!r}" + ) + raise ValueError(msg) # pragma: no cover def _getini(self, name: str): try: @@ -1513,11 +1638,7 @@ def _getini(self, name: str): try: value = self.inicfg[name] except KeyError: - if default is not None: - return default - if type is None: - return "" - return [] + return default else: value = override_value # Coerce the values based on types. @@ -1536,9 +1657,11 @@ def _getini(self, name: str): # in this case, we already have a list ready to use. # if type == "paths": - # TODO: This assert is probably not valid in all cases. - assert self.inipath is not None - dp = self.inipath.parent + dp = ( + self.inipath.parent + if self.inipath is not None + else self.invocation_params.dir + ) input_values = shlex.split(value) if isinstance(value, str) else value return [dp / x for x in input_values] elif type == "args": @@ -1552,33 +1675,43 @@ def _getini(self, name: str): return _strtobool(str(value).strip()) elif type == "string": return value + elif type == "int": + if not isinstance(value, str): + raise TypeError( + f"Expected an int string for option {name} of type integer, but got: {value!r}" + ) from None + return int(value) + elif type == "float": + if not isinstance(value, str): + raise TypeError( + f"Expected a float string for option {name} of type float, but got: {value!r}" + ) from None + return float(value) elif type is None: return value else: return self._getini_unknown_type(name, type, value) def _getconftest_pathlist( - self, name: str, path: Path, rootpath: Path - ) -> Optional[List[Path]]: + self, name: str, path: pathlib.Path + ) -> list[pathlib.Path] | None: try: - mod, relroots = self.pluginmanager._rget_with_confmod( - name, path, self.getoption("importmode"), rootpath - ) + mod, relroots = self.pluginmanager._rget_with_confmod(name, path) except KeyError: return None assert mod.__file__ is not None - modpath = Path(mod.__file__).parent - values: List[Path] = [] + modpath = pathlib.Path(mod.__file__).parent + values: list[pathlib.Path] = [] for relroot in relroots: if isinstance(relroot, os.PathLike): - relroot = Path(relroot) + relroot = pathlib.Path(relroot) else: relroot = relroot.replace("/", os.sep) relroot = absolutepath(modpath / relroot) values.append(relroot) return values - def _get_override_ini_value(self, name: str) -> Optional[str]: + def _get_override_ini_value(self, name: str) -> str | None: value = None # override_ini is a list of "ini=value" options. # Always use the last item if multiple values are set for same ini-name, @@ -1588,23 +1721,22 @@ def _get_override_ini_value(self, name: str) -> Optional[str]: key, user_ini_value = ini_config.split("=", 1) except ValueError as e: raise UsageError( - "-o/--override-ini expects option=value style (got: {!r}).".format( - ini_config - ) + f"-o/--override-ini expects option=value style (got: {ini_config!r})." ) from e else: if key == name: value = user_ini_value return value - def getoption(self, name: str, default=notset, skip: bool = False): + def getoption(self, name: str, default: Any = notset, skip: bool = False): """Return command line option value. - :param name: Name of the option. You may also specify + :param name: Name of the option. You may also specify the literal ``--OPT`` option instead of the "dest" option name. - :param default: Default value if no option of that name exists. - :param skip: If True, raise pytest.skip if option does not exists - or has a None value. + :param default: Fallback value if no option of that name is **declared** via :hook:`pytest_addoption`. + Note this parameter will be ignored when the option is **declared** even if the option's value is ``None``. + :param skip: If ``True``, raise :func:`pytest.skip` if option is undeclared or has a ``None`` value. + Note that even if ``True``, if a default was specified it will be returned instead of a skip. """ name = self._opt2dest.get(name, name) try: @@ -1629,6 +1761,80 @@ def getvalueorskip(self, name: str, path=None): """Deprecated, use getoption(skip=True) instead.""" return self.getoption(name, skip=True) + #: Verbosity type for failed assertions (see :confval:`verbosity_assertions`). + VERBOSITY_ASSERTIONS: Final = "assertions" + #: Verbosity type for test case execution (see :confval:`verbosity_test_cases`). + VERBOSITY_TEST_CASES: Final = "test_cases" + _VERBOSITY_INI_DEFAULT: Final = "auto" + + def get_verbosity(self, verbosity_type: str | None = None) -> int: + r"""Retrieve the verbosity level for a fine-grained verbosity type. + + :param verbosity_type: Verbosity type to get level for. If a level is + configured for the given type, that value will be returned. If the + given type is not a known verbosity type, the global verbosity + level will be returned. If the given type is None (default), the + global verbosity level will be returned. + + To configure a level for a fine-grained verbosity type, the + configuration file should have a setting for the configuration name + and a numeric value for the verbosity level. A special value of "auto" + can be used to explicitly use the global verbosity level. + + Example: + + .. code-block:: ini + + # content of pytest.ini + [pytest] + verbosity_assertions = 2 + + .. code-block:: console + + pytest -v + + .. code-block:: python + + print(config.get_verbosity()) # 1 + print(config.get_verbosity(Config.VERBOSITY_ASSERTIONS)) # 2 + """ + global_level = self.getoption("verbose", default=0) + assert isinstance(global_level, int) + if verbosity_type is None: + return global_level + + ini_name = Config._verbosity_ini_name(verbosity_type) + if ini_name not in self._parser._inidict: + return global_level + + level = self.getini(ini_name) + if level == Config._VERBOSITY_INI_DEFAULT: + return global_level + + return int(level) + + @staticmethod + def _verbosity_ini_name(verbosity_type: str) -> str: + return f"verbosity_{verbosity_type}" + + @staticmethod + def _add_verbosity_ini(parser: Parser, verbosity_type: str, help: str) -> None: + """Add a output verbosity configuration option for the given output type. + + :param parser: Parser for command line arguments and ini-file values. + :param verbosity_type: Fine-grained verbosity category. + :param help: Description of the output this type controls. + + The value should be retrieved via a call to + :py:func:`config.get_verbosity(type) `. + """ + parser.addini( + Config._verbosity_ini_name(verbosity_type), + help=help, + type="string", + default=Config._VERBOSITY_INI_DEFAULT, + ) + def _warn_about_missing_assertion(self, mode: str) -> None: if not _assertion_supported(): if mode == "plain": @@ -1668,7 +1874,7 @@ def _assertion_supported() -> bool: def create_terminal_writer( - config: Config, file: Optional[TextIO] = None + config: Config, file: TextIO | None = None ) -> TerminalWriter: """Create a TerminalWriter instance configured according to the options in the config object. @@ -1712,7 +1918,7 @@ def _strtobool(val: str) -> bool: @lru_cache(maxsize=50) def parse_warning_filter( arg: str, *, escape: bool -) -> Tuple["warnings._ActionKind", str, Type[Warning], str, int]: +) -> tuple[warnings._ActionKind, str, type[Warning], str, int]: """Parse a warnings filter string. This is copied from warnings._setoption with the following changes: @@ -1754,15 +1960,15 @@ def parse_warning_filter( parts.append("") action_, message, category_, module, lineno_ = (s.strip() for s in parts) try: - action: "warnings._ActionKind" = warnings._getaction(action_) # type: ignore[attr-defined] + action: warnings._ActionKind = warnings._getaction(action_) # type: ignore[attr-defined] except warnings._OptionError as e: - raise UsageError(error_template.format(error=str(e))) + raise UsageError(error_template.format(error=str(e))) from None try: - category: Type[Warning] = _resolve_warning_category(category_) + category: type[Warning] = _resolve_warning_category(category_) except Exception: exc_info = ExceptionInfo.from_current() exception_text = exc_info.getrepr(style="native") - raise UsageError(error_template.format(error=exception_text)) + raise UsageError(error_template.format(error=exception_text)) from None if message and escape: message = re.escape(message) if module and escape: @@ -1775,13 +1981,20 @@ def parse_warning_filter( except ValueError as e: raise UsageError( error_template.format(error=f"invalid lineno {lineno_!r}: {e}") - ) + ) from None else: lineno = 0 + try: + re.compile(message) + re.compile(module) + except re.error as e: + raise UsageError( + error_template.format(error=f"Invalid regex {e.pattern!r}: {e}") + ) from None return action, message, category, module, lineno -def _resolve_warning_category(category: str) -> Type[Warning]: +def _resolve_warning_category(category: str) -> type[Warning]: """ Copied from warnings._getcategory, but changed so it lets exceptions (specially ImportErrors) propagate so we can get access to their tracebacks (#9218). @@ -1800,7 +2013,7 @@ def _resolve_warning_category(category: str) -> Type[Warning]: cat = getattr(m, klass) if not issubclass(cat, Warning): raise UsageError(f"{cat} is not a Warning subclass") - return cast(Type[Warning], cat) + return cast(type[Warning], cat) def apply_warning_filters( diff --git a/src/_pytest/config/argparsing.py b/src/_pytest/config/argparsing.py index d3f01916b61..8d4ed823325 100644 --- a/src/_pytest/config/argparsing.py +++ b/src/_pytest/config/argparsing.py @@ -1,35 +1,33 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + import argparse +from collections.abc import Callable +from collections.abc import Mapping +from collections.abc import Sequence import os -import sys -import warnings -from gettext import gettext from typing import Any -from typing import Callable from typing import cast -from typing import Dict -from typing import List -from typing import Mapping +from typing import final +from typing import Literal from typing import NoReturn -from typing import Optional -from typing import Sequence -from typing import Tuple -from typing import TYPE_CHECKING -from typing import Union import _pytest._io -from _pytest.compat import final from _pytest.config.exceptions import UsageError -from _pytest.deprecated import ARGUMENT_PERCENT_DEFAULT -from _pytest.deprecated import ARGUMENT_TYPE_STR -from _pytest.deprecated import ARGUMENT_TYPE_STR_CHOICE from _pytest.deprecated import check_ispytest -if TYPE_CHECKING: - from typing_extensions import Literal FILE_OR_DIR = "file_or_dir" +class NotSet: + def __repr__(self) -> str: + return "" + + +NOT_SET = NotSet() + + @final class Parser: """Parser for command line arguments and ini-file values. @@ -38,32 +36,32 @@ class Parser: there's an error processing the command line arguments. """ - prog: Optional[str] = None + prog: str | None = None def __init__( self, - usage: Optional[str] = None, - processopt: Optional[Callable[["Argument"], None]] = None, + usage: str | None = None, + processopt: Callable[[Argument], None] | None = None, *, _ispytest: bool = False, ) -> None: check_ispytest(_ispytest) self._anonymous = OptionGroup("Custom options", parser=self, _ispytest=True) - self._groups: List[OptionGroup] = [] + self._groups: list[OptionGroup] = [] self._processopt = processopt self._usage = usage - self._inidict: Dict[str, Tuple[str, Optional[str], Any]] = {} - self._ininames: List[str] = [] - self.extra_info: Dict[str, Any] = {} + self._inidict: dict[str, tuple[str, str | None, Any]] = {} + self._ininames: list[str] = [] + self.extra_info: dict[str, Any] = {} - def processoption(self, option: "Argument") -> None: + def processoption(self, option: Argument) -> None: if self._processopt: if option.dest: self._processopt(option) def getgroup( - self, name: str, description: str = "", after: Optional[str] = None - ) -> "OptionGroup": + self, name: str, description: str = "", after: str | None = None + ) -> OptionGroup: """Get (or create) a named option Group. :param name: Name of the option group. @@ -93,7 +91,7 @@ def addoption(self, *opts: str, **attrs: Any) -> None: :param opts: Option names, can be short or long options. :param attrs: - Same attributes as the argparse library's :py:func:`add_argument() + Same attributes as the argparse library's :meth:`add_argument() ` function accepts. After command line parsing, options are available on the pytest config @@ -105,8 +103,8 @@ def addoption(self, *opts: str, **attrs: Any) -> None: def parse( self, - args: Sequence[Union[str, "os.PathLike[str]"]], - namespace: Optional[argparse.Namespace] = None, + args: Sequence[str | os.PathLike[str]], + namespace: argparse.Namespace | None = None, ) -> argparse.Namespace: from _pytest._argcomplete import try_argcomplete @@ -115,11 +113,11 @@ def parse( strargs = [os.fspath(x) for x in args] return self.optparser.parse_args(strargs, namespace=namespace) - def _getparser(self) -> "MyOptionParser": + def _getparser(self) -> MyOptionParser: from _pytest._argcomplete import filescompleter optparser = MyOptionParser(self, self.extra_info, prog=self.prog) - groups = self._groups + [self._anonymous] + groups = [*self._groups, self._anonymous] for group in groups: if group.options: desc = group.description or group.name @@ -136,19 +134,19 @@ def _getparser(self) -> "MyOptionParser": def parse_setoption( self, - args: Sequence[Union[str, "os.PathLike[str]"]], + args: Sequence[str | os.PathLike[str]], option: argparse.Namespace, - namespace: Optional[argparse.Namespace] = None, - ) -> List[str]: + namespace: argparse.Namespace | None = None, + ) -> list[str]: parsedoption = self.parse(args, namespace=namespace) for name, value in parsedoption.__dict__.items(): setattr(option, name, value) - return cast(List[str], getattr(parsedoption, FILE_OR_DIR)) + return cast(list[str], getattr(parsedoption, FILE_OR_DIR)) def parse_known_args( self, - args: Sequence[Union[str, "os.PathLike[str]"]], - namespace: Optional[argparse.Namespace] = None, + args: Sequence[str | os.PathLike[str]], + namespace: argparse.Namespace | None = None, ) -> argparse.Namespace: """Parse the known arguments at this point. @@ -158,9 +156,9 @@ def parse_known_args( def parse_known_and_unknown_args( self, - args: Sequence[Union[str, "os.PathLike[str]"]], - namespace: Optional[argparse.Namespace] = None, - ) -> Tuple[argparse.Namespace, List[str]]: + args: Sequence[str | os.PathLike[str]], + namespace: argparse.Namespace | None = None, + ) -> tuple[argparse.Namespace, list[str]]: """Parse the known arguments at this point, and also return the remaining unknown arguments. @@ -176,10 +174,11 @@ def addini( self, name: str, help: str, - type: Optional[ - "Literal['string', 'paths', 'pathlist', 'args', 'linelist', 'bool']" - ] = None, - default: Any = None, + type: Literal[ + "string", "paths", "pathlist", "args", "linelist", "bool", "int", "float" + ] + | None = None, + default: Any = NOT_SET, ) -> None: """Register an ini-file option. @@ -194,10 +193,23 @@ def addini( * ``linelist``: a list of strings, separated by line breaks * ``paths``: a list of :class:`pathlib.Path`, separated as in a shell * ``pathlist``: a list of ``py.path``, separated as in a shell + * ``int``: an integer + * ``float``: a floating-point number + + .. versionadded:: 8.4 + + The ``float`` and ``int`` types. + + For ``paths`` and ``pathlist`` types, they are considered relative to the ini-file. + In case the execution is happening without an ini-file defined, + they will be considered relative to the current working directory (for example with ``--override-ini``). .. versionadded:: 7.0 The ``paths`` variable type. + .. versionadded:: 8.1 + Use the current working directory to resolve ``paths`` and ``pathlist`` in the absence of an ini-file. + Defaults to ``string`` if ``None`` or not passed. :param default: Default value if no ini-file option exists but is queried. @@ -205,16 +217,53 @@ def addini( The value of ini-variables can be retrieved via a call to :py:func:`config.getini(name) `. """ - assert type in (None, "string", "paths", "pathlist", "args", "linelist", "bool") + assert type in ( + None, + "string", + "paths", + "pathlist", + "args", + "linelist", + "bool", + "int", + "float", + ) + if default is NOT_SET: + default = get_ini_default_for_type(type) + self._inidict[name] = (help, type, default) self._ininames.append(name) +def get_ini_default_for_type( + type: Literal[ + "string", "paths", "pathlist", "args", "linelist", "bool", "int", "float" + ] + | None, +) -> Any: + """ + Used by addini to get the default value for a given ini-option type, when + default is not supplied. + """ + if type is None: + return "" + elif type in ("paths", "pathlist", "args", "linelist"): + return [] + elif type == "bool": + return False + elif type == "int": + return 0 + elif type == "float": + return 0.0 + else: + return "" + + class ArgumentError(Exception): """Raised if an Argument instance is created with invalid or inconsistent arguments.""" - def __init__(self, msg: str, option: Union["Argument", str]) -> None: + def __init__(self, msg: str, option: Argument | str) -> None: self.msg = msg self.option_id = str(option) @@ -234,46 +283,22 @@ class Argument: https://docs.python.org/3/library/optparse.html#optparse-standard-option-types """ - _typ_map = {"int": int, "string": str, "float": float, "complex": complex} - def __init__(self, *names: str, **attrs: Any) -> None: """Store params in private vars for use in add_argument.""" self._attrs = attrs - self._short_opts: List[str] = [] - self._long_opts: List[str] = [] - if "%default" in (attrs.get("help") or ""): - warnings.warn(ARGUMENT_PERCENT_DEFAULT, stacklevel=3) + self._short_opts: list[str] = [] + self._long_opts: list[str] = [] try: - typ = attrs["type"] + self.type = attrs["type"] except KeyError: pass - else: - # This might raise a keyerror as well, don't want to catch that. - if isinstance(typ, str): - if typ == "choice": - warnings.warn( - ARGUMENT_TYPE_STR_CHOICE.format(typ=typ, names=names), - stacklevel=4, - ) - # argparse expects a type here take it from - # the type of the first element - attrs["type"] = type(attrs["choices"][0]) - else: - warnings.warn( - ARGUMENT_TYPE_STR.format(typ=typ, names=names), stacklevel=4 - ) - attrs["type"] = Argument._typ_map[typ] - # Used in test_parseopt -> test_parse_defaultgetter. - self.type = attrs["type"] - else: - self.type = typ try: # Attribute existence is tested in Config._processopt. self.default = attrs["default"] except KeyError: pass self._set_opt_strings(names) - dest: Optional[str] = attrs.get("dest") + dest: str | None = attrs.get("dest") if dest: self.dest = dest elif self._long_opts: @@ -285,7 +310,7 @@ def __init__(self, *names: str, **attrs: Any) -> None: self.dest = "???" # Needed for the error repr. raise ArgumentError("need a long or short option", self) from e - def names(self) -> List[str]: + def names(self) -> list[str]: return self._short_opts + self._long_opts def attrs(self) -> Mapping[str, Any]: @@ -297,11 +322,6 @@ def attrs(self) -> Mapping[str, Any]: self._attrs[attr] = getattr(self, attr) except AttributeError: pass - if self._attrs.get("help"): - a = self._attrs["help"] - a = a.replace("%default", "%(default)s") - # a = a.replace('%prog', '%(prog)s') - self._attrs["help"] = a return self._attrs def _set_opt_strings(self, opts: Sequence[str]) -> None: @@ -312,29 +332,29 @@ def _set_opt_strings(self, opts: Sequence[str]) -> None: for opt in opts: if len(opt) < 2: raise ArgumentError( - "invalid option string %r: " - "must be at least two characters long" % opt, + f"invalid option string {opt!r}: " + "must be at least two characters long", self, ) elif len(opt) == 2: if not (opt[0] == "-" and opt[1] != "-"): raise ArgumentError( - "invalid short option string %r: " - "must be of the form -x, (x any non-dash char)" % opt, + f"invalid short option string {opt!r}: " + "must be of the form -x, (x any non-dash char)", self, ) self._short_opts.append(opt) else: if not (opt[0:2] == "--" and opt[2] != "-"): raise ArgumentError( - "invalid long option string %r: " - "must start with --, followed by non-dash" % opt, + f"invalid long option string {opt!r}: " + "must start with --, followed by non-dash", self, ) self._long_opts.append(opt) def __repr__(self) -> str: - args: List[str] = [] + args: list[str] = [] if self._short_opts: args += ["_short_opts: " + repr(self._short_opts)] if self._long_opts: @@ -354,14 +374,14 @@ def __init__( self, name: str, description: str = "", - parser: Optional[Parser] = None, + parser: Parser | None = None, *, _ispytest: bool = False, ) -> None: check_ispytest(_ispytest) self.name = name self.description = description - self.options: List[Argument] = [] + self.options: list[Argument] = [] self.parser = parser def addoption(self, *opts: str, **attrs: Any) -> None: @@ -375,14 +395,14 @@ def addoption(self, *opts: str, **attrs: Any) -> None: :param opts: Option names, can be short or long options. :param attrs: - Same attributes as the argparse library's :py:func:`add_argument() + Same attributes as the argparse library's :meth:`add_argument() ` function accepts. """ conflict = set(opts).intersection( name for opt in self.options for name in opt.names() ) if conflict: - raise ValueError("option names %s already added" % conflict) + raise ValueError(f"option names {conflict} already added") option = Argument(*opts, **attrs) self._addoption_instance(option, shortupper=False) @@ -390,7 +410,7 @@ def _addoption(self, *opts: str, **attrs: Any) -> None: option = Argument(*opts, **attrs) self._addoption_instance(option, shortupper=True) - def _addoption_instance(self, option: "Argument", shortupper: bool = False) -> None: + def _addoption_instance(self, option: Argument, shortupper: bool = False) -> None: if not shortupper: for opt in option._short_opts: if opt[0] == "-" and opt[1].islower(): @@ -404,8 +424,8 @@ class MyOptionParser(argparse.ArgumentParser): def __init__( self, parser: Parser, - extra_info: Optional[Dict[str, Any]] = None, - prog: Optional[str] = None, + extra_info: dict[str, Any] | None = None, + prog: str | None = None, ) -> None: self._parser = parser super().__init__( @@ -414,6 +434,7 @@ def __init__( add_help=False, formatter_class=DropShorterLongHelpFormatter, allow_abbrev=False, + fromfile_prefix_chars="@", ) # extra_info is a dict of (param -> value) to display if there's # an usage error to provide more contextual information to the user. @@ -424,67 +445,30 @@ def error(self, message: str) -> NoReturn: msg = f"{self.prog}: error: {message}" if hasattr(self._parser, "_config_source_hint"): - # Type ignored because the attribute is set dynamically. - msg = f"{msg} ({self._parser._config_source_hint})" # type: ignore + msg = f"{msg} ({self._parser._config_source_hint})" raise UsageError(self.format_usage() + msg) # Type ignored because typeshed has a very complex type in the superclass. def parse_args( # type: ignore self, - args: Optional[Sequence[str]] = None, - namespace: Optional[argparse.Namespace] = None, + args: Sequence[str] | None = None, + namespace: argparse.Namespace | None = None, ) -> argparse.Namespace: """Allow splitting of positional arguments.""" parsed, unrecognized = self.parse_known_args(args, namespace) if unrecognized: for arg in unrecognized: if arg and arg[0] == "-": - lines = ["unrecognized arguments: %s" % (" ".join(unrecognized))] + lines = [ + "unrecognized arguments: {}".format(" ".join(unrecognized)) + ] for k, v in sorted(self.extra_info.items()): lines.append(f" {k}: {v}") self.error("\n".join(lines)) getattr(parsed, FILE_OR_DIR).extend(unrecognized) return parsed - if sys.version_info[:2] < (3, 9): # pragma: no cover - # Backport of https://github.com/python/cpython/pull/14316 so we can - # disable long --argument abbreviations without breaking short flags. - def _parse_optional( - self, arg_string: str - ) -> Optional[Tuple[Optional[argparse.Action], str, Optional[str]]]: - if not arg_string: - return None - if not arg_string[0] in self.prefix_chars: - return None - if arg_string in self._option_string_actions: - action = self._option_string_actions[arg_string] - return action, arg_string, None - if len(arg_string) == 1: - return None - if "=" in arg_string: - option_string, explicit_arg = arg_string.split("=", 1) - if option_string in self._option_string_actions: - action = self._option_string_actions[option_string] - return action, option_string, explicit_arg - if self.allow_abbrev or not arg_string.startswith("--"): - option_tuples = self._get_option_tuples(arg_string) - if len(option_tuples) > 1: - msg = gettext( - "ambiguous option: %(option)s could match %(matches)s" - ) - options = ", ".join(option for _, option, _ in option_tuples) - self.error(msg % {"option": arg_string, "matches": options}) - elif len(option_tuples) == 1: - (option_tuple,) = option_tuples - return option_tuple - if self._negative_number_matcher.match(arg_string): - if not self._has_negative_number_optionals: - return None - if " " in arg_string: - return None - return None, arg_string, None - class DropShorterLongHelpFormatter(argparse.HelpFormatter): """Shorten help for long options that differ only in extra hyphens. @@ -504,7 +488,7 @@ def _format_action_invocation(self, action: argparse.Action) -> str: orgstr = super()._format_action_invocation(action) if orgstr and orgstr[0] != "-": # only optional arguments return orgstr - res: Optional[str] = getattr(action, "_formatted_action_invocation", None) + res: str | None = getattr(action, "_formatted_action_invocation", None) if res: return res options = orgstr.split(", ") @@ -513,13 +497,13 @@ def _format_action_invocation(self, action: argparse.Action) -> str: action._formatted_action_invocation = orgstr # type: ignore return orgstr return_list = [] - short_long: Dict[str, str] = {} + short_long: dict[str, str] = {} for option in options: if len(option) == 2 or option[2] == " ": continue if not option.startswith("--"): raise ArgumentError( - 'long optional argument without "--": [%s]' % (option), option + f'long optional argument without "--": [{option}]', option ) xxoption = option[2:] shortened = xxoption.replace("-", "") diff --git a/src/_pytest/config/compat.py b/src/_pytest/config/compat.py index 5bd922a4a87..21eab4c7e47 100644 --- a/src/_pytest/config/compat.py +++ b/src/_pytest/config/compat.py @@ -1,15 +1,20 @@ +from __future__ import annotations + +from collections.abc import Mapping import functools -import warnings from pathlib import Path -from typing import Optional +from typing import Any +import warnings + +import pluggy from ..compat import LEGACY_PATH from ..compat import legacy_path from ..deprecated import HOOK_LEGACY_PATH_ARG -from _pytest.nodes import _check_path + # hookname: (Path, LEGACY_PATH) -imply_paths_hooks = { +imply_paths_hooks: Mapping[str, tuple[str, str]] = { "pytest_ignore_collect": ("collection_path", "path"), "pytest_collect_file": ("file_path", "path"), "pytest_pycollect_makemodule": ("module_path", "path"), @@ -18,6 +23,14 @@ } +def _check_path(path: Path, fspath: LEGACY_PATH) -> None: + if Path(fspath) != path: + raise ValueError( + f"Path({fspath!r}) != {path!r}\n" + "if both path and fspath are given they need to be equal" + ) + + class PathAwareHookProxy: """ this helper wraps around hook callers @@ -27,24 +40,24 @@ class PathAwareHookProxy: this may have to be changed later depending on bugs """ - def __init__(self, hook_caller): - self.__hook_caller = hook_caller + def __init__(self, hook_relay: pluggy.HookRelay) -> None: + self._hook_relay = hook_relay - def __dir__(self): - return dir(self.__hook_caller) + def __dir__(self) -> list[str]: + return dir(self._hook_relay) - def __getattr__(self, key, _wraps=functools.wraps): - hook = getattr(self.__hook_caller, key) + def __getattr__(self, key: str) -> pluggy.HookCaller: + hook: pluggy.HookCaller = getattr(self._hook_relay, key) if key not in imply_paths_hooks: self.__dict__[key] = hook return hook else: path_var, fspath_var = imply_paths_hooks[key] - @_wraps(hook) - def fixed_hook(**kw): - path_value: Optional[Path] = kw.pop(path_var, None) - fspath_value: Optional[LEGACY_PATH] = kw.pop(fspath_var, None) + @functools.wraps(hook) + def fixed_hook(**kw: Any) -> Any: + path_value: Path | None = kw.pop(path_var, None) + fspath_value: LEGACY_PATH | None = kw.pop(fspath_var, None) if fspath_value is not None: warnings.warn( HOOK_LEGACY_PATH_ARG.format( @@ -65,6 +78,8 @@ def fixed_hook(**kw): kw[fspath_var] = fspath_value return hook(**kw) + fixed_hook.name = hook.name # type: ignore[attr-defined] + fixed_hook.spec = hook.spec # type: ignore[attr-defined] fixed_hook.__name__ = key self.__dict__[key] = fixed_hook - return fixed_hook + return fixed_hook # type: ignore[return-value] diff --git a/src/_pytest/config/exceptions.py b/src/_pytest/config/exceptions.py index 4f1320e758d..90108eca904 100644 --- a/src/_pytest/config/exceptions.py +++ b/src/_pytest/config/exceptions.py @@ -1,4 +1,6 @@ -from _pytest.compat import final +from __future__ import annotations + +from typing import final @final diff --git a/src/_pytest/config/findpaths.py b/src/_pytest/config/findpaths.py index 234b9e12906..15bfbb0613e 100644 --- a/src/_pytest/config/findpaths.py +++ b/src/_pytest/config/findpaths.py @@ -1,14 +1,11 @@ +from __future__ import annotations + +from collections.abc import Iterable +from collections.abc import Sequence import os -import sys from pathlib import Path -from typing import Dict -from typing import Iterable -from typing import List -from typing import Optional -from typing import Sequence -from typing import Tuple +import sys from typing import TYPE_CHECKING -from typing import Union import iniconfig @@ -16,9 +13,17 @@ from _pytest.outcomes import fail from _pytest.pathlib import absolutepath from _pytest.pathlib import commonpath +from _pytest.pathlib import safe_exists + if TYPE_CHECKING: - from . import Config + from typing import Union + + from typing_extensions import TypeAlias + + # Even though TOML supports richer data types, all values are converted to str/list[str] during + # parsing to maintain compatibility with the rest of the configuration system. + ConfigDict: TypeAlias = dict[str, Union[str, list[str]]] def _parse_ini_config(path: Path) -> iniconfig.IniConfig: @@ -35,12 +40,11 @@ def _parse_ini_config(path: Path) -> iniconfig.IniConfig: def load_config_dict_from_file( filepath: Path, -) -> Optional[Dict[str, Union[str, List[str]]]]: +) -> ConfigDict | None: """Load pytest configuration from the given file path, if supported. Return None if the file does not contain valid pytest configuration. """ - # Configuration from ini files are obtained from the [pytest] section, if present. if filepath.suffix == ".ini": iniconfig = _parse_ini_config(filepath) @@ -81,7 +85,7 @@ def load_config_dict_from_file( # TOML supports richer data types than ini files (strings, arrays, floats, ints, etc), # however we need to convert all scalar values to str for compatibility with the rest # of the configuration system, which expects strings only. - def make_scalar(v: object) -> Union[str, List[str]]: + def make_scalar(v: object) -> str | list[str]: return v if isinstance(v, list) else str(v) return {k: make_scalar(v) for k, v in result.items()} @@ -90,8 +94,9 @@ def make_scalar(v: object) -> Union[str, List[str]]: def locate_config( + invocation_dir: Path, args: Iterable[Path], -) -> Tuple[Optional[Path], Optional[Path], Dict[str, Union[str, List[str]]]]: +) -> tuple[Path | None, Path | None, ConfigDict]: """Search in the list of arguments for a valid ini-file for pytest, and return a tuple of (rootdir, inifile, cfg-dict).""" config_names = [ @@ -103,21 +108,29 @@ def locate_config( ] args = [x for x in args if not str(x).startswith("-")] if not args: - args = [Path.cwd()] + args = [invocation_dir] + found_pyproject_toml: Path | None = None for arg in args: argpath = absolutepath(arg) for base in (argpath, *argpath.parents): for config_name in config_names: p = base / config_name if p.is_file(): + if p.name == "pyproject.toml" and found_pyproject_toml is None: + found_pyproject_toml = p ini_config = load_config_dict_from_file(p) if ini_config is not None: return base, p, ini_config + if found_pyproject_toml is not None: + return found_pyproject_toml.parent, found_pyproject_toml, {} return None, None, {} -def get_common_ancestor(paths: Iterable[Path]) -> Path: - common_ancestor: Optional[Path] = None +def get_common_ancestor( + invocation_dir: Path, + paths: Iterable[Path], +) -> Path: + common_ancestor: Path | None = None for path in paths: if not path.exists(): continue @@ -133,13 +146,13 @@ def get_common_ancestor(paths: Iterable[Path]) -> Path: if shared is not None: common_ancestor = shared if common_ancestor is None: - common_ancestor = Path.cwd() + common_ancestor = invocation_dir elif common_ancestor.is_file(): common_ancestor = common_ancestor.parent return common_ancestor -def get_dirs_from_args(args: Iterable[str]) -> List[Path]: +def get_dirs_from_args(args: Iterable[str]) -> list[Path]: def is_option(x: str) -> bool: return x.startswith("-") @@ -151,14 +164,6 @@ def get_dir_from_path(path: Path) -> Path: return path return path.parent - def safe_exists(path: Path) -> bool: - # This can throw on paths that contain characters unrepresentable at the OS level, - # or with invalid syntax on Windows (https://bugs.python.org/issue35306) - try: - return path.exists() - except OSError: - return False - # These look like paths but may not exist possible_paths = ( absolutepath(get_file_part_from_node_id(arg)) @@ -173,22 +178,35 @@ def safe_exists(path: Path) -> bool: def determine_setup( - inifile: Optional[str], + *, + inifile: str | None, args: Sequence[str], - rootdir_cmd_arg: Optional[str] = None, - config: Optional["Config"] = None, -) -> Tuple[Path, Optional[Path], Dict[str, Union[str, List[str]]]]: + rootdir_cmd_arg: str | None, + invocation_dir: Path, +) -> tuple[Path, Path | None, ConfigDict]: + """Determine the rootdir, inifile and ini configuration values from the + command line arguments. + + :param inifile: + The `--inifile` command line argument, if given. + :param args: + The free command line arguments. + :param rootdir_cmd_arg: + The `--rootdir` command line argument, if given. + :param invocation_dir: + The working directory when pytest was invoked. + """ rootdir = None dirs = get_dirs_from_args(args) if inifile: inipath_ = absolutepath(inifile) - inipath: Optional[Path] = inipath_ + inipath: Path | None = inipath_ inicfg = load_config_dict_from_file(inipath_) or {} if rootdir_cmd_arg is None: rootdir = inipath_.parent else: - ancestor = get_common_ancestor(dirs) - rootdir, inipath, inicfg = locate_config([ancestor]) + ancestor = get_common_ancestor(invocation_dir, dirs) + rootdir, inipath, inicfg = locate_config(invocation_dir, [ancestor]) if rootdir is None and rootdir_cmd_arg is None: for possible_rootdir in (ancestor, *ancestor.parents): if (possible_rootdir / "setup.py").is_file(): @@ -196,22 +214,18 @@ def determine_setup( break else: if dirs != [ancestor]: - rootdir, inipath, inicfg = locate_config(dirs) + rootdir, inipath, inicfg = locate_config(invocation_dir, dirs) if rootdir is None: - if config is not None: - cwd = config.invocation_params.dir - else: - cwd = Path.cwd() - rootdir = get_common_ancestor([cwd, ancestor]) + rootdir = get_common_ancestor( + invocation_dir, [invocation_dir, ancestor] + ) if is_fs_root(rootdir): rootdir = ancestor if rootdir_cmd_arg: rootdir = absolutepath(os.path.expandvars(rootdir_cmd_arg)) if not rootdir.is_dir(): raise UsageError( - "Directory '{}' not found. Check your '--rootdir' option.".format( - rootdir - ) + f"Directory '{rootdir}' not found. Check your '--rootdir' option." ) assert rootdir is not None return rootdir, inipath, inicfg or {} diff --git a/src/_pytest/debugging.py b/src/_pytest/debugging.py index a3f80802cab..de1b2688f76 100644 --- a/src/_pytest/debugging.py +++ b/src/_pytest/debugging.py @@ -1,21 +1,21 @@ +# mypy: allow-untyped-defs +# ruff: noqa: T100 """Interactive debugging with PDB, the Python Debugger.""" + +from __future__ import annotations + import argparse +from collections.abc import Callable +from collections.abc import Generator import functools import sys import types -import unittest from typing import Any -from typing import Callable -from typing import Generator -from typing import List -from typing import Optional -from typing import Tuple -from typing import Type -from typing import TYPE_CHECKING -from typing import Union +import unittest from _pytest import outcomes from _pytest._code import ExceptionInfo +from _pytest.capture import CaptureManager from _pytest.config import Config from _pytest.config import ConftestImportFailure from _pytest.config import hookimpl @@ -24,13 +24,10 @@ from _pytest.config.exceptions import UsageError from _pytest.nodes import Node from _pytest.reports import BaseReport +from _pytest.runner import CallInfo -if TYPE_CHECKING: - from _pytest.capture import CaptureManager - from _pytest.runner import CallInfo - -def _validate_usepdb_cls(value: str) -> Tuple[str, str]: +def _validate_usepdb_cls(value: str) -> tuple[str, str]: """Validate syntax of --pdbcls option.""" try: modname, classname = value.split(":") @@ -43,13 +40,13 @@ def _validate_usepdb_cls(value: str) -> Tuple[str, str]: def pytest_addoption(parser: Parser) -> None: group = parser.getgroup("general") - group._addoption( + group.addoption( "--pdb", dest="usepdb", action="store_true", help="Start the interactive Python debugger on errors or KeyboardInterrupt", ) - group._addoption( + group.addoption( "--pdbcls", dest="usepdb_cls", metavar="modulename:classname", @@ -57,7 +54,7 @@ def pytest_addoption(parser: Parser) -> None: help="Specify a custom interactive Python debugger for use with --pdb." "For example: --pdbcls=IPython.terminal.debugger:TerminalPdb", ) - group._addoption( + group.addoption( "--trace", dest="trace", action="store_true", @@ -95,22 +92,22 @@ def fin() -> None: class pytestPDB: """Pseudo PDB that defers to the real pdb.""" - _pluginmanager: Optional[PytestPluginManager] = None - _config: Optional[Config] = None - _saved: List[ - Tuple[Callable[..., None], Optional[PytestPluginManager], Optional[Config]] + _pluginmanager: PytestPluginManager | None = None + _config: Config | None = None + _saved: list[ + tuple[Callable[..., None], PytestPluginManager | None, Config | None] ] = [] _recursive_debug = 0 - _wrapped_pdb_cls: Optional[Tuple[Type[Any], Type[Any]]] = None + _wrapped_pdb_cls: tuple[type[Any], type[Any]] | None = None @classmethod - def _is_capturing(cls, capman: Optional["CaptureManager"]) -> Union[str, bool]: + def _is_capturing(cls, capman: CaptureManager | None) -> str | bool: if capman: return capman.is_capturing() return False @classmethod - def _import_pdb_cls(cls, capman: Optional["CaptureManager"]): + def _import_pdb_cls(cls, capman: CaptureManager | None): if not cls._config: import pdb @@ -149,12 +146,10 @@ def _import_pdb_cls(cls, capman: Optional["CaptureManager"]): return wrapped_cls @classmethod - def _get_pdb_wrapper_class(cls, pdb_cls, capman: Optional["CaptureManager"]): + def _get_pdb_wrapper_class(cls, pdb_cls, capman: CaptureManager | None): import _pytest.config - # Type ignored because mypy doesn't support "dynamic" - # inheritance like this. - class PytestPdbWrapper(pdb_cls): # type: ignore[valid-type,misc] + class PytestPdbWrapper(pdb_cls): _pytest_capman = capman _continued = False @@ -164,6 +159,9 @@ def do_debug(self, arg): cls._recursive_debug -= 1 return ret + if hasattr(pdb_cls, "do_debug"): + do_debug.__doc__ = pdb_cls.do_debug.__doc__ + def do_continue(self, arg): ret = super().do_continue(arg) if cls._recursive_debug == 0: @@ -179,8 +177,7 @@ def do_continue(self, arg): else: tw.sep( ">", - "PDB continue (IO-capturing resumed for %s)" - % capturing, + f"PDB continue (IO-capturing resumed for {capturing})", ) assert capman is not None capman.resume() @@ -191,15 +188,17 @@ def do_continue(self, arg): self._continued = True return ret + if hasattr(pdb_cls, "do_continue"): + do_continue.__doc__ = pdb_cls.do_continue.__doc__ + do_c = do_cont = do_continue def do_quit(self, arg): - """Raise Exit outcome when quit command is used in pdb. - - This is a bit of a hack - it would be better if BdbQuit - could be handled, but this would require to wrap the - whole pytest run, and adjust the report etc. - """ + # Raise Exit outcome when quit command is used in pdb. + # + # This is a bit of a hack - it would be better if BdbQuit + # could be handled, but this would require to wrap the + # whole pytest run, and adjust the report etc. ret = super().do_quit(arg) if cls._recursive_debug == 0: @@ -207,6 +206,9 @@ def do_quit(self, arg): return ret + if hasattr(pdb_cls, "do_quit"): + do_quit.__doc__ = pdb_cls.do_quit.__doc__ + do_q = do_quit do_exit = do_quit @@ -241,7 +243,7 @@ def _init_pdb(cls, method, *args, **kwargs): import _pytest.config if cls._pluginmanager is None: - capman: Optional[CaptureManager] = None + capman: CaptureManager | None = None else: capman = cls._pluginmanager.getplugin("capturemanager") if capman: @@ -263,8 +265,7 @@ def _init_pdb(cls, method, *args, **kwargs): elif capturing: tw.sep( ">", - "PDB %s (IO-capturing turned off for %s)" - % (method, capturing), + f"PDB {method} (IO-capturing turned off for {capturing})", ) else: tw.sep(">", f"PDB {method}") @@ -285,7 +286,7 @@ def set_trace(cls, *args, **kwargs) -> None: class PdbInvoke: def pytest_exception_interact( - self, node: Node, call: "CallInfo[Any]", report: BaseReport + self, node: Node, call: CallInfo[Any], report: BaseReport ) -> None: capman = node.config.pluginmanager.getplugin("capturemanager") if capman: @@ -299,18 +300,18 @@ def pytest_exception_interact( _enter_pdb(node, call.excinfo, report) def pytest_internalerror(self, excinfo: ExceptionInfo[BaseException]) -> None: - tb = _postmortem_traceback(excinfo) - post_mortem(tb) + exc_or_tb = _postmortem_exc_or_tb(excinfo) + post_mortem(exc_or_tb) class PdbTrace: - @hookimpl(hookwrapper=True) - def pytest_pyfunc_call(self, pyfuncitem) -> Generator[None, None, None]: + @hookimpl(wrapper=True) + def pytest_pyfunc_call(self, pyfuncitem) -> Generator[None, object, object]: wrap_pytest_function_for_tracing(pyfuncitem) - yield + return (yield) -def wrap_pytest_function_for_tracing(pyfuncitem): +def wrap_pytest_function_for_tracing(pyfuncitem) -> None: """Change the Python function object of the given Function item by a wrapper which actually enters pdb before calling the python function itself, effectively leaving the user in the pdb prompt in the first @@ -322,14 +323,14 @@ def wrap_pytest_function_for_tracing(pyfuncitem): # python < 3.7.4) runcall's first param is `func`, which means we'd get # an exception if one of the kwargs to testfunction was called `func`. @functools.wraps(testfunction) - def wrapper(*args, **kwargs): + def wrapper(*args, **kwargs) -> None: func = functools.partial(testfunction, *args, **kwargs) _pdb.runcall(func) pyfuncitem.obj = wrapper -def maybe_wrap_pytest_function_for_tracing(pyfuncitem): +def maybe_wrap_pytest_function_for_tracing(pyfuncitem) -> None: """Wrap the given pytestfunct item for tracing support if --trace was given in the command line.""" if pyfuncitem.config.getvalue("trace"): @@ -339,7 +340,7 @@ def maybe_wrap_pytest_function_for_tracing(pyfuncitem): def _enter_pdb( node: Node, excinfo: ExceptionInfo[BaseException], rep: BaseReport ) -> BaseReport: - # XXX we re-use the TerminalReporter's terminalwriter + # XXX we reuse the TerminalReporter's terminalwriter # because this seems to avoid some encoding related troubles # for not completely clear reasons. tw = node.config.pluginmanager.getplugin("terminalreporter")._tw @@ -361,31 +362,46 @@ def _enter_pdb( tw.sep(">", "traceback") rep.toterminal(tw) tw.sep(">", "entering PDB") - tb = _postmortem_traceback(excinfo) + tb_or_exc = _postmortem_exc_or_tb(excinfo) rep._pdbshown = True # type: ignore[attr-defined] - post_mortem(tb) + post_mortem(tb_or_exc) return rep -def _postmortem_traceback(excinfo: ExceptionInfo[BaseException]) -> types.TracebackType: +def _postmortem_exc_or_tb( + excinfo: ExceptionInfo[BaseException], +) -> types.TracebackType | BaseException: from doctest import UnexpectedException + get_exc = sys.version_info >= (3, 13) if isinstance(excinfo.value, UnexpectedException): # A doctest.UnexpectedException is not useful for post_mortem. # Use the underlying exception instead: - return excinfo.value.exc_info[2] + underlying_exc = excinfo.value + if get_exc: + return underlying_exc.exc_info[1] + + return underlying_exc.exc_info[2] elif isinstance(excinfo.value, ConftestImportFailure): # A config.ConftestImportFailure is not useful for post_mortem. # Use the underlying exception instead: - return excinfo.value.excinfo[2] + cause = excinfo.value.cause + if get_exc: + return cause + + assert cause.__traceback__ is not None + return cause.__traceback__ else: assert excinfo._excinfo is not None + if get_exc: + return excinfo._excinfo[1] + return excinfo._excinfo[2] -def post_mortem(t: types.TracebackType) -> None: +def post_mortem(tb_or_exc: types.TracebackType | BaseException) -> None: p = pytestPDB._init_pdb("post_mortem") p.reset() - p.interaction(None, t) + p.interaction(None, tb_or_exc) if p.quitting: outcomes.exit("Quitting debugger") diff --git a/src/_pytest/deprecated.py b/src/_pytest/deprecated.py index b9c10df7a00..a605c24e58f 100644 --- a/src/_pytest/deprecated.py +++ b/src/_pytest/deprecated.py @@ -8,12 +8,16 @@ :class:`PytestWarning`, or :class:`UnformattedWarning` in case of warnings which need to format their messages. """ + +from __future__ import annotations + from warnings import warn from _pytest.warning_types import PytestDeprecationWarning -from _pytest.warning_types import PytestRemovedIn8Warning +from _pytest.warning_types import PytestRemovedIn9Warning from _pytest.warning_types import UnformattedWarning + # set of plugins which have been integrated into the core; we use this list to ignore # them during registration to avoid conflicts DEPRECATED_EXTERNAL_PLUGINS = { @@ -22,21 +26,6 @@ "pytest_faulthandler", } -NOSE_SUPPORT = UnformattedWarning( - PytestRemovedIn8Warning, - "Support for nose tests is deprecated and will be removed in a future release.\n" - "{nodeid} is using nose method: `{method}` ({stage})\n" - "See docs: https://docs.pytest.org/en/stable/deprecations.html#support-for-tests-written-for-nose", -) - -NOSE_SUPPORT_METHOD = UnformattedWarning( - PytestRemovedIn8Warning, - "Support for nose tests is deprecated and will be removed in a future release.\n" - "{nodeid} is using nose-specific method: `{method}(self)`\n" - "To remove this warning, rename it to `{method}_method(self)`\n" - "See docs: https://docs.pytest.org/en/stable/deprecations.html#support-for-tests-written-for-nose", -) - # This can be* removed pytest 8, but it's harmless and common, so no rush to remove. # * If you're in the future: "could have been". @@ -45,74 +34,25 @@ "Use @pytest.fixture instead; they are the same." ) -WARNING_CMDLINE_PREPARSE_HOOK = PytestRemovedIn8Warning( - "The pytest_cmdline_preparse hook is deprecated and will be removed in a future release. \n" - "Please use pytest_load_initial_conftests hook instead." -) - -FSCOLLECTOR_GETHOOKPROXY_ISINITPATH = PytestRemovedIn8Warning( - "The gethookproxy() and isinitpath() methods of FSCollector and Package are deprecated; " - "use self.session.gethookproxy() and self.session.isinitpath() instead. " -) - -STRICT_OPTION = PytestRemovedIn8Warning( - "The --strict option is deprecated, use --strict-markers instead." -) - # This deprecation is never really meant to be removed. PRIVATE = PytestDeprecationWarning("A private pytest class or function was used.") -ARGUMENT_PERCENT_DEFAULT = PytestRemovedIn8Warning( - 'pytest now uses argparse. "%default" should be changed to "%(default)s"', -) - -ARGUMENT_TYPE_STR_CHOICE = UnformattedWarning( - PytestRemovedIn8Warning, - "`type` argument to addoption() is the string {typ!r}." - " For choices this is optional and can be omitted, " - " but when supplied should be a type (for example `str` or `int`)." - " (options: {names})", -) - -ARGUMENT_TYPE_STR = UnformattedWarning( - PytestRemovedIn8Warning, - "`type` argument to addoption() is the string {typ!r}, " - " but when supplied should be a type (for example `str` or `int`)." - " (options: {names})", -) - HOOK_LEGACY_PATH_ARG = UnformattedWarning( - PytestRemovedIn8Warning, + PytestRemovedIn9Warning, "The ({pylib_path_arg}: py.path.local) argument is deprecated, please use ({pathlib_path_arg}: pathlib.Path)\n" "see https://docs.pytest.org/en/latest/deprecations.html" "#py-path-local-arguments-for-hooks-replaced-with-pathlib-path", ) NODE_CTOR_FSPATH_ARG = UnformattedWarning( - PytestRemovedIn8Warning, + PytestRemovedIn9Warning, "The (fspath: py.path.local) argument to {node_type_name} is deprecated. " "Please use the (path: pathlib.Path) argument instead.\n" "See https://docs.pytest.org/en/latest/deprecations.html" "#fspath-argument-for-node-constructors-replaced-with-pathlib-path", ) -WARNS_NONE_ARG = PytestRemovedIn8Warning( - "Passing None has been deprecated.\n" - "See https://docs.pytest.org/en/latest/how-to/capture-warnings.html" - "#additional-use-cases-of-warnings-in-tests" - " for alternatives in common use cases." -) - -KEYWORD_MSG_ARG = UnformattedWarning( - PytestRemovedIn8Warning, - "pytest.{func}(msg=...) is now deprecated, use pytest.{func}(reason=...) instead", -) - -INSTANCE_COLLECTOR = PytestRemovedIn8Warning( - "The pytest.Instance collector type is deprecated and is no longer used. " - "See https://docs.pytest.org/en/latest/deprecations.html#the-pytest-instance-collector", -) HOOK_LEGACY_MARKING = UnformattedWarning( PytestDeprecationWarning, "The hook{type} {fullname} uses old-style configuration options (marks or attributes).\n" @@ -122,6 +62,11 @@ "#configuring-hook-specs-impls-using-markers", ) +MARKED_FIXTURE = PytestRemovedIn9Warning( + "Marks applied to fixtures have no effect\n" + "See docs: https://docs.pytest.org/en/stable/deprecations.html#applying-a-mark-to-a-fixture-function" +) + # You want to make some `__init__` or function "private". # # def my_private_function(some, args): diff --git a/src/_pytest/doctest.py b/src/_pytest/doctest.py index 455ad62cc9c..0dbef6056d7 100644 --- a/src/_pytest/doctest.py +++ b/src/_pytest/doctest.py @@ -1,27 +1,26 @@ +# mypy: allow-untyped-defs """Discover and run doctests in modules and test files.""" + +from __future__ import annotations + import bdb +from collections.abc import Callable +from collections.abc import Generator +from collections.abc import Iterable +from collections.abc import Sequence +from contextlib import contextmanager +import functools import inspect import os +from pathlib import Path import platform +import re import sys import traceback import types -import warnings -from contextlib import contextmanager -from pathlib import Path from typing import Any -from typing import Callable -from typing import Dict -from typing import Generator -from typing import Iterable -from typing import List -from typing import Optional -from typing import Pattern -from typing import Sequence -from typing import Tuple -from typing import Type from typing import TYPE_CHECKING -from typing import Union +import warnings from _pytest import outcomes from _pytest._code.code import ExceptionInfo @@ -32,20 +31,22 @@ from _pytest.config import Config from _pytest.config.argparsing import Parser from _pytest.fixtures import fixture -from _pytest.fixtures import FixtureRequest +from _pytest.fixtures import TopRequest from _pytest.nodes import Collector from _pytest.nodes import Item from _pytest.outcomes import OutcomeException from _pytest.outcomes import skip from _pytest.pathlib import fnmatch_ex -from _pytest.pathlib import import_path from _pytest.python import Module from _pytest.python_api import approx from _pytest.warning_types import PytestWarning + if TYPE_CHECKING: import doctest + from typing_extensions import Self + DOCTEST_REPORT_CHOICE_NONE = "none" DOCTEST_REPORT_CHOICE_CDIFF = "cdiff" DOCTEST_REPORT_CHOICE_NDIFF = "ndiff" @@ -63,7 +64,7 @@ # Lazy definition of runner class RUNNER_CLASS = None # Lazy definition of output checker class -CHECKER_CLASS: Optional[Type["doctest.OutputChecker"]] = None +CHECKER_CLASS: type[doctest.OutputChecker] | None = None def pytest_addoption(parser: Parser) -> None: @@ -104,7 +105,7 @@ def pytest_addoption(parser: Parser) -> None: "--doctest-ignore-import-errors", action="store_true", default=False, - help="Ignore doctest ImportErrors", + help="Ignore doctest collection errors", dest="doctest_ignore_import_errors", ) group.addoption( @@ -125,17 +126,15 @@ def pytest_unconfigure() -> None: def pytest_collect_file( file_path: Path, parent: Collector, -) -> Optional[Union["DoctestModule", "DoctestTextfile"]]: +) -> DoctestModule | DoctestTextfile | None: config = parent.config if file_path.suffix == ".py": if config.option.doctestmodules and not any( (_is_setup_py(file_path), _is_main_py(file_path)) ): - mod: DoctestModule = DoctestModule.from_parent(parent, path=file_path) - return mod + return DoctestModule.from_parent(parent, path=file_path) elif _is_doctest(config, file_path, parent): - txt: DoctestTextfile = DoctestTextfile.from_parent(parent, path=file_path) - return txt + return DoctestTextfile.from_parent(parent, path=file_path) return None @@ -159,7 +158,7 @@ def _is_main_py(path: Path) -> bool: class ReprFailDoctest(TerminalRepr): def __init__( - self, reprlocation_lines: Sequence[Tuple[ReprFileLocation, Sequence[str]]] + self, reprlocation_lines: Sequence[tuple[ReprFileLocation, Sequence[str]]] ) -> None: self.reprlocation_lines = reprlocation_lines @@ -171,12 +170,12 @@ def toterminal(self, tw: TerminalWriter) -> None: class MultipleDoctestFailures(Exception): - def __init__(self, failures: Sequence["doctest.DocTestFailure"]) -> None: + def __init__(self, failures: Sequence[doctest.DocTestFailure]) -> None: super().__init__() self.failures = failures -def _init_runner_class() -> Type["doctest.DocTestRunner"]: +def _init_runner_class() -> type[doctest.DocTestRunner]: import doctest class PytestDoctestRunner(doctest.DebugRunner): @@ -188,8 +187,8 @@ class PytestDoctestRunner(doctest.DebugRunner): def __init__( self, - checker: Optional["doctest.OutputChecker"] = None, - verbose: Optional[bool] = None, + checker: doctest.OutputChecker | None = None, + verbose: bool | None = None, optionflags: int = 0, continue_on_failure: bool = True, ) -> None: @@ -199,8 +198,8 @@ def __init__( def report_failure( self, out, - test: "doctest.DocTest", - example: "doctest.Example", + test: doctest.DocTest, + example: doctest.Example, got: str, ) -> None: failure = doctest.DocTestFailure(test, example, got) @@ -212,9 +211,9 @@ def report_failure( def report_unexpected_exception( self, out, - test: "doctest.DocTest", - example: "doctest.Example", - exc_info: Tuple[Type[BaseException], BaseException, types.TracebackType], + test: doctest.DocTest, + example: doctest.Example, + exc_info: tuple[type[BaseException], BaseException, types.TracebackType], ) -> None: if isinstance(exc_info[1], OutcomeException): raise exc_info[1] @@ -230,11 +229,11 @@ def report_unexpected_exception( def _get_runner( - checker: Optional["doctest.OutputChecker"] = None, - verbose: Optional[bool] = None, + checker: doctest.OutputChecker | None = None, + verbose: bool | None = None, optionflags: int = 0, continue_on_failure: bool = True, -) -> "doctest.DocTestRunner": +) -> doctest.DocTestRunner: # We need this in order to do a lazy import on doctest global RUNNER_CLASS if RUNNER_CLASS is None: @@ -253,45 +252,50 @@ class DoctestItem(Item): def __init__( self, name: str, - parent: "Union[DoctestTextfile, DoctestModule]", - runner: Optional["doctest.DocTestRunner"] = None, - dtest: Optional["doctest.DocTest"] = None, + parent: DoctestTextfile | DoctestModule, + runner: doctest.DocTestRunner, + dtest: doctest.DocTest, ) -> None: super().__init__(name, parent) self.runner = runner self.dtest = dtest + + # Stuff needed for fixture support. self.obj = None - self.fixture_request: Optional[FixtureRequest] = None + fm = self.session._fixturemanager + fixtureinfo = fm.getfixtureinfo(node=self, func=None, cls=None) + self._fixtureinfo = fixtureinfo + self.fixturenames = fixtureinfo.names_closure + self._initrequest() @classmethod - def from_parent( # type: ignore + def from_parent( # type: ignore[override] cls, - parent: "Union[DoctestTextfile, DoctestModule]", + parent: DoctestTextfile | DoctestModule, *, name: str, - runner: "doctest.DocTestRunner", - dtest: "doctest.DocTest", - ): + runner: doctest.DocTestRunner, + dtest: doctest.DocTest, + ) -> Self: # incompatible signature due to imposed limits on subclass """The public named constructor.""" return super().from_parent(name=name, parent=parent, runner=runner, dtest=dtest) + def _initrequest(self) -> None: + self.funcargs: dict[str, object] = {} + self._request = TopRequest(self, _ispytest=True) # type: ignore[arg-type] + def setup(self) -> None: - if self.dtest is not None: - self.fixture_request = _setup_fixtures(self) - globs = dict(getfixture=self.fixture_request.getfixturevalue) - for name, value in self.fixture_request.getfixturevalue( - "doctest_namespace" - ).items(): - globs[name] = value - self.dtest.globs.update(globs) + self._request._fillfixtures() + globs = dict(getfixture=self._request.getfixturevalue) + for name, value in self._request.getfixturevalue("doctest_namespace").items(): + globs[name] = value + self.dtest.globs.update(globs) def runtest(self) -> None: - assert self.dtest is not None - assert self.runner is not None _check_all_skipped(self.dtest) self._disable_output_capturing_for_darwin() - failures: List["doctest.DocTestFailure"] = [] + failures: list[doctest.DocTestFailure] = [] # Type ignored because we change the type of `out` from what # doctest expects. self.runner.run(self.dtest, out=failures) # type: ignore[arg-type] @@ -313,12 +317,12 @@ def _disable_output_capturing_for_darwin(self) -> None: def repr_failure( # type: ignore[override] self, excinfo: ExceptionInfo[BaseException], - ) -> Union[str, TerminalRepr]: + ) -> str | TerminalRepr: import doctest - failures: Optional[ - Sequence[Union[doctest.DocTestFailure, doctest.UnexpectedException]] - ] = None + failures: ( + Sequence[doctest.DocTestFailure | doctest.UnexpectedException] | None + ) = None if isinstance( excinfo.value, (doctest.DocTestFailure, doctest.UnexpectedException) ): @@ -349,7 +353,7 @@ def repr_failure( # type: ignore[override] # add line numbers to the left of the error message assert test.lineno is not None lines = [ - "%03d %s" % (i + test.lineno + 1, x) for (i, x) in enumerate(lines) + f"{i + test.lineno + 1:03d} {x}" for (i, x) in enumerate(lines) ] # trim docstring error lines to 10 lines = lines[max(example.lineno - 9, 0) : example.lineno + 1] @@ -367,19 +371,18 @@ def repr_failure( # type: ignore[override] ).split("\n") else: inner_excinfo = ExceptionInfo.from_exc_info(failure.exc_info) - lines += ["UNEXPECTED EXCEPTION: %s" % repr(inner_excinfo.value)] + lines += [f"UNEXPECTED EXCEPTION: {inner_excinfo.value!r}"] lines += [ x.strip("\n") for x in traceback.format_exception(*failure.exc_info) ] reprlocation_lines.append((reprlocation, lines)) return ReprFailDoctest(reprlocation_lines) - def reportinfo(self) -> Tuple[Union["os.PathLike[str]", str], Optional[int], str]: - assert self.dtest is not None - return self.path, self.dtest.lineno, "[doctest] %s" % self.name + def reportinfo(self) -> tuple[os.PathLike[str] | str, int | None, str]: + return self.path, self.dtest.lineno, f"[doctest] {self.name}" -def _get_flag_lookup() -> Dict[str, int]: +def _get_flag_lookup() -> dict[str, int]: import doctest return dict( @@ -395,8 +398,8 @@ def _get_flag_lookup() -> Dict[str, int]: ) -def get_optionflags(parent): - optionflags_str = parent.config.getini("doctest_optionflags") +def get_optionflags(config: Config) -> int: + optionflags_str = config.getini("doctest_optionflags") flag_lookup_table = _get_flag_lookup() flag_acc = 0 for flag in optionflags_str: @@ -404,8 +407,8 @@ def get_optionflags(parent): return flag_acc -def _get_continue_on_failure(config): - continue_on_failure = config.getvalue("doctest_continue_on_failure") +def _get_continue_on_failure(config: Config) -> bool: + continue_on_failure: bool = config.getvalue("doctest_continue_on_failure") if continue_on_failure: # We need to turn off this if we use pdb since we should stop at # the first failure. @@ -428,7 +431,7 @@ def collect(self) -> Iterable[DoctestItem]: name = self.path.name globs = {"__name__": "__main__"} - optionflags = get_optionflags(self) + optionflags = get_optionflags(self.config) runner = _get_runner( verbose=False, @@ -445,7 +448,7 @@ def collect(self) -> Iterable[DoctestItem]: ) -def _check_all_skipped(test: "doctest.DocTest") -> None: +def _check_all_skipped(test: doctest.DocTest) -> None: """Raise pytest.skip() if all examples in the given DocTest have the SKIP option set.""" import doctest @@ -465,13 +468,13 @@ def _is_mocked(obj: object) -> bool: @contextmanager -def _patch_unwrap_mock_aware() -> Generator[None, None, None]: +def _patch_unwrap_mock_aware() -> Generator[None]: """Context manager which replaces ``inspect.unwrap`` with a version that's aware of mock objects and doesn't recurse into them.""" real_unwrap = inspect.unwrap def _mock_aware_unwrap( - func: Callable[..., Any], *, stop: Optional[Callable[[Any], Any]] = None + func: Callable[..., Any], *, stop: Callable[[Any], Any] | None = None ) -> Any: try: if stop is None or stop is _is_mocked: @@ -480,9 +483,9 @@ def _mock_aware_unwrap( return real_unwrap(func, stop=lambda obj: _is_mocked(obj) or _stop(func)) except Exception as e: warnings.warn( - "Got %r when unwrapping %r. This is usually caused " + f"Got {e!r} when unwrapping {func!r}. This is usually caused " "by a violation of Python's object protocol; see e.g. " - "https://github.com/pytest-dev/pytest/issues/5080" % (e, func), + "https://github.com/pytest-dev/pytest/issues/5080", PytestWarning, ) raise @@ -499,64 +502,81 @@ def collect(self) -> Iterable[DoctestItem]: import doctest class MockAwareDocTestFinder(doctest.DocTestFinder): - """A hackish doctest finder that overrides stdlib internals to fix a stdlib bug. - - https://github.com/pytest-dev/pytest/issues/3456 - https://bugs.python.org/issue25532 - """ - - def _find_lineno(self, obj, source_lines): - """Doctest code does not take into account `@property`, this - is a hackish way to fix it. https://bugs.python.org/issue17446 - - Wrapped Doctests will need to be unwrapped so the correct - line number is returned. This will be reported upstream. #8796 - """ - if isinstance(obj, property): - obj = getattr(obj, "fget", obj) - - if hasattr(obj, "__wrapped__"): - # Get the main obj in case of it being wrapped - obj = inspect.unwrap(obj) - - # Type ignored because this is a private function. - return super()._find_lineno( # type:ignore[misc] - obj, - source_lines, - ) + py_ver_info_minor = sys.version_info[:2] + is_find_lineno_broken = ( + py_ver_info_minor < (3, 11) + or (py_ver_info_minor == (3, 11) and sys.version_info.micro < 9) + or (py_ver_info_minor == (3, 12) and sys.version_info.micro < 3) + ) + if is_find_lineno_broken: + + def _find_lineno(self, obj, source_lines): + """On older Pythons, doctest code does not take into account + `@property`. https://github.com/python/cpython/issues/61648 + + Moreover, wrapped Doctests need to be unwrapped so the correct + line number is returned. #8796 + """ + if isinstance(obj, property): + obj = getattr(obj, "fget", obj) + + if hasattr(obj, "__wrapped__"): + # Get the main obj in case of it being wrapped + obj = inspect.unwrap(obj) - def _find( - self, tests, obj, name, module, source_lines, globs, seen - ) -> None: - if _is_mocked(obj): - return - with _patch_unwrap_mock_aware(): # Type ignored because this is a private function. - super()._find( # type:ignore[misc] - tests, obj, name, module, source_lines, globs, seen + return super()._find_lineno( # type:ignore[misc] + obj, + source_lines, ) - if self.path.name == "conftest.py": - module = self.config.pluginmanager._importconftest( - self.path, - self.config.getoption("importmode"), - rootpath=self.config.rootpath, - ) - else: - try: - module = import_path( - self.path, - root=self.config.rootpath, - mode=self.config.getoption("importmode"), - ) - except ImportError: - if self.config.getvalue("doctest_ignore_import_errors"): - skip("unable to import module %r" % self.path) - else: - raise + if sys.version_info < (3, 10): + + def _find( + self, tests, obj, name, module, source_lines, globs, seen + ) -> None: + """Override _find to work around issue in stdlib. + + https://github.com/pytest-dev/pytest/issues/3456 + https://github.com/python/cpython/issues/69718 + """ + if _is_mocked(obj): + return # pragma: no cover + with _patch_unwrap_mock_aware(): + # Type ignored because this is a private function. + super()._find( # type:ignore[misc] + tests, obj, name, module, source_lines, globs, seen + ) + + if sys.version_info < (3, 13): + + def _from_module(self, module, object): + """`cached_property` objects are never considered a part + of the 'current module'. As such they are skipped by doctest. + Here we override `_from_module` to check the underlying + function instead. https://github.com/python/cpython/issues/107995 + """ + if isinstance(object, functools.cached_property): + object = object.func + + # Type ignored because this is a private function. + return super()._from_module(module, object) # type: ignore[misc] + + try: + module = self.obj + except Collector.CollectError: + if self.config.getvalue("doctest_ignore_import_errors"): + skip(f"unable to import module {self.path!r}") + else: + raise + + # While doctests currently don't support fixtures directly, we still + # need to pick up autouse fixtures. + self.session._fixturemanager.parsefactories(self) + # Uses internal doctest module parsing mechanism. finder = MockAwareDocTestFinder() - optionflags = get_optionflags(self) + optionflags = get_optionflags(self.config) runner = _get_runner( verbose=False, optionflags=optionflags, @@ -571,25 +591,8 @@ def _find( ) -def _setup_fixtures(doctest_item: DoctestItem) -> FixtureRequest: - """Used by DoctestTextfile and DoctestItem to setup fixture information.""" - - def func() -> None: - pass - - doctest_item.funcargs = {} # type: ignore[attr-defined] - fm = doctest_item.session._fixturemanager - doctest_item._fixtureinfo = fm.getfixtureinfo( # type: ignore[attr-defined] - node=doctest_item, func=func, cls=None, funcargs=False - ) - fixture_request = FixtureRequest(doctest_item, _ispytest=True) - fixture_request._fillfixtures() - return fixture_request - - -def _init_checker_class() -> Type["doctest.OutputChecker"]: +def _init_checker_class() -> type[doctest.OutputChecker]: import doctest - import re class LiteralsOutputChecker(doctest.OutputChecker): # Based on doctest_nose_plugin.py from the nltk project @@ -632,7 +635,7 @@ def check_output(self, want: str, got: str, optionflags: int) -> bool: if not allow_unicode and not allow_bytes and not allow_number: return False - def remove_prefixes(regex: Pattern[str], txt: str) -> str: + def remove_prefixes(regex: re.Pattern[str], txt: str) -> str: return re.sub(regex, r"\1\2", txt) if allow_unicode: @@ -655,8 +658,8 @@ def _remove_unwanted_precision(self, want: str, got: str) -> str: return got offset = 0 for w, g in zip(wants, gots): - fraction: Optional[str] = w.group("fraction") - exponent: Optional[str] = w.group("exponent1") + fraction: str | None = w.group("fraction") + exponent: str | None = w.group("exponent1") if exponent is None: exponent = w.group("exponent2") precision = 0 if fraction is None else len(fraction) @@ -675,7 +678,7 @@ def _remove_unwanted_precision(self, want: str, got: str) -> str: return LiteralsOutputChecker -def _get_checker() -> "doctest.OutputChecker": +def _get_checker() -> doctest.OutputChecker: """Return a doctest.OutputChecker subclass that supports some additional options: @@ -734,7 +737,7 @@ def _get_report_choice(key: str) -> int: @fixture(scope="session") -def doctest_namespace() -> Dict[str, Any]: +def doctest_namespace() -> dict[str, Any]: """Fixture that returns a :py:class:`dict` that will be injected into the namespace of doctests. diff --git a/src/_pytest/faulthandler.py b/src/_pytest/faulthandler.py index af879aa44cf..79efc1d1704 100644 --- a/src/_pytest/faulthandler.py +++ b/src/_pytest/faulthandler.py @@ -1,17 +1,18 @@ -import io +from __future__ import annotations + +from collections.abc import Generator import os import sys -from typing import Generator -import pytest from _pytest.config import Config from _pytest.config.argparsing import Parser from _pytest.nodes import Item from _pytest.stash import StashKey +import pytest +fault_handler_original_stderr_fd_key = StashKey[int]() fault_handler_stderr_fd_key = StashKey[int]() -fault_handler_originally_enabled_key = StashKey[bool]() def pytest_addoption(parser: Parser) -> None: @@ -25,8 +26,15 @@ def pytest_addoption(parser: Parser) -> None: def pytest_configure(config: Config) -> None: import faulthandler - config.stash[fault_handler_stderr_fd_key] = os.dup(get_stderr_fileno()) - config.stash[fault_handler_originally_enabled_key] = faulthandler.is_enabled() + # at teardown we want to restore the original faulthandler fileno + # but faulthandler has no api to return the original fileno + # so here we stash the stderr fileno to be used at teardown + # sys.stderr and sys.__stderr__ may be closed or patched during the session + # so we can't rely on their values being good at that point (#11572). + stderr_fileno = get_stderr_fileno() + if faulthandler.is_enabled(): + config.stash[fault_handler_original_stderr_fd_key] = stderr_fileno + config.stash[fault_handler_stderr_fd_key] = os.dup(stderr_fileno) faulthandler.enable(file=config.stash[fault_handler_stderr_fd_key]) @@ -38,9 +46,10 @@ def pytest_unconfigure(config: Config) -> None: if fault_handler_stderr_fd_key in config.stash: os.close(config.stash[fault_handler_stderr_fd_key]) del config.stash[fault_handler_stderr_fd_key] - if config.stash.get(fault_handler_originally_enabled_key, False): - # Re-enable the faulthandler if it was originally enabled. - faulthandler.enable(file=get_stderr_fileno()) + # Re-enable the faulthandler if it was originally enabled. + if fault_handler_original_stderr_fd_key in config.stash: + faulthandler.enable(config.stash[fault_handler_original_stderr_fd_key]) + del config.stash[fault_handler_original_stderr_fd_key] def get_stderr_fileno() -> int: @@ -51,10 +60,11 @@ def get_stderr_fileno() -> int: if fileno == -1: raise AttributeError() return fileno - except (AttributeError, io.UnsupportedOperation): + except (AttributeError, ValueError): # pytest-xdist monkeypatches sys.stderr with an object that is not an actual file. # https://docs.python.org/3/library/faulthandler.html#issue-with-file-descriptors # This is potentially dangerous, but the best we can do. + assert sys.__stderr__ is not None return sys.__stderr__.fileno() @@ -62,8 +72,8 @@ def get_timeout_config_value(config: Config) -> float: return float(config.getini("faulthandler_timeout") or 0.0) -@pytest.hookimpl(hookwrapper=True, trylast=True) -def pytest_runtest_protocol(item: Item) -> Generator[None, None, None]: +@pytest.hookimpl(wrapper=True, trylast=True) +def pytest_runtest_protocol(item: Item) -> Generator[None, object, object]: timeout = get_timeout_config_value(item.config) if timeout > 0: import faulthandler @@ -71,11 +81,11 @@ def pytest_runtest_protocol(item: Item) -> Generator[None, None, None]: stderr = item.config.stash[fault_handler_stderr_fd_key] faulthandler.dump_traceback_later(timeout, file=stderr) try: - yield + return (yield) finally: faulthandler.cancel_dump_traceback_later() else: - yield + return (yield) @pytest.hookimpl(tryfirst=True) diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index 0462504efaf..421237e35a0 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -1,59 +1,63 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + +import abc +from collections import defaultdict +from collections import deque +from collections import OrderedDict +from collections.abc import Callable +from collections.abc import Generator +from collections.abc import Iterable +from collections.abc import Iterator +from collections.abc import Mapping +from collections.abc import MutableMapping +from collections.abc import Sequence +from collections.abc import Set as AbstractSet import dataclasses import functools import inspect import os -import sys -import warnings -from collections import defaultdict -from collections import deque -from contextlib import suppress from pathlib import Path -from types import TracebackType +import sys +import types from typing import Any -from typing import Callable from typing import cast -from typing import Dict -from typing import Generator +from typing import Final +from typing import final from typing import Generic -from typing import Iterable -from typing import Iterator -from typing import List -from typing import MutableMapping from typing import NoReturn from typing import Optional -from typing import Sequence -from typing import Set -from typing import Tuple -from typing import Type +from typing import overload from typing import TYPE_CHECKING from typing import TypeVar from typing import Union +import warnings import _pytest from _pytest import nodes from _pytest._code import getfslineno +from _pytest._code import Source from _pytest._code.code import FormattedExcinfo from _pytest._code.code import TerminalRepr from _pytest._io import TerminalWriter -from _pytest.compat import _format_args -from _pytest.compat import _PytestWrapper from _pytest.compat import assert_never -from _pytest.compat import final from _pytest.compat import get_real_func -from _pytest.compat import get_real_method from _pytest.compat import getfuncargnames from _pytest.compat import getimfunc from _pytest.compat import getlocation -from _pytest.compat import is_generator from _pytest.compat import NOTSET from _pytest.compat import NotSetType -from _pytest.compat import overload from _pytest.compat import safe_getattr +from _pytest.compat import safe_isclass +from _pytest.compat import signature from _pytest.config import _PluggyPlugin from _pytest.config import Config +from _pytest.config import ExitCode from _pytest.config.argparsing import Parser from _pytest.deprecated import check_ispytest +from _pytest.deprecated import MARKED_FIXTURE from _pytest.deprecated import YIELD_FIXTURE +from _pytest.main import Session from _pytest.mark import Mark from _pytest.mark import ParameterSet from _pytest.mark.structures import MarkDecorator @@ -62,17 +66,20 @@ from _pytest.outcomes import TEST_OUTCOME from _pytest.pathlib import absolutepath from _pytest.pathlib import bestrelpath +from _pytest.scope import _ScopeName from _pytest.scope import HIGH_SCOPES from _pytest.scope import Scope -from _pytest.stash import StashKey +from _pytest.warning_types import PytestRemovedIn9Warning +from _pytest.warning_types import PytestWarning -if TYPE_CHECKING: - from typing import Deque +if sys.version_info < (3, 11): + from exceptiongroup import BaseExceptionGroup + - from _pytest.scope import _ScopeName - from _pytest.main import Session +if TYPE_CHECKING: from _pytest.python import CallSpec2 + from _pytest.python import Function from _pytest.python import Metafunc @@ -82,61 +89,62 @@ FixtureFunction = TypeVar("FixtureFunction", bound=Callable[..., object]) # The type of a fixture function (type alias generic in fixture value). _FixtureFunc = Union[ - Callable[..., FixtureValue], Callable[..., Generator[FixtureValue, None, None]] + Callable[..., FixtureValue], Callable[..., Generator[FixtureValue]] ] # The type of FixtureDef.cached_result (type alias generic in fixture value). _FixtureCachedResult = Union[ - Tuple[ + tuple[ # The result. FixtureValue, # Cache key. object, None, ], - Tuple[ + tuple[ None, # Cache key. object, - # Exc info if raised. - Tuple[Type[BaseException], BaseException, TracebackType], + # The exception and the original traceback. + tuple[BaseException, Optional[types.TracebackType]], ], ] @dataclasses.dataclass(frozen=True) class PseudoFixtureDef(Generic[FixtureValue]): - cached_result: "_FixtureCachedResult[FixtureValue]" + cached_result: _FixtureCachedResult[FixtureValue] _scope: Scope -def pytest_sessionstart(session: "Session") -> None: +def pytest_sessionstart(session: Session) -> None: session._fixturemanager = FixtureManager(session) def get_scope_package( node: nodes.Item, - fixturedef: "FixtureDef[object]", -) -> Optional[Union[nodes.Item, nodes.Collector]]: + fixturedef: FixtureDef[object], +) -> nodes.Node | None: from _pytest.python import Package - current: Optional[Union[nodes.Item, nodes.Collector]] = node - fixture_package_name = "{}/{}".format(fixturedef.baseid, "__init__.py") - while current and ( - not isinstance(current, Package) or fixture_package_name != current.nodeid - ): - current = current.parent # type: ignore[assignment] - if current is None: - return node.session - return current + for parent in node.iter_parents(): + if isinstance(parent, Package) and parent.nodeid == fixturedef.baseid: + return parent + return node.session + +def get_scope_node(node: nodes.Node, scope: Scope) -> nodes.Node | None: + """Get the closest parent node (including self) which matches the given + scope. -def get_scope_node( - node: nodes.Node, scope: Scope -) -> Optional[Union[nodes.Item, nodes.Collector]]: + If there is no parent node for the scope (e.g. asking for class scope on a + Module, or on a Function when not defined in a class), returns None. + """ import _pytest.python if scope is Scope.Function: - return node.getparent(nodes.Item) + # Type ignored because this is actually safe, see: + # https://github.com/python/mypy/issues/4717 + return node.getparent(nodes.Item) # type: ignore[type-abstract] elif scope is Scope.Class: return node.getparent(_pytest.python.Class) elif scope is Scope.Module: @@ -149,184 +157,123 @@ def get_scope_node( assert_never(scope) -# Used for storing artificial fixturedefs for direct parametrization. -name2pseudofixturedef_key = StashKey[Dict[str, "FixtureDef[Any]"]]() +# TODO: Try to use FixtureFunctionDefinition instead of the marker +def getfixturemarker(obj: object) -> FixtureFunctionMarker | None: + """Return fixturemarker or None if it doesn't exist""" + if isinstance(obj, FixtureFunctionDefinition): + return obj._fixture_function_marker + return None -def add_funcarg_pseudo_fixture_def( - collector: nodes.Collector, metafunc: "Metafunc", fixturemanager: "FixtureManager" -) -> None: - # This function will transform all collected calls to functions - # if they use direct funcargs (i.e. direct parametrization) - # because we want later test execution to be able to rely on - # an existing FixtureDef structure for all arguments. - # XXX we can probably avoid this algorithm if we modify CallSpec2 - # to directly care for creating the fixturedefs within its methods. - if not metafunc._calls[0].funcargs: - # This function call does not have direct parametrization. - return - # Collect funcargs of all callspecs into a list of values. - arg2params: Dict[str, List[object]] = {} - arg2scope: Dict[str, Scope] = {} - for callspec in metafunc._calls: - for argname, argvalue in callspec.funcargs.items(): - assert argname not in callspec.params - callspec.params[argname] = argvalue - arg2params_list = arg2params.setdefault(argname, []) - callspec.indices[argname] = len(arg2params_list) - arg2params_list.append(argvalue) - if argname not in arg2scope: - scope = callspec._arg2scope.get(argname, Scope.Function) - arg2scope[argname] = scope - callspec.funcargs.clear() - - # Register artificial FixtureDef's so that later at test execution - # time we can rely on a proper FixtureDef to exist for fixture setup. - arg2fixturedefs = metafunc._arg2fixturedefs - for argname, valuelist in arg2params.items(): - # If we have a scope that is higher than function, we need - # to make sure we only ever create an according fixturedef on - # a per-scope basis. We thus store and cache the fixturedef on the - # node related to the scope. - scope = arg2scope[argname] - node = None - if scope is not Scope.Function: - node = get_scope_node(collector, scope) - if node is None: - assert scope is Scope.Class and isinstance( - collector, _pytest.python.Module - ) - # Use module-level collector for class-scope (for now). - node = collector - if node is None: - name2pseudofixturedef = None - else: - default: Dict[str, FixtureDef[Any]] = {} - name2pseudofixturedef = node.stash.setdefault( - name2pseudofixturedef_key, default - ) - if name2pseudofixturedef is not None and argname in name2pseudofixturedef: - arg2fixturedefs[argname] = [name2pseudofixturedef[argname]] - else: - fixturedef = FixtureDef( - fixturemanager=fixturemanager, - baseid="", - argname=argname, - func=get_direct_param_fixture_func, - scope=arg2scope[argname], - params=valuelist, - unittest=False, - ids=None, - ) - arg2fixturedefs[argname] = [fixturedef] - if name2pseudofixturedef is not None: - name2pseudofixturedef[argname] = fixturedef +# Algorithm for sorting on a per-parametrized resource setup basis. +# It is called for Session scope first and performs sorting +# down to the lower scopes such as to minimize number of "high scope" +# setups and teardowns. -def getfixturemarker(obj: object) -> Optional["FixtureFunctionMarker"]: - """Return fixturemarker or None if it doesn't exist or raised - exceptions.""" - return cast( - Optional[FixtureFunctionMarker], - safe_getattr(obj, "_pytestfixturefunction", None), - ) +@dataclasses.dataclass(frozen=True) +class ParamArgKey: + """A key for a high-scoped parameter used by an item. + + For use as a hashable key in `reorder_items`. The combination of fields + is meant to uniquely identify a particular "instance" of a param, + potentially shared by multiple items in a scope. + """ + + #: The param name. + argname: str + param_index: int + #: For scopes Package, Module, Class, the path to the file (directory in + #: Package's case) of the package/module/class where the item is defined. + scoped_item_path: Path | None + #: For Class scope, the class where the item is defined. + item_cls: type | None -# Parametrized fixture key, helper alias for code below. -_Key = Tuple[object, ...] +_V = TypeVar("_V") +OrderedSet = dict[_V, None] -def get_parametrized_fixture_keys(item: nodes.Item, scope: Scope) -> Iterator[_Key]: - """Return list of keys for all parametrized arguments which match - the specified scope.""" +def get_param_argkeys(item: nodes.Item, scope: Scope) -> Iterator[ParamArgKey]: + """Return all ParamArgKeys for item matching the specified high scope.""" assert scope is not Scope.Function + try: - callspec = item.callspec # type: ignore[attr-defined] + callspec: CallSpec2 = item.callspec # type: ignore[attr-defined] except AttributeError: - pass - else: - cs: CallSpec2 = callspec - # cs.indices.items() is random order of argnames. Need to - # sort this so that different calls to - # get_parametrized_fixture_keys will be deterministic. - for argname, param_index in sorted(cs.indices.items()): - if cs._arg2scope[argname] != scope: - continue - if scope is Scope.Session: - key: _Key = (argname, param_index) - elif scope is Scope.Package: - key = (argname, param_index, item.path.parent) - elif scope is Scope.Module: - key = (argname, param_index, item.path) - elif scope is Scope.Class: - item_cls = item.cls # type: ignore[attr-defined] - key = (argname, param_index, item.path, item_cls) - else: - assert_never(scope) - yield key + return + item_cls = None + if scope is Scope.Session: + scoped_item_path = None + elif scope is Scope.Package: + # Package key = module's directory. + scoped_item_path = item.path.parent + elif scope is Scope.Module: + scoped_item_path = item.path + elif scope is Scope.Class: + scoped_item_path = item.path + item_cls = item.cls # type: ignore[attr-defined] + else: + assert_never(scope) -# Algorithm for sorting on a per-parametrized resource setup basis. -# It is called for Session scope first and performs sorting -# down to the lower scopes such as to minimize number of "high scope" -# setups and teardowns. + for argname in callspec.indices: + if callspec._arg2scope[argname] != scope: + continue + param_index = callspec.indices[argname] + yield ParamArgKey(argname, param_index, scoped_item_path, item_cls) -def reorder_items(items: Sequence[nodes.Item]) -> List[nodes.Item]: - argkeys_cache: Dict[Scope, Dict[nodes.Item, Dict[_Key, None]]] = {} - items_by_argkey: Dict[Scope, Dict[_Key, Deque[nodes.Item]]] = {} +def reorder_items(items: Sequence[nodes.Item]) -> list[nodes.Item]: + argkeys_by_item: dict[Scope, dict[nodes.Item, OrderedSet[ParamArgKey]]] = {} + items_by_argkey: dict[Scope, dict[ParamArgKey, OrderedDict[nodes.Item, None]]] = {} for scope in HIGH_SCOPES: - d: Dict[nodes.Item, Dict[_Key, None]] = {} - argkeys_cache[scope] = d - item_d: Dict[_Key, Deque[nodes.Item]] = defaultdict(deque) - items_by_argkey[scope] = item_d + scoped_argkeys_by_item = argkeys_by_item[scope] = {} + scoped_items_by_argkey = items_by_argkey[scope] = defaultdict(OrderedDict) for item in items: - keys = dict.fromkeys(get_parametrized_fixture_keys(item, scope), None) - if keys: - d[item] = keys - for key in keys: - item_d[key].append(item) - items_dict = dict.fromkeys(items, None) + argkeys = dict.fromkeys(get_param_argkeys(item, scope)) + if argkeys: + scoped_argkeys_by_item[item] = argkeys + for argkey in argkeys: + scoped_items_by_argkey[argkey][item] = None + + items_set = dict.fromkeys(items) return list( - reorder_items_atscope(items_dict, argkeys_cache, items_by_argkey, Scope.Session) + reorder_items_atscope( + items_set, argkeys_by_item, items_by_argkey, Scope.Session + ) ) -def fix_cache_order( - item: nodes.Item, - argkeys_cache: Dict[Scope, Dict[nodes.Item, Dict[_Key, None]]], - items_by_argkey: Dict[Scope, Dict[_Key, "Deque[nodes.Item]"]], -) -> None: - for scope in HIGH_SCOPES: - for key in argkeys_cache[scope].get(item, []): - items_by_argkey[scope][key].appendleft(item) - - def reorder_items_atscope( - items: Dict[nodes.Item, None], - argkeys_cache: Dict[Scope, Dict[nodes.Item, Dict[_Key, None]]], - items_by_argkey: Dict[Scope, Dict[_Key, "Deque[nodes.Item]"]], + items: OrderedSet[nodes.Item], + argkeys_by_item: Mapping[Scope, Mapping[nodes.Item, OrderedSet[ParamArgKey]]], + items_by_argkey: Mapping[ + Scope, Mapping[ParamArgKey, OrderedDict[nodes.Item, None]] + ], scope: Scope, -) -> Dict[nodes.Item, None]: +) -> OrderedSet[nodes.Item]: if scope is Scope.Function or len(items) < 3: return items - ignore: Set[Optional[_Key]] = set() - items_deque = deque(items) - items_done: Dict[nodes.Item, None] = {} + scoped_items_by_argkey = items_by_argkey[scope] - scoped_argkeys_cache = argkeys_cache[scope] + scoped_argkeys_by_item = argkeys_by_item[scope] + + ignore: set[ParamArgKey] = set() + items_deque = deque(items) + items_done: OrderedSet[nodes.Item] = {} while items_deque: - no_argkey_group: Dict[nodes.Item, None] = {} + no_argkey_items: OrderedSet[nodes.Item] = {} slicing_argkey = None while items_deque: item = items_deque.popleft() - if item in items_done or item in no_argkey_group: + if item in items_done or item in no_argkey_items: continue argkeys = dict.fromkeys( - (k for k in scoped_argkeys_cache.get(item, []) if k not in ignore), None + k for k in scoped_argkeys_by_item.get(item, ()) if k not in ignore ) if not argkeys: - no_argkey_group[item] = None + no_argkey_items[item] = None else: slicing_argkey, _ = argkeys.popitem() # We don't have to remove relevant items from later in the @@ -335,35 +282,64 @@ def reorder_items_atscope( i for i in scoped_items_by_argkey[slicing_argkey] if i in items ] for i in reversed(matching_items): - fix_cache_order(i, argkeys_cache, items_by_argkey) items_deque.appendleft(i) + # Fix items_by_argkey order. + for other_scope in HIGH_SCOPES: + other_scoped_items_by_argkey = items_by_argkey[other_scope] + for argkey in argkeys_by_item[other_scope].get(i, ()): + argkey_dict = other_scoped_items_by_argkey[argkey] + if not hasattr(sys, "pypy_version_info"): + argkey_dict[i] = None + argkey_dict.move_to_end(i, last=False) + else: + # Work around a bug in PyPy: + # https://github.com/pypy/pypy/issues/5257 + # https://github.com/pytest-dev/pytest/issues/13312 + bkp = argkey_dict.copy() + argkey_dict.clear() + argkey_dict[i] = None + argkey_dict.update(bkp) break - if no_argkey_group: - no_argkey_group = reorder_items_atscope( - no_argkey_group, argkeys_cache, items_by_argkey, scope.next_lower() + if no_argkey_items: + reordered_no_argkey_items = reorder_items_atscope( + no_argkey_items, argkeys_by_item, items_by_argkey, scope.next_lower() ) - for item in no_argkey_group: - items_done[item] = None - ignore.add(slicing_argkey) + items_done.update(reordered_no_argkey_items) + if slicing_argkey is not None: + ignore.add(slicing_argkey) return items_done -def get_direct_param_fixture_func(request: "FixtureRequest") -> Any: - return request.param +@dataclasses.dataclass(frozen=True) +class FuncFixtureInfo: + """Fixture-related information for a fixture-requesting item (e.g. test + function). + This is used to examine the fixtures which an item requests statically + (known during collection). This includes autouse fixtures, fixtures + requested by the `usefixtures` marker, fixtures requested in the function + parameters, and the transitive closure of these. -@dataclasses.dataclass -class FuncFixtureInfo: - __slots__ = ("argnames", "initialnames", "names_closure", "name2fixturedefs") + An item may also request fixtures dynamically (using `request.getfixturevalue`); + these are not reflected here. + """ - # Original function argument names. - argnames: Tuple[str, ...] - # Argnames that function immediately requires. These include argnames + - # fixture names specified via usefixtures and via autouse=True in fixture - # definitions. - initialnames: Tuple[str, ...] - names_closure: List[str] - name2fixturedefs: Dict[str, Sequence["FixtureDef[Any]"]] + __slots__ = ("argnames", "initialnames", "name2fixturedefs", "names_closure") + + # Fixture names that the item requests directly by function parameters. + argnames: tuple[str, ...] + # Fixture names that the item immediately requires. These include + # argnames + fixture names specified via usefixtures and via autouse=True in + # fixture definitions. + initialnames: tuple[str, ...] + # The transitive closure of the fixture names that the item requires. + # Note: can't include dynamic dependencies (`request.getfixturevalue` calls). + names_closure: list[str] + # A map from a fixture name in the transitive closure to the FixtureDefs + # matching the name which are applicable to this function. + # There may be multiple overriding fixtures with the same name. The + # sequence is ordered from furthest to closes to the function. + name2fixturedefs: dict[str, Sequence[FixtureDef[Any]]] def prune_dependency_tree(self) -> None: """Recompute names_closure from initialnames and name2fixturedefs. @@ -376,11 +352,11 @@ def prune_dependency_tree(self) -> None: tree. In this way the dependency tree can get pruned, and the closure of argnames may get reduced. """ - closure: Set[str] = set() + closure: set[str] = set() working_set = set(self.initialnames) while working_set: argname = working_set.pop() - # Argname may be smth not included in the original names_closure, + # Argname may be something not included in the original names_closure, # in which case we ignore it. This currently happens with pseudo # FixtureDefs which wrap 'get_direct_param_fixture_func(request)'. # So they introduce the new dependency 'request' which might have @@ -393,25 +369,34 @@ def prune_dependency_tree(self) -> None: self.names_closure[:] = sorted(closure, key=self.names_closure.index) -class FixtureRequest: - """A request for a fixture from a test or fixture function. +class FixtureRequest(abc.ABC): + """The type of the ``request`` fixture. - A request object gives access to the requesting test context and has - an optional ``param`` attribute in case the fixture is parametrized - indirectly. + A request object gives access to the requesting test context and has a + ``param`` attribute in case the fixture is parametrized. """ - def __init__(self, pyfuncitem, *, _ispytest: bool = False) -> None: + def __init__( + self, + pyfuncitem: Function, + fixturename: str | None, + arg2fixturedefs: dict[str, Sequence[FixtureDef[Any]]], + fixture_defs: dict[str, FixtureDef[Any]], + *, + _ispytest: bool = False, + ) -> None: check_ispytest(_ispytest) - self._pyfuncitem = pyfuncitem #: Fixture for which this request is being performed. - self.fixturename: Optional[str] = None - self._scope = Scope.Function - self._fixture_defs: Dict[str, FixtureDef[Any]] = {} - fixtureinfo: FuncFixtureInfo = pyfuncitem._fixtureinfo - self._arg2fixturedefs = fixtureinfo.name2fixturedefs.copy() - self._arg2index: Dict[str, int] = {} - self._fixturemanager: FixtureManager = pyfuncitem.session._fixturemanager + self.fixturename: Final = fixturename + self._pyfuncitem: Final = pyfuncitem + # The FixtureDefs for each fixture name requested by this item. + # Starts from the statically-known fixturedefs resolved during + # collection. Dynamically requested fixtures (using + # `request.getfixturevalue("foo")`) are added dynamically. + self._arg2fixturedefs: Final = arg2fixturedefs + # The evaluated argnames so far, mapping to the FixtureDef they resolved + # to. + self._fixture_defs: Final = fixture_defs # Notes on the type of `param`: # -`request.param` is only defined in parametrized fixtures, and will raise # AttributeError otherwise. Python typing has no notion of "undefined", so @@ -423,61 +408,44 @@ def __init__(self, pyfuncitem, *, _ispytest: bool = False) -> None: self.param: Any @property - def scope(self) -> "_ScopeName": + def _fixturemanager(self) -> FixtureManager: + return self._pyfuncitem.session._fixturemanager + + @property + @abc.abstractmethod + def _scope(self) -> Scope: + raise NotImplementedError() + + @property + def scope(self) -> _ScopeName: """Scope string, one of "function", "class", "module", "package", "session".""" return self._scope.value + @abc.abstractmethod + def _check_scope( + self, + requested_fixturedef: FixtureDef[object] | PseudoFixtureDef[object], + requested_scope: Scope, + ) -> None: + raise NotImplementedError() + @property - def fixturenames(self) -> List[str]: + def fixturenames(self) -> list[str]: """Names of all active fixtures in this request.""" - result = list(self._pyfuncitem._fixtureinfo.names_closure) + result = list(self._pyfuncitem.fixturenames) result.extend(set(self._fixture_defs).difference(result)) return result @property + @abc.abstractmethod def node(self): """Underlying collection node (depends on current request scope).""" - scope = self._scope - if scope is Scope.Function: - # This might also be a non-function Item despite its attribute name. - node: Optional[Union[nodes.Item, nodes.Collector]] = self._pyfuncitem - elif scope is Scope.Package: - # FIXME: _fixturedef is not defined on FixtureRequest (this class), - # but on FixtureRequest (a subclass). - node = get_scope_package(self._pyfuncitem, self._fixturedef) # type: ignore[attr-defined] - else: - node = get_scope_node(self._pyfuncitem, scope) - if node is None and scope is Scope.Class: - # Fallback to function item itself. - node = self._pyfuncitem - assert node, 'Could not obtain a node for scope "{}" for function {!r}'.format( - scope, self._pyfuncitem - ) - return node - - def _getnextfixturedef(self, argname: str) -> "FixtureDef[Any]": - fixturedefs = self._arg2fixturedefs.get(argname, None) - if fixturedefs is None: - # We arrive here because of a dynamic call to - # getfixturevalue(argname) usage which was naturally - # not known at parsing/collection time. - assert self._pyfuncitem.parent is not None - parentid = self._pyfuncitem.parent.nodeid - fixturedefs = self._fixturemanager.getfixturedefs(argname, parentid) - # TODO: Fix this type ignore. Either add assert or adjust types. - # Can this be None here? - self._arg2fixturedefs[argname] = fixturedefs # type: ignore[assignment] - # fixturedefs list is immutable so we maintain a decreasing index. - index = self._arg2index.get(argname, 0) - 1 - if fixturedefs is None or (-index > len(fixturedefs)): - raise FixtureLookupError(argname, self) - self._arg2index[argname] = index - return fixturedefs[index] + raise NotImplementedError() @property def config(self) -> Config: """The pytest config object associated with this request.""" - return self._pyfuncitem.config # type: ignore[no-any-return] + return self._pyfuncitem.config @property def function(self): @@ -500,27 +468,25 @@ def cls(self): @property def instance(self): """Instance (can be None) on which test function was collected.""" - # unittest support hack, see _pytest.unittest.TestCaseFunction. - try: - return self._pyfuncitem._testcase - except AttributeError: - function = getattr(self, "function", None) - return getattr(function, "__self__", None) + if self.scope != "function": + return None + return getattr(self._pyfuncitem, "instance", None) @property def module(self): """Python module object where the test function was collected.""" if self.scope not in ("function", "class", "module"): raise AttributeError(f"module not available in {self.scope}-scoped context") - return self._pyfuncitem.getparent(_pytest.python.Module).obj + mod = self._pyfuncitem.getparent(_pytest.python.Module) + assert mod is not None + return mod.obj @property def path(self) -> Path: """Path where the test function was collected.""" if self.scope not in ("function", "class", "module", "package"): raise AttributeError(f"path not available in {self.scope}-scoped context") - # TODO: Remove ignore once _pyfuncitem is properly typed. - return self._pyfuncitem.path # type: ignore + return self._pyfuncitem.path @property def keywords(self) -> MutableMapping[str, Any]: @@ -529,17 +495,17 @@ def keywords(self) -> MutableMapping[str, Any]: return node.keywords @property - def session(self) -> "Session": + def session(self) -> Session: """Pytest session object.""" - return self._pyfuncitem.session # type: ignore[no-any-return] + return self._pyfuncitem.session + @abc.abstractmethod def addfinalizer(self, finalizer: Callable[[], object]) -> None: """Add finalizer/teardown function to be called without arguments after the last test within the requesting test context finished execution.""" - # XXX usually this method is shadowed by fixturedef specific ones. - self.node.addfinalizer(finalizer) + raise NotImplementedError() - def applymarker(self, marker: Union[str, MarkDecorator]) -> None: + def applymarker(self, marker: str | MarkDecorator) -> None: """Apply a marker to a single test function invocation. This method is useful if you don't want to have a keyword/marker @@ -550,20 +516,13 @@ def applymarker(self, marker: Union[str, MarkDecorator]) -> None: """ self.node.add_marker(marker) - def raiseerror(self, msg: Optional[str]) -> NoReturn: + def raiseerror(self, msg: str | None) -> NoReturn: """Raise a FixtureLookupError exception. :param msg: An optional custom error message. """ - raise self._fixturemanager.FixtureLookupError(None, self, msg) - - def _fillfixtures(self) -> None: - item = self._pyfuncitem - fixturenames = getattr(item, "fixturenames", self.fixturenames) - for argname in fixturenames: - if argname not in item.funcargs: - item.funcargs[argname] = self.getfixturevalue(argname) + raise FixtureLookupError(None, self, msg) def getfixturevalue(self, argname: str) -> Any: """Dynamically run a named fixture function. @@ -582,6 +541,11 @@ def getfixturevalue(self, argname: str) -> Any: :raises pytest.FixtureLookupError: If the given fixture could not be found. """ + # Note that in addition to the use case described in the docstring, + # getfixturevalue() is also called by pytest itself during item and fixture + # setup to evaluate the fixtures that are requested statically + # (using function parameters, autouse, etc). + fixturedef = self._get_active_fixturedef(argname) assert fixturedef.cached_result is not None, ( f'The fixture value for "{argname}" is not available. ' @@ -589,200 +553,263 @@ def getfixturevalue(self, argname: str) -> Any: ) return fixturedef.cached_result[0] - def _get_active_fixturedef( - self, argname: str - ) -> Union["FixtureDef[object]", PseudoFixtureDef[object]]: - try: - return self._fixture_defs[argname] - except KeyError: - try: - fixturedef = self._getnextfixturedef(argname) - except FixtureLookupError: - if argname == "request": - cached_result = (self, [0], None) - return PseudoFixtureDef(cached_result, Scope.Function) - raise - # Remove indent to prevent the python3 exception - # from leaking into the call. - self._compute_fixture_value(fixturedef) - self._fixture_defs[argname] = fixturedef - return fixturedef + def _iter_chain(self) -> Iterator[SubRequest]: + """Yield all SubRequests in the chain, from self up. - def _get_fixturestack(self) -> List["FixtureDef[Any]"]: + Note: does *not* yield the TopRequest. + """ current = self - values: List[FixtureDef[Any]] = [] while isinstance(current, SubRequest): - values.append(current._fixturedef) # type: ignore[has-type] + yield current current = current._parent_request - values.reverse() - return values - def _compute_fixture_value(self, fixturedef: "FixtureDef[object]") -> None: - """Create a SubRequest based on "self" and call the execute method - of the given FixtureDef object. + def _get_active_fixturedef( + self, argname: str + ) -> FixtureDef[object] | PseudoFixtureDef[object]: + if argname == "request": + cached_result = (self, [0], None) + return PseudoFixtureDef(cached_result, Scope.Function) + + # If we already finished computing a fixture by this name in this item, + # return it. + fixturedef = self._fixture_defs.get(argname) + if fixturedef is not None: + self._check_scope(fixturedef, fixturedef._scope) + return fixturedef - This will force the FixtureDef object to throw away any previous - results and compute a new fixture value, which will be stored into - the FixtureDef object itself. - """ - # prepare a subrequest object before calling fixture function - # (latter managed by fixturedef) - argname = fixturedef.argname - funcitem = self._pyfuncitem - scope = fixturedef._scope + # Find the appropriate fixturedef. + fixturedefs = self._arg2fixturedefs.get(argname, None) + if fixturedefs is None: + # We arrive here because of a dynamic call to + # getfixturevalue(argname) which was naturally + # not known at parsing/collection time. + fixturedefs = self._fixturemanager.getfixturedefs(argname, self._pyfuncitem) + if fixturedefs is not None: + self._arg2fixturedefs[argname] = fixturedefs + # No fixtures defined with this name. + if fixturedefs is None: + raise FixtureLookupError(argname, self) + # The are no fixtures with this name applicable for the function. + if not fixturedefs: + raise FixtureLookupError(argname, self) + + # A fixture may override another fixture with the same name, e.g. a + # fixture in a module can override a fixture in a conftest, a fixture in + # a class can override a fixture in the module, and so on. + # An overriding fixture can request its own name (possibly indirectly); + # in this case it gets the value of the fixture it overrides, one level + # up. + # Check how many `argname`s deep we are, and take the next one. + # `fixturedefs` is sorted from furthest to closest, so use negative + # indexing to go in reverse. + index = -1 + for request in self._iter_chain(): + if request.fixturename == argname: + index -= 1 + # If already consumed all of the available levels, fail. + if -index > len(fixturedefs): + raise FixtureLookupError(argname, self) + fixturedef = fixturedefs[index] + + # Prepare a SubRequest object for calling the fixture. try: - callspec = funcitem.callspec + callspec = self._pyfuncitem.callspec except AttributeError: callspec = None if callspec is not None and argname in callspec.params: param = callspec.params[argname] param_index = callspec.indices[argname] - # If a parametrize invocation set a scope it will override - # the static scope defined with the fixture function. - with suppress(KeyError): - scope = callspec._arg2scope[argname] + # The parametrize invocation scope overrides the fixture's scope. + scope = callspec._arg2scope[argname] else: param = NOTSET param_index = 0 - has_params = fixturedef.params is not None - fixtures_not_supported = getattr(funcitem, "nofuncargs", False) - if has_params and fixtures_not_supported: - msg = ( - "{name} does not support fixtures, maybe unittest.TestCase subclass?\n" - "Node id: {nodeid}\n" - "Function type: {typename}" - ).format( - name=funcitem.name, - nodeid=funcitem.nodeid, - typename=type(funcitem).__name__, - ) - fail(msg, pytrace=False) - if has_params: - frame = inspect.stack()[3] - frameinfo = inspect.getframeinfo(frame[0]) - source_path = absolutepath(frameinfo.filename) - source_lineno = frameinfo.lineno - try: - source_path_str = str( - source_path.relative_to(funcitem.config.rootpath) - ) - except ValueError: - source_path_str = str(source_path) - msg = ( - "The requested fixture has no parameter defined for test:\n" - " {}\n\n" - "Requested fixture '{}' defined in:\n{}" - "\n\nRequested here:\n{}:{}".format( - funcitem.nodeid, - fixturedef.argname, - getlocation(fixturedef.func, funcitem.config.rootpath), - source_path_str, - source_lineno, - ) - ) - fail(msg, pytrace=False) - + scope = fixturedef._scope + self._check_fixturedef_without_param(fixturedef) + # The parametrize invocation scope only controls caching behavior while + # allowing wider-scoped fixtures to keep depending on the parametrized + # fixture. Scope control is enforced for parametrized fixtures + # by recreating the whole fixture tree on parameter change. + # Hence `fixturedef._scope`, not `scope`. + self._check_scope(fixturedef, fixturedef._scope) subrequest = SubRequest( self, scope, param, param_index, fixturedef, _ispytest=True ) - # Check if a higher-level scoped fixture accesses a lower level one. - subrequest._check_scope(argname, self._scope, scope) - try: - # Call the fixture function. - fixturedef.execute(request=subrequest) - finally: - self._schedule_finalizers(fixturedef, subrequest) + # Make sure the fixture value is cached, running it if it isn't + fixturedef.execute(request=subrequest) - def _schedule_finalizers( - self, fixturedef: "FixtureDef[object]", subrequest: "SubRequest" - ) -> None: - # If fixture function failed it might have registered finalizers. - subrequest.node.addfinalizer(lambda: fixturedef.finish(request=subrequest)) + self._fixture_defs[argname] = fixturedef + return fixturedef + + def _check_fixturedef_without_param(self, fixturedef: FixtureDef[object]) -> None: + """Check that this request is allowed to execute this fixturedef without + a param.""" + funcitem = self._pyfuncitem + has_params = fixturedef.params is not None + fixtures_not_supported = getattr(funcitem, "nofuncargs", False) + if has_params and fixtures_not_supported: + msg = ( + f"{funcitem.name} does not support fixtures, maybe unittest.TestCase subclass?\n" + f"Node id: {funcitem.nodeid}\n" + f"Function type: {type(funcitem).__name__}" + ) + fail(msg, pytrace=False) + if has_params: + frame = inspect.stack()[3] + frameinfo = inspect.getframeinfo(frame[0]) + source_path = absolutepath(frameinfo.filename) + source_lineno = frameinfo.lineno + try: + source_path_str = str(source_path.relative_to(funcitem.config.rootpath)) + except ValueError: + source_path_str = str(source_path) + location = getlocation(fixturedef.func, funcitem.config.rootpath) + msg = ( + "The requested fixture has no parameter defined for test:\n" + f" {funcitem.nodeid}\n\n" + f"Requested fixture '{fixturedef.argname}' defined in:\n" + f"{location}\n\n" + f"Requested here:\n" + f"{source_path_str}:{source_lineno}" + ) + fail(msg, pytrace=False) + + def _get_fixturestack(self) -> list[FixtureDef[Any]]: + values = [request._fixturedef for request in self._iter_chain()] + values.reverse() + return values + + +@final +class TopRequest(FixtureRequest): + """The type of the ``request`` fixture in a test function.""" + + def __init__(self, pyfuncitem: Function, *, _ispytest: bool = False) -> None: + super().__init__( + fixturename=None, + pyfuncitem=pyfuncitem, + arg2fixturedefs=pyfuncitem._fixtureinfo.name2fixturedefs.copy(), + fixture_defs={}, + _ispytest=_ispytest, + ) + + @property + def _scope(self) -> Scope: + return Scope.Function def _check_scope( self, - argname: str, - invoking_scope: Scope, + requested_fixturedef: FixtureDef[object] | PseudoFixtureDef[object], requested_scope: Scope, ) -> None: - if argname == "request": - return - if invoking_scope > requested_scope: - # Try to report something helpful. - text = "\n".join(self._factorytraceback()) - fail( - f"ScopeMismatch: You tried to access the {requested_scope.value} scoped " - f"fixture {argname} with a {invoking_scope.value} scoped request object, " - f"involved factories:\n{text}", - pytrace=False, - ) + # TopRequest always has function scope so always valid. + pass - def _factorytraceback(self) -> List[str]: - lines = [] - for fixturedef in self._get_fixturestack(): - factory = fixturedef.func - fs, lineno = getfslineno(factory) - if isinstance(fs, Path): - session: Session = self._pyfuncitem.session - p = bestrelpath(session.path, fs) - else: - p = fs - args = _format_args(factory) - lines.append("%s:%d: def %s%s" % (p, lineno + 1, factory.__name__, args)) - return lines + @property + def node(self): + return self._pyfuncitem def __repr__(self) -> str: - return "" % (self.node) + return f"" + + def _fillfixtures(self) -> None: + item = self._pyfuncitem + for argname in item.fixturenames: + if argname not in item.funcargs: + item.funcargs[argname] = self.getfixturevalue(argname) + + def addfinalizer(self, finalizer: Callable[[], object]) -> None: + self.node.addfinalizer(finalizer) @final class SubRequest(FixtureRequest): - """A sub request for handling getting a fixture from a test function/fixture.""" + """The type of the ``request`` fixture in a fixture function requested + (transitively) by a test function.""" def __init__( self, - request: "FixtureRequest", + request: FixtureRequest, scope: Scope, param: Any, param_index: int, - fixturedef: "FixtureDef[object]", + fixturedef: FixtureDef[object], *, _ispytest: bool = False, ) -> None: - check_ispytest(_ispytest) - self._parent_request = request - self.fixturename = fixturedef.argname + super().__init__( + pyfuncitem=request._pyfuncitem, + fixturename=fixturedef.argname, + fixture_defs=request._fixture_defs, + arg2fixturedefs=request._arg2fixturedefs, + _ispytest=_ispytest, + ) + self._parent_request: Final[FixtureRequest] = request + self._scope_field: Final = scope + self._fixturedef: Final[FixtureDef[object]] = fixturedef if param is not NOTSET: self.param = param - self.param_index = param_index - self._scope = scope - self._fixturedef = fixturedef - self._pyfuncitem = request._pyfuncitem - self._fixture_defs = request._fixture_defs - self._arg2fixturedefs = request._arg2fixturedefs - self._arg2index = request._arg2index - self._fixturemanager = request._fixturemanager + self.param_index: Final = param_index def __repr__(self) -> str: return f"" - def addfinalizer(self, finalizer: Callable[[], object]) -> None: - """Add finalizer/teardown function to be called without arguments after - the last test within the requesting test context finished execution.""" - self._fixturedef.addfinalizer(finalizer) + @property + def _scope(self) -> Scope: + return self._scope_field - def _schedule_finalizers( - self, fixturedef: "FixtureDef[object]", subrequest: "SubRequest" + @property + def node(self): + scope = self._scope + if scope is Scope.Function: + # This might also be a non-function Item despite its attribute name. + node: nodes.Node | None = self._pyfuncitem + elif scope is Scope.Package: + node = get_scope_package(self._pyfuncitem, self._fixturedef) + else: + node = get_scope_node(self._pyfuncitem, scope) + if node is None and scope is Scope.Class: + # Fallback to function item itself. + node = self._pyfuncitem + assert node, ( + f'Could not obtain a node for scope "{scope}" for function {self._pyfuncitem!r}' + ) + return node + + def _check_scope( + self, + requested_fixturedef: FixtureDef[object] | PseudoFixtureDef[object], + requested_scope: Scope, ) -> None: - # If the executing fixturedef was not explicitly requested in the argument list (via - # getfixturevalue inside the fixture call) then ensure this fixture def will be finished - # first. - if fixturedef.argname not in self.fixturenames: - fixturedef.addfinalizer( - functools.partial(self._fixturedef.finish, request=self) + if isinstance(requested_fixturedef, PseudoFixtureDef): + return + if self._scope > requested_scope: + # Try to report something helpful. + argname = requested_fixturedef.argname + fixture_stack = "\n".join( + self._format_fixturedef_line(fixturedef) + for fixturedef in self._get_fixturestack() + ) + requested_fixture = self._format_fixturedef_line(requested_fixturedef) + fail( + f"ScopeMismatch: You tried to access the {requested_scope.value} scoped " + f"fixture {argname} with a {self._scope.value} scoped request object. " + f"Requesting fixture stack:\n{fixture_stack}\n" + f"Requested fixture:\n{requested_fixture}", + pytrace=False, ) - super()._schedule_finalizers(fixturedef, subrequest) + + def _format_fixturedef_line(self, fixturedef: FixtureDef[object]) -> str: + factory = fixturedef.func + path, lineno = getfslineno(factory) + if isinstance(path, Path): + path = bestrelpath(self._pyfuncitem.session.path, path) + sig = signature(factory) + return f"{path}:{lineno + 1}: def {factory.__name__}{sig}" + + def addfinalizer(self, finalizer: Callable[[], object]) -> None: + self._fixturedef.addfinalizer(finalizer) @final @@ -790,19 +817,28 @@ class FixtureLookupError(LookupError): """Could not return a requested fixture (missing or invalid).""" def __init__( - self, argname: Optional[str], request: FixtureRequest, msg: Optional[str] = None + self, argname: str | None, request: FixtureRequest, msg: str | None = None ) -> None: self.argname = argname self.request = request self.fixturestack = request._get_fixturestack() self.msg = msg - def formatrepr(self) -> "FixtureLookupErrorRepr": - tblines: List[str] = [] + def formatrepr(self) -> FixtureLookupErrorRepr: + tblines: list[str] = [] addline = tblines.append stack = [self.request._pyfuncitem.obj] stack.extend(map(lambda x: x.func, self.fixturestack)) msg = self.msg + # This function currently makes an assumption that a non-None msg means we + # have a non-empty `self.fixturestack`. This is currently true, but if + # somebody at some point want to extend the use of FixtureLookupError to + # new cases it might break. + # Add the assert to make it clearer to developer that this will fail, otherwise + # it crashes because `fspath` does not get set due to `stack` being empty. + assert self.msg is None or self.fixturestack, ( + "formatrepr assumptions broken, rewrite it to handle it" + ) if msg is not None: # The last fixture raise an error, let's present # it at the requesting side. @@ -825,14 +861,15 @@ def formatrepr(self) -> "FixtureLookupErrorRepr": if msg is None: fm = self.request._fixturemanager available = set() - parentid = self.request._pyfuncitem.parent.nodeid + parent = self.request._pyfuncitem.parent + assert parent is not None for name, fixturedefs in fm._arg2fixturedefs.items(): - faclist = list(fm._matchfactories(fixturedefs, parentid)) + faclist = list(fm._matchfactories(fixturedefs, parent)) if faclist: available.add(name) if self.argname in available: - msg = " recursive dependency involving fixture '{}' detected".format( - self.argname + msg = ( + f" recursive dependency involving fixture '{self.argname}' detected" ) else: msg = f"fixture '{self.argname}' not found" @@ -845,11 +882,11 @@ def formatrepr(self) -> "FixtureLookupErrorRepr": class FixtureLookupErrorRepr(TerminalRepr): def __init__( self, - filename: Union[str, "os.PathLike[str]"], + filename: str | os.PathLike[str], firstlineno: int, tblines: Sequence[str], errorstring: str, - argname: Optional[str], + argname: str | None, ) -> None: self.tblines = tblines self.errorstring = errorstring @@ -873,23 +910,14 @@ def toterminal(self, tw: TerminalWriter) -> None: red=True, ) tw.line() - tw.line("%s:%d" % (os.fspath(self.filename), self.firstlineno + 1)) - - -def fail_fixturefunc(fixturefunc, msg: str) -> NoReturn: - fs, lineno = getfslineno(fixturefunc) - location = f"{fs}:{lineno + 1}" - source = _pytest._code.Source(fixturefunc) - fail(msg + ":\n\n" + str(source.indent()) + "\n" + location, pytrace=False) + tw.line(f"{os.fspath(self.filename)}:{self.firstlineno + 1}") def call_fixture_func( - fixturefunc: "_FixtureFunc[FixtureValue]", request: FixtureRequest, kwargs + fixturefunc: _FixtureFunc[FixtureValue], request: FixtureRequest, kwargs ) -> FixtureValue: - if is_generator(fixturefunc): - fixturefunc = cast( - Callable[..., Generator[FixtureValue, None, None]], fixturefunc - ) + if inspect.isgeneratorfunction(fixturefunc): + fixturefunc = cast(Callable[..., Generator[FixtureValue]], fixturefunc) generator = fixturefunc(**kwargs) try: fixture_result = next(generator) @@ -912,29 +940,33 @@ def _teardown_yield_fixture(fixturefunc, it) -> None: except StopIteration: pass else: - fail_fixturefunc(fixturefunc, "fixture function has more than one 'yield'") + fs, lineno = getfslineno(fixturefunc) + fail( + f"fixture function has more than one 'yield':\n\n" + f"{Source(fixturefunc).indent()}\n" + f"{fs}:{lineno + 1}", + pytrace=False, + ) def _eval_scope_callable( - scope_callable: "Callable[[str, Config], _ScopeName]", + scope_callable: Callable[[str, Config], _ScopeName], fixture_name: str, config: Config, -) -> "_ScopeName": +) -> _ScopeName: try: # Type ignored because there is no typing mechanism to specify # keyword arguments, currently. result = scope_callable(fixture_name=fixture_name, config=config) # type: ignore[call-arg] except Exception as e: raise TypeError( - "Error evaluating {} while defining fixture '{}'.\n" - "Expected a function with the signature (*, fixture_name, config)".format( - scope_callable, fixture_name - ) + f"Error evaluating {scope_callable} while defining fixture '{fixture_name}'.\n" + "Expected a function with the signature (*, fixture_name, config)" ) from e if not isinstance(result, str): fail( - "Expected {} to return a 'str' while defining fixture '{}', but it returned:\n" - "{!r}".format(scope_callable, fixture_name, result), + f"Expected {scope_callable} to return a 'str' while defining fixture '{fixture_name}', but it returned:\n" + f"{result!r}", pytrace=False, ) return result @@ -942,28 +974,32 @@ def _eval_scope_callable( @final class FixtureDef(Generic[FixtureValue]): - """A container for a fixture definition.""" + """A container for a fixture definition. + + Note: At this time, only explicitly documented fields and methods are + considered public stable API. + """ def __init__( self, - fixturemanager: "FixtureManager", - baseid: Optional[str], + config: Config, + baseid: str | None, argname: str, - func: "_FixtureFunc[FixtureValue]", - scope: Union[Scope, "_ScopeName", Callable[[str, Config], "_ScopeName"], None], - params: Optional[Sequence[object]], - unittest: bool = False, - ids: Optional[ - Union[Tuple[Optional[object], ...], Callable[[Any], Optional[object]]] - ] = None, + func: _FixtureFunc[FixtureValue], + scope: Scope | _ScopeName | Callable[[str, Config], _ScopeName] | None, + params: Sequence[object] | None, + ids: tuple[object | None, ...] | Callable[[Any], object | None] | None = None, + *, + _ispytest: bool = False, + # only used in a deprecationwarning msg, can be removed in pytest9 + _autouse: bool = False, ) -> None: - self._fixturemanager = fixturemanager + check_ispytest(_ispytest) # The "base" node ID for the fixture. # # This is a node ID prefix. A fixture is only available to a node (e.g. - # a `Function` item) if the fixture's baseid is a parent of the node's - # nodeid (see the `iterparentnodeids` function for what constitutes a - # "parent" and a "prefix" in this context). + # a `Function` item) if the fixture's baseid is a nodeid of a parent of + # node. # # For a fixture found in a Collector's object (e.g. a `Module`s module, # a `Class`'s class), the baseid is the Collector's nodeid. @@ -972,43 +1008,42 @@ def __init__( # directory path relative to the rootdir. # # For other plugins, the baseid is the empty string (always matches). - self.baseid = baseid or "" + self.baseid: Final = baseid or "" # Whether the fixture was found from a node or a conftest in the # collection tree. Will be false for fixtures defined in non-conftest # plugins. - self.has_location = baseid is not None + self.has_location: Final = baseid is not None # The fixture factory function. - self.func = func + self.func: Final = func # The name by which the fixture may be requested. - self.argname = argname + self.argname: Final = argname if scope is None: scope = Scope.Function elif callable(scope): - scope = _eval_scope_callable(scope, argname, fixturemanager.config) + scope = _eval_scope_callable(scope, argname, config) if isinstance(scope, str): scope = Scope.from_user( scope, descr=f"Fixture '{func.__name__}'", where=baseid ) - self._scope = scope + self._scope: Final = scope # If the fixture is directly parametrized, the parameter values. - self.params: Optional[Sequence[object]] = params + self.params: Final = params # If the fixture is directly parametrized, a tuple of explicit IDs to # assign to the parameter values, or a callable to generate an ID given # a parameter value. - self.ids = ids + self.ids: Final = ids # The names requested by the fixtures. - self.argnames = getfuncargnames(func, name=argname, is_method=unittest) - # Whether the fixture was collected from a unittest TestCase class. - # Note that it really only makes sense to define autouse fixtures in - # unittest TestCases. - self.unittest = unittest + self.argnames: Final = getfuncargnames(func, name=argname) # If the fixture was executed, the current value of the fixture. # Can change if the fixture is executed with different parameters. - self.cached_result: Optional[_FixtureCachedResult[FixtureValue]] = None - self._finalizers: List[Callable[[], object]] = [] + self.cached_result: _FixtureCachedResult[FixtureValue] | None = None + self._finalizers: Final[list[Callable[[], object]]] = [] + + # only used to emit a deprecationwarning, can be removed in pytest9 + self._autouse = _autouse @property - def scope(self) -> "_ScopeName": + def scope(self) -> _ScopeName: """Scope string, one of "function", "class", "module", "package", "session".""" return self._scope.value @@ -1016,47 +1051,61 @@ def addfinalizer(self, finalizer: Callable[[], object]) -> None: self._finalizers.append(finalizer) def finish(self, request: SubRequest) -> None: - exc = None - try: - while self._finalizers: - try: - func = self._finalizers.pop() - func() - except BaseException as e: - # XXX Only first exception will be seen by user, - # ideally all should be reported. - if exc is None: - exc = e - if exc: - raise exc - finally: - ihook = request.node.ihook - ihook.pytest_fixture_post_finalizer(fixturedef=self, request=request) - # Even if finalization fails, we invalidate the cached fixture - # value and remove all finalizers because they may be bound methods - # which will keep instances alive. - self.cached_result = None - self._finalizers = [] + exceptions: list[BaseException] = [] + while self._finalizers: + fin = self._finalizers.pop() + try: + fin() + except BaseException as e: + exceptions.append(e) + node = request.node + node.ihook.pytest_fixture_post_finalizer(fixturedef=self, request=request) + # Even if finalization fails, we invalidate the cached fixture + # value and remove all finalizers because they may be bound methods + # which will keep instances alive. + self.cached_result = None + self._finalizers.clear() + if len(exceptions) == 1: + raise exceptions[0] + elif len(exceptions) > 1: + msg = f'errors while tearing down fixture "{self.argname}" of {node}' + raise BaseExceptionGroup(msg, exceptions[::-1]) def execute(self, request: SubRequest) -> FixtureValue: - # Get required arguments and register our own finish() - # with their finalization. + """Return the value of this fixture, executing it if not cached.""" + # Ensure that the dependent fixtures requested by this fixture are loaded. + # This needs to be done before checking if we have a cached value, since + # if a dependent fixture has their cache invalidated, e.g. due to + # parametrization, they finalize themselves and fixtures depending on it + # (which will likely include this fixture) setting `self.cached_result = None`. + # See #4871 + requested_fixtures_that_should_finalize_us = [] for argname in self.argnames: fixturedef = request._get_active_fixturedef(argname) - if argname != "request": - # PseudoFixtureDef is only for "request". - assert isinstance(fixturedef, FixtureDef) - fixturedef.addfinalizer(functools.partial(self.finish, request=request)) - - my_cache_key = self.cache_key(request) + # Saves requested fixtures in a list so we later can add our finalizer + # to them, ensuring that if a requested fixture gets torn down we get torn + # down first. This is generally handled by SetupState, but still currently + # needed when this fixture is not parametrized but depends on a parametrized + # fixture. + if not isinstance(fixturedef, PseudoFixtureDef): + requested_fixtures_that_should_finalize_us.append(fixturedef) + + # Check for (and return) cached value/exception. if self.cached_result is not None: - # note: comparison with `==` can fail (or be expensive) for e.g. - # numpy arrays (#6497). + request_cache_key = self.cache_key(request) cache_key = self.cached_result[1] - if my_cache_key is cache_key: + try: + # Attempt to make a normal == check: this might fail for objects + # which do not implement the standard comparison (like numpy arrays -- #6497). + cache_hit = bool(request_cache_key == cache_key) + except (ValueError, RuntimeError): + # If the comparison raises, use 'is' as fallback. + cache_hit = request_cache_key is cache_key + + if cache_hit: if self.cached_result[2] is not None: - _, val, tb = self.cached_result[2] - raise val.with_traceback(tb) + exc, exc_tb = self.cached_result[2] + raise exc.with_traceback(exc_tb) else: result = self.cached_result[0] return result @@ -1065,43 +1114,52 @@ def execute(self, request: SubRequest) -> FixtureValue: self.finish(request) assert self.cached_result is None + # Add finalizer to requested fixtures we saved previously. + # We make sure to do this after checking for cached value to avoid + # adding our finalizer multiple times. (#12135) + finalizer = functools.partial(self.finish, request=request) + for parent_fixture in requested_fixtures_that_should_finalize_us: + parent_fixture.addfinalizer(finalizer) + ihook = request.node.ihook - result = ihook.pytest_fixture_setup(fixturedef=self, request=request) + try: + # Setup the fixture, run the code in it, and cache the value + # in self.cached_result + result = ihook.pytest_fixture_setup(fixturedef=self, request=request) + finally: + # schedule our finalizer, even if the setup failed + request.node.addfinalizer(finalizer) + return result def cache_key(self, request: SubRequest) -> object: - return request.param_index if not hasattr(request, "param") else request.param + return getattr(request, "param", None) def __repr__(self) -> str: - return "".format( - self.argname, self.scope, self.baseid - ) + return f"" def resolve_fixture_function( fixturedef: FixtureDef[FixtureValue], request: FixtureRequest -) -> "_FixtureFunc[FixtureValue]": +) -> _FixtureFunc[FixtureValue]: """Get the actual callable that can be called to obtain the fixture - value, dealing with unittest-specific instances and bound methods.""" + value.""" fixturefunc = fixturedef.func - if fixturedef.unittest: - if request.instance is not None: - # Bind the unbound method to the TestCase instance. - fixturefunc = fixturedef.func.__get__(request.instance) # type: ignore[union-attr] - else: - # The fixture function needs to be bound to the actual - # request.instance so that code working with "fixturedef" behaves - # as expected. - if request.instance is not None: - # Handle the case where fixture is defined not in a test class, but some other class - # (for example a plugin class with a fixture), see #2270. - if hasattr(fixturefunc, "__self__") and not isinstance( - request.instance, fixturefunc.__self__.__class__ # type: ignore[union-attr] - ): - return fixturefunc - fixturefunc = getimfunc(fixturedef.func) - if fixturefunc != fixturedef.func: - fixturefunc = fixturefunc.__get__(request.instance) # type: ignore[union-attr] + # The fixture function needs to be bound to the actual + # request.instance so that code working with "fixturedef" behaves + # as expected. + instance = request.instance + if instance is not None: + # Handle the case where fixture is defined not in a test class, but some other class + # (for example a plugin class with a fixture), see #2270. + if hasattr(fixturefunc, "__self__") and not isinstance( + instance, + fixturefunc.__self__.__class__, + ): + return fixturefunc + fixturefunc = getimfunc(fixturedef.func) + if fixturefunc != fixturedef.func: + fixturefunc = fixturefunc.__get__(instance) return fixturefunc @@ -1111,152 +1169,166 @@ def pytest_fixture_setup( """Execution of fixture setup.""" kwargs = {} for argname in fixturedef.argnames: - fixdef = request._get_active_fixturedef(argname) - assert fixdef.cached_result is not None - result, arg_cache_key, exc = fixdef.cached_result - request._check_scope(argname, request._scope, fixdef._scope) - kwargs[argname] = result + kwargs[argname] = request.getfixturevalue(argname) fixturefunc = resolve_fixture_function(fixturedef, request) my_cache_key = fixturedef.cache_key(request) + + if inspect.isasyncgenfunction(fixturefunc) or inspect.iscoroutinefunction( + fixturefunc + ): + auto_str = " with autouse=True" if fixturedef._autouse else "" + + warnings.warn( + PytestRemovedIn9Warning( + f"{request.node.name!r} requested an async fixture " + f"{request.fixturename!r}{auto_str}, with no plugin or hook that " + "handled it. This is usually an error, as pytest does not natively " + "support it. " + "This will turn into an error in pytest 9.\n" + "See: https://docs.pytest.org/en/stable/deprecations.html#sync-test-depending-on-async-fixture" + ), + # no stacklevel will point at users code, so we just point here + stacklevel=1, + ) + try: result = call_fixture_func(fixturefunc, request, kwargs) - except TEST_OUTCOME: - exc_info = sys.exc_info() - assert exc_info[0] is not None - if isinstance( - exc_info[1], skip.Exception - ) and not fixturefunc.__name__.startswith("xunit_setup"): - exc_info[1]._use_item_location = True # type: ignore[attr-defined] - fixturedef.cached_result = (None, my_cache_key, exc_info) + except TEST_OUTCOME as e: + if isinstance(e, skip.Exception): + # The test requested a fixture which caused a skip. + # Don't show the fixture as the skip location, as then the user + # wouldn't know which test skipped. + e._use_item_location = True + fixturedef.cached_result = (None, my_cache_key, (e, e.__traceback__)) raise fixturedef.cached_result = (result, my_cache_key, None) return result -def _ensure_immutable_ids( - ids: Optional[Union[Sequence[Optional[object]], Callable[[Any], Optional[object]]]] -) -> Optional[Union[Tuple[Optional[object], ...], Callable[[Any], Optional[object]]]]: - if ids is None: - return None - if callable(ids): - return ids - return tuple(ids) - - -def _params_converter( - params: Optional[Iterable[object]], -) -> Optional[Tuple[object, ...]]: - return tuple(params) if params is not None else None - - -def wrap_function_to_error_out_if_called_directly( - function: FixtureFunction, - fixture_marker: "FixtureFunctionMarker", -) -> FixtureFunction: - """Wrap the given fixture function so we can raise an error about it being called directly, - instead of used as an argument in a test function.""" - message = ( - 'Fixture "{name}" called directly. Fixtures are not meant to be called directly,\n' - "but are created automatically when test functions request them as parameters.\n" - "See https://docs.pytest.org/en/stable/explanation/fixtures.html for more information about fixtures, and\n" - "https://docs.pytest.org/en/stable/deprecations.html#calling-fixtures-directly about how to update your code." - ).format(name=fixture_marker.name or function.__name__) - - @functools.wraps(function) - def result(*args, **kwargs): - fail(message, pytrace=False) - - # Keep reference to the original function in our own custom attribute so we don't unwrap - # further than this point and lose useful wrappings like @mock.patch (#3774). - result.__pytest_wrapped__ = _PytestWrapper(function) # type: ignore[attr-defined] - - return cast(FixtureFunction, result) - - @final @dataclasses.dataclass(frozen=True) class FixtureFunctionMarker: - scope: "Union[_ScopeName, Callable[[str, Config], _ScopeName]]" - params: Optional[Tuple[object, ...]] + scope: _ScopeName | Callable[[str, Config], _ScopeName] + params: tuple[object, ...] | None autouse: bool = False - ids: Optional[ - Union[Tuple[Optional[object], ...], Callable[[Any], Optional[object]]] - ] = None - name: Optional[str] = None + ids: tuple[object | None, ...] | Callable[[Any], object | None] | None = None + name: str | None = None _ispytest: dataclasses.InitVar[bool] = False def __post_init__(self, _ispytest: bool) -> None: check_ispytest(_ispytest) - def __call__(self, function: FixtureFunction) -> FixtureFunction: + def __call__(self, function: FixtureFunction) -> FixtureFunctionDefinition: if inspect.isclass(function): raise ValueError("class fixtures not supported (maybe in the future)") - if getattr(function, "_pytestfixturefunction", False): + if isinstance(function, FixtureFunctionDefinition): raise ValueError( - "fixture is being applied more than once to the same function" + f"@pytest.fixture is being applied more than once to the same function {function.__name__!r}" ) - function = wrap_function_to_error_out_if_called_directly(function, self) + if hasattr(function, "pytestmark"): + warnings.warn(MARKED_FIXTURE, stacklevel=2) + + fixture_definition = FixtureFunctionDefinition( + function=function, fixture_function_marker=self, _ispytest=True + ) name = self.name or function.__name__ if name == "request": location = getlocation(function) fail( - "'request' is a reserved word for fixtures, use another name:\n {}".format( - location - ), + f"'request' is a reserved word for fixtures, use another name:\n {location}", pytrace=False, ) - # Type ignored because https://github.com/python/mypy/issues/2087. - function._pytestfixturefunction = self # type: ignore[attr-defined] - return function + return fixture_definition + + +# TODO: paramspec/return type annotation tracking and storing +class FixtureFunctionDefinition: + def __init__( + self, + *, + function: Callable[..., Any], + fixture_function_marker: FixtureFunctionMarker, + instance: object | None = None, + _ispytest: bool = False, + ) -> None: + check_ispytest(_ispytest) + self.name = fixture_function_marker.name or function.__name__ + # In order to show the function that this fixture contains in messages. + # Set the __name__ to be same as the function __name__ or the given fixture name. + self.__name__ = self.name + self._fixture_function_marker = fixture_function_marker + if instance is not None: + self._fixture_function = cast( + Callable[..., Any], function.__get__(instance) + ) + else: + self._fixture_function = function + functools.update_wrapper(self, function) + + def __repr__(self) -> str: + return f"" + + def __get__(self, instance, owner=None): + """Behave like a method if the function it was applied to was a method.""" + return FixtureFunctionDefinition( + function=self._fixture_function, + fixture_function_marker=self._fixture_function_marker, + instance=instance, + _ispytest=True, + ) + + def __call__(self, *args: Any, **kwds: Any) -> Any: + message = ( + f'Fixture "{self.name}" called directly. Fixtures are not meant to be called directly,\n' + "but are created automatically when test functions request them as parameters.\n" + "See https://docs.pytest.org/en/stable/explanation/fixtures.html for more information about fixtures, and\n" + "https://docs.pytest.org/en/stable/deprecations.html#calling-fixtures-directly" + ) + fail(message, pytrace=False) + + def _get_wrapped_function(self) -> Callable[..., Any]: + return self._fixture_function @overload def fixture( - fixture_function: FixtureFunction, + fixture_function: Callable[..., object], *, - scope: "Union[_ScopeName, Callable[[str, Config], _ScopeName]]" = ..., - params: Optional[Iterable[object]] = ..., + scope: _ScopeName | Callable[[str, Config], _ScopeName] = ..., + params: Iterable[object] | None = ..., autouse: bool = ..., - ids: Optional[ - Union[Sequence[Optional[object]], Callable[[Any], Optional[object]]] - ] = ..., - name: Optional[str] = ..., -) -> FixtureFunction: - ... + ids: Sequence[object | None] | Callable[[Any], object | None] | None = ..., + name: str | None = ..., +) -> FixtureFunctionDefinition: ... @overload -def fixture( # noqa: F811 +def fixture( fixture_function: None = ..., *, - scope: "Union[_ScopeName, Callable[[str, Config], _ScopeName]]" = ..., - params: Optional[Iterable[object]] = ..., + scope: _ScopeName | Callable[[str, Config], _ScopeName] = ..., + params: Iterable[object] | None = ..., autouse: bool = ..., - ids: Optional[ - Union[Sequence[Optional[object]], Callable[[Any], Optional[object]]] - ] = ..., - name: Optional[str] = None, -) -> FixtureFunctionMarker: - ... + ids: Sequence[object | None] | Callable[[Any], object | None] | None = ..., + name: str | None = None, +) -> FixtureFunctionMarker: ... -def fixture( # noqa: F811 - fixture_function: Optional[FixtureFunction] = None, +def fixture( + fixture_function: FixtureFunction | None = None, *, - scope: "Union[_ScopeName, Callable[[str, Config], _ScopeName]]" = "function", - params: Optional[Iterable[object]] = None, + scope: _ScopeName | Callable[[str, Config], _ScopeName] = "function", + params: Iterable[object] | None = None, autouse: bool = False, - ids: Optional[ - Union[Sequence[Optional[object]], Callable[[Any], Optional[object]]] - ] = None, - name: Optional[str] = None, -) -> Union[FixtureFunctionMarker, FixtureFunction]: + ids: Sequence[object | None] | Callable[[Any], object | None] | None = None, + name: str | None = None, +) -> FixtureFunctionMarker | FixtureFunctionDefinition: """Decorator to mark a fixture factory function. This decorator can be used, with or without parameters, to define a @@ -1357,7 +1429,7 @@ def pytestconfig(request: FixtureRequest) -> Config: Example:: def test_foo(pytestconfig): - if pytestconfig.getoption("verbose") > 0: + if pytestconfig.get_verbosity() > 0: ... """ @@ -1371,6 +1443,58 @@ def pytest_addoption(parser: Parser) -> None: default=[], help="List of default fixtures to be used with this project", ) + group = parser.getgroup("general") + group.addoption( + "--fixtures", + "--funcargs", + action="store_true", + dest="showfixtures", + default=False, + help="Show available fixtures, sorted by plugin appearance " + "(fixtures with leading '_' are only shown with '-v')", + ) + group.addoption( + "--fixtures-per-test", + action="store_true", + dest="show_fixtures_per_test", + default=False, + help="Show fixtures per test", + ) + + +def pytest_cmdline_main(config: Config) -> int | ExitCode | None: + if config.option.showfixtures: + showfixtures(config) + return 0 + if config.option.show_fixtures_per_test: + show_fixtures_per_test(config) + return 0 + return None + + +def _get_direct_parametrize_args(node: nodes.Node) -> set[str]: + """Return all direct parametrization arguments of a node, so we don't + mistake them for fixtures. + + Check https://github.com/pytest-dev/pytest/issues/5036. + + These things are done later as well when dealing with parametrization + so this could be improved. + """ + parametrize_argnames: set[str] = set() + for marker in node.iter_markers(name="parametrize"): + if not marker.kwargs.get("indirect", False): + p_argnames, _ = ParameterSet._parse_parametrize_args( + *marker.args, **marker.kwargs + ) + parametrize_argnames.update(p_argnames) + return parametrize_argnames + + +def deduplicate_names(*seqs: Iterable[str]) -> tuple[str, ...]: + """De-duplicate the sequence of names while keeping the original order.""" + # Ideally we would use a set, but it does not preserve insertion order. + return tuple(dict.fromkeys(name for seq in seqs for name in seq)) class FixtureManager: @@ -1404,92 +1528,105 @@ class FixtureManager: by a lookup of their FuncFixtureInfo. """ - FixtureLookupError = FixtureLookupError - FixtureLookupErrorRepr = FixtureLookupErrorRepr - - def __init__(self, session: "Session") -> None: + def __init__(self, session: Session) -> None: self.session = session self.config: Config = session.config - self._arg2fixturedefs: Dict[str, List[FixtureDef[Any]]] = {} - self._holderobjseen: Set[object] = set() + # Maps a fixture name (argname) to all of the FixtureDefs in the test + # suite/plugins defined with this name. Populated by parsefactories(). + # TODO: The order of the FixtureDefs list of each arg is significant, + # explain. + self._arg2fixturedefs: Final[dict[str, list[FixtureDef[Any]]]] = {} + self._holderobjseen: Final[set[object]] = set() # A mapping from a nodeid to a list of autouse fixtures it defines. - self._nodeid_autousenames: Dict[str, List[str]] = { + self._nodeid_autousenames: Final[dict[str, list[str]]] = { "": self.config.getini("usefixtures"), } session.config.pluginmanager.register(self, "funcmanage") - def _get_direct_parametrize_args(self, node: nodes.Node) -> List[str]: - """Return all direct parametrization arguments of a node, so we don't - mistake them for fixtures. + def getfixtureinfo( + self, + node: nodes.Item, + func: Callable[..., object] | None, + cls: type | None, + ) -> FuncFixtureInfo: + """Calculate the :class:`FuncFixtureInfo` for an item. - Check https://github.com/pytest-dev/pytest/issues/5036. + If ``func`` is None, or if the item sets an attribute + ``nofuncargs = True``, then ``func`` is not examined at all. - These things are done later as well when dealing with parametrization - so this could be improved. + :param node: + The item requesting the fixtures. + :param func: + The item's function. + :param cls: + If the function is a method, the method's class. """ - parametrize_argnames: List[str] = [] - for marker in node.iter_markers(name="parametrize"): - if not marker.kwargs.get("indirect", False): - p_argnames, _ = ParameterSet._parse_parametrize_args( - *marker.args, **marker.kwargs - ) - parametrize_argnames.extend(p_argnames) - - return parametrize_argnames - - def getfixtureinfo( - self, node: nodes.Node, func, cls, funcargs: bool = True - ) -> FuncFixtureInfo: - if funcargs and not getattr(node, "nofuncargs", False): + if func is not None and not getattr(node, "nofuncargs", False): argnames = getfuncargnames(func, name=node.name, cls=cls) else: argnames = () + usefixturesnames = self._getusefixturesnames(node) + autousenames = self._getautousenames(node) + initialnames = deduplicate_names(autousenames, usefixturesnames, argnames) - usefixtures = tuple( - arg for mark in node.iter_markers(name="usefixtures") for arg in mark.args - ) - initialnames = usefixtures + argnames - fm = node.session._fixturemanager - initialnames, names_closure, arg2fixturedefs = fm.getfixtureclosure( - initialnames, node, ignore_args=self._get_direct_parametrize_args(node) + direct_parametrize_args = _get_direct_parametrize_args(node) + + names_closure, arg2fixturedefs = self.getfixtureclosure( + parentnode=node, + initialnames=initialnames, + ignore_args=direct_parametrize_args, ) + return FuncFixtureInfo(argnames, initialnames, names_closure, arg2fixturedefs) - def pytest_plugin_registered(self, plugin: _PluggyPlugin) -> None: - nodeid = None - try: - p = absolutepath(plugin.__file__) # type: ignore[attr-defined] - except AttributeError: - pass + def pytest_plugin_registered(self, plugin: _PluggyPlugin, plugin_name: str) -> None: + # Fixtures defined in conftest plugins are only visible to within the + # conftest's directory. This is unlike fixtures in non-conftest plugins + # which have global visibility. So for conftests, construct the base + # nodeid from the plugin name (which is the conftest path). + if plugin_name and plugin_name.endswith("conftest.py"): + # Note: we explicitly do *not* use `plugin.__file__` here -- The + # difference is that plugin_name has the correct capitalization on + # case-insensitive systems (Windows) and other normalization issues + # (issue #11816). + conftestpath = absolutepath(plugin_name) + try: + nodeid = str(conftestpath.parent.relative_to(self.config.rootpath)) + except ValueError: + nodeid = "" + if nodeid == ".": + nodeid = "" + if os.sep != nodes.SEP: + nodeid = nodeid.replace(os.sep, nodes.SEP) else: - # Construct the base nodeid which is later used to check - # what fixtures are visible for particular tests (as denoted - # by their test id). - if p.name.startswith("conftest.py"): - try: - nodeid = str(p.parent.relative_to(self.config.rootpath)) - except ValueError: - nodeid = "" - if nodeid == ".": - nodeid = "" - if os.sep != nodes.SEP: - nodeid = nodeid.replace(os.sep, nodes.SEP) + nodeid = None self.parsefactories(plugin, nodeid) - def _getautousenames(self, nodeid: str) -> Iterator[str]: - """Return the names of autouse fixtures applicable to nodeid.""" - for parentnodeid in nodes.iterparentnodeids(nodeid): - basenames = self._nodeid_autousenames.get(parentnodeid) + def _getautousenames(self, node: nodes.Node) -> Iterator[str]: + """Return the names of autouse fixtures applicable to node.""" + for parentnode in node.listchain(): + basenames = self._nodeid_autousenames.get(parentnode.nodeid) if basenames: yield from basenames + def _getusefixturesnames(self, node: nodes.Item) -> Iterator[str]: + """Return the names of usefixtures fixtures applicable to node.""" + for marker_node, mark in node.iter_markers_with_node(name="usefixtures"): + if not mark.args: + marker_node.warn( + PytestWarning( + f"usefixtures() in {node.nodeid} without arguments has no effect" + ) + ) + yield from mark.args + def getfixtureclosure( self, - fixturenames: Tuple[str, ...], parentnode: nodes.Node, - ignore_args: Sequence[str] = (), - ) -> Tuple[Tuple[str, ...], List[str], Dict[str, Sequence[FixtureDef[Any]]]]: + initialnames: tuple[str, ...], + ignore_args: AbstractSet[str], + ) -> tuple[list[str], dict[str, Sequence[FixtureDef[Any]]]]: # Collect the closure of all fixtures, starting with the given # fixturenames as the initial set. As we have to visit all # factory definitions anyway, we also return an arg2fixturedefs @@ -1497,22 +1634,9 @@ def getfixtureclosure( # to re-discover fixturedefs again for each fixturename # (discovering matching fixtures for a given name/node is expensive). - parentid = parentnode.nodeid - fixturenames_closure = list(self._getautousenames(parentid)) - - def merge(otherlist: Iterable[str]) -> None: - for arg in otherlist: - if arg not in fixturenames_closure: - fixturenames_closure.append(arg) - - merge(fixturenames) + fixturenames_closure = list(initialnames) - # At this point, fixturenames_closure contains what we call "initialnames", - # which is a set of fixturenames the function immediately requests. We - # need to return it as well, so save this. - initialnames = tuple(fixturenames_closure) - - arg2fixturedefs: Dict[str, Sequence[FixtureDef[Any]]] = {} + arg2fixturedefs: dict[str, Sequence[FixtureDef[Any]]] = {} lastlen = -1 while lastlen != len(fixturenames_closure): lastlen = len(fixturenames_closure) @@ -1521,10 +1645,12 @@ def merge(otherlist: Iterable[str]) -> None: continue if argname in arg2fixturedefs: continue - fixturedefs = self.getfixturedefs(argname, parentid) + fixturedefs = self.getfixturedefs(argname, parentnode) if fixturedefs: arg2fixturedefs[argname] = fixturedefs - merge(fixturedefs[-1].argnames) + for arg in fixturedefs[-1].argnames: + if arg not in fixturenames_closure: + fixturenames_closure.append(arg) def sort_by_scope(arg_name: str) -> Scope: try: @@ -1535,9 +1661,9 @@ def sort_by_scope(arg_name: str) -> Scope: return fixturedefs[-1]._scope fixturenames_closure.sort(key=sort_by_scope, reverse=True) - return initialnames, fixturenames_closure, arg2fixturedefs + return fixturenames_closure, arg2fixturedefs - def pytest_generate_tests(self, metafunc: "Metafunc") -> None: + def pytest_generate_tests(self, metafunc: Metafunc) -> None: """Generate new tests based on parametrized fixtures used by the given metafunc""" def get_parametrize_mark_argnames(mark: Mark) -> Sequence[str]: @@ -1582,35 +1708,85 @@ def get_parametrize_mark_argnames(mark: Mark) -> Sequence[str]: # Try next super fixture, if any. - def pytest_collection_modifyitems(self, items: List[nodes.Item]) -> None: + def pytest_collection_modifyitems(self, items: list[nodes.Item]) -> None: # Separate parametrized setups. items[:] = reorder_items(items) + def _register_fixture( + self, + *, + name: str, + func: _FixtureFunc[object], + nodeid: str | None, + scope: Scope | _ScopeName | Callable[[str, Config], _ScopeName] = "function", + params: Sequence[object] | None = None, + ids: tuple[object | None, ...] | Callable[[Any], object | None] | None = None, + autouse: bool = False, + ) -> None: + """Register a fixture + + :param name: + The fixture's name. + :param func: + The fixture's implementation function. + :param nodeid: + The visibility of the fixture. The fixture will be available to the + node with this nodeid and its children in the collection tree. + None means that the fixture is visible to the entire collection tree, + e.g. a fixture defined for general use in a plugin. + :param scope: + The fixture's scope. + :param params: + The fixture's parametrization params. + :param ids: + The fixture's IDs. + :param autouse: + Whether this is an autouse fixture. + """ + fixture_def = FixtureDef( + config=self.config, + baseid=nodeid, + argname=name, + func=func, + scope=scope, + params=params, + ids=ids, + _ispytest=True, + _autouse=autouse, + ) + + faclist = self._arg2fixturedefs.setdefault(name, []) + if fixture_def.has_location: + faclist.append(fixture_def) + else: + # fixturedefs with no location are at the front + # so this inserts the current fixturedef after the + # existing fixturedefs from external plugins but + # before the fixturedefs provided in conftests. + i = len([f for f in faclist if not f.has_location]) + faclist.insert(i, fixture_def) + if autouse: + self._nodeid_autousenames.setdefault(nodeid or "", []).append(name) + @overload def parsefactories( self, node_or_obj: nodes.Node, - *, - unittest: bool = ..., ) -> None: raise NotImplementedError() @overload - def parsefactories( # noqa: F811 + def parsefactories( self, node_or_obj: object, - nodeid: Optional[str], - *, - unittest: bool = ..., + nodeid: str | None, ) -> None: raise NotImplementedError() - def parsefactories( # noqa: F811 + def parsefactories( self, - node_or_obj: Union[nodes.Node, object], - nodeid: Union[str, NotSetType, None] = NOTSET, - *, - unittest: bool = False, + node_or_obj: nodes.Node | object, + nodeid: str | NotSetType | None = NOTSET, ) -> None: """Collect fixtures from a collection node or object. @@ -1618,7 +1794,7 @@ def parsefactories( # noqa: F811 If `node_or_object` is a collection node (with an underlying Python object), the node's object is traversed and the node's nodeid is used to - determine the fixtures' visibilty. `nodeid` must not be specified in + determine the fixtures' visibility. `nodeid` must not be specified in this case. If `node_or_object` is an object (e.g. a plugin), the object is @@ -1636,78 +1812,206 @@ def parsefactories( # noqa: F811 if holderobj in self._holderobjseen: return + # Avoid accessing `@property` (and other descriptors) when iterating fixtures. + if not safe_isclass(holderobj) and not isinstance(holderobj, types.ModuleType): + holderobj_tp: object = type(holderobj) + else: + holderobj_tp = holderobj + self._holderobjseen.add(holderobj) - autousenames = [] for name in dir(holderobj): - # ugly workaround for one of the fspath deprecated property of node - # todo: safely generalize - if isinstance(holderobj, nodes.Node) and name == "fspath": - continue - # The attribute can be an arbitrary descriptor, so the attribute - # access below can raise. safe_getatt() ignores such exceptions. - obj = safe_getattr(holderobj, name, None) - marker = getfixturemarker(obj) - if not isinstance(marker, FixtureFunctionMarker): - # Magic globals with __getattr__ might have got us a wrong - # fixture attribute. - continue - - if marker.name: - name = marker.name - - # During fixture definition we wrap the original fixture function - # to issue a warning if called directly, so here we unwrap it in - # order to not emit the warning when pytest itself calls the - # fixture function. - obj = get_real_method(obj, holderobj) - - fixture_def = FixtureDef( - fixturemanager=self, - baseid=nodeid, - argname=name, - func=obj, - scope=marker.scope, - params=marker.params, - unittest=unittest, - ids=marker.ids, - ) + # access below can raise. safe_getattr() ignores such exceptions. + obj_ub = safe_getattr(holderobj_tp, name, None) + if type(obj_ub) is FixtureFunctionDefinition: + marker = obj_ub._fixture_function_marker + if marker.name: + fixture_name = marker.name + else: + fixture_name = name - faclist = self._arg2fixturedefs.setdefault(name, []) - if fixture_def.has_location: - faclist.append(fixture_def) - else: - # fixturedefs with no location are at the front - # so this inserts the current fixturedef after the - # existing fixturedefs from external plugins but - # before the fixturedefs provided in conftests. - i = len([f for f in faclist if not f.has_location]) - faclist.insert(i, fixture_def) - if marker.autouse: - autousenames.append(name) - - if autousenames: - self._nodeid_autousenames.setdefault(nodeid or "", []).extend(autousenames) + # OK we know it is a fixture -- now safe to look up on the _instance_. + try: + obj = getattr(holderobj, name) + # if the fixture is named in the decorator we cannot find it in the module + except AttributeError: + obj = obj_ub + + func = obj._get_wrapped_function() + + self._register_fixture( + name=fixture_name, + nodeid=nodeid, + func=func, + scope=marker.scope, + params=marker.params, + ids=marker.ids, + autouse=marker.autouse, + ) def getfixturedefs( - self, argname: str, nodeid: str - ) -> Optional[Sequence[FixtureDef[Any]]]: - """Get a list of fixtures which are applicable to the given node id. - - :param str argname: Name of the fixture to search for. - :param str nodeid: Full node id of the requesting test. - :rtype: Sequence[FixtureDef] + self, argname: str, node: nodes.Node + ) -> Sequence[FixtureDef[Any]] | None: + """Get FixtureDefs for a fixture name which are applicable + to a given node. + + Returns None if there are no fixtures at all defined with the given + name. (This is different from the case in which there are fixtures + with the given name, but none applicable to the node. In this case, + an empty result is returned). + + :param argname: Name of the fixture to search for. + :param node: The requesting Node. """ try: fixturedefs = self._arg2fixturedefs[argname] except KeyError: return None - return tuple(self._matchfactories(fixturedefs, nodeid)) + return tuple(self._matchfactories(fixturedefs, node)) def _matchfactories( - self, fixturedefs: Iterable[FixtureDef[Any]], nodeid: str + self, fixturedefs: Iterable[FixtureDef[Any]], node: nodes.Node ) -> Iterator[FixtureDef[Any]]: - parentnodeids = set(nodes.iterparentnodeids(nodeid)) + parentnodeids = {n.nodeid for n in node.iter_parents()} for fixturedef in fixturedefs: if fixturedef.baseid in parentnodeids: yield fixturedef + + +def show_fixtures_per_test(config: Config) -> int | ExitCode: + from _pytest.main import wrap_session + + return wrap_session(config, _show_fixtures_per_test) + + +_PYTEST_DIR = Path(_pytest.__file__).parent + + +def _pretty_fixture_path(invocation_dir: Path, func) -> str: + loc = Path(getlocation(func, invocation_dir)) + prefix = Path("...", "_pytest") + try: + return str(prefix / loc.relative_to(_PYTEST_DIR)) + except ValueError: + return bestrelpath(invocation_dir, loc) + + +def _show_fixtures_per_test(config: Config, session: Session) -> None: + import _pytest.config + + session.perform_collect() + invocation_dir = config.invocation_params.dir + tw = _pytest.config.create_terminal_writer(config) + verbose = config.get_verbosity() + + def get_best_relpath(func) -> str: + loc = getlocation(func, invocation_dir) + return bestrelpath(invocation_dir, Path(loc)) + + def write_fixture(fixture_def: FixtureDef[object]) -> None: + argname = fixture_def.argname + if verbose <= 0 and argname.startswith("_"): + return + prettypath = _pretty_fixture_path(invocation_dir, fixture_def.func) + tw.write(f"{argname}", green=True) + tw.write(f" -- {prettypath}", yellow=True) + tw.write("\n") + fixture_doc = inspect.getdoc(fixture_def.func) + if fixture_doc: + write_docstring( + tw, + fixture_doc.split("\n\n", maxsplit=1)[0] + if verbose <= 0 + else fixture_doc, + ) + else: + tw.line(" no docstring available", red=True) + + def write_item(item: nodes.Item) -> None: + # Not all items have _fixtureinfo attribute. + info: FuncFixtureInfo | None = getattr(item, "_fixtureinfo", None) + if info is None or not info.name2fixturedefs: + # This test item does not use any fixtures. + return + tw.line() + tw.sep("-", f"fixtures used by {item.name}") + # TODO: Fix this type ignore. + tw.sep("-", f"({get_best_relpath(item.function)})") # type: ignore[attr-defined] + # dict key not used in loop but needed for sorting. + for _, fixturedefs in sorted(info.name2fixturedefs.items()): + assert fixturedefs is not None + if not fixturedefs: + continue + # Last item is expected to be the one used by the test item. + write_fixture(fixturedefs[-1]) + + for session_item in session.items: + write_item(session_item) + + +def showfixtures(config: Config) -> int | ExitCode: + from _pytest.main import wrap_session + + return wrap_session(config, _showfixtures_main) + + +def _showfixtures_main(config: Config, session: Session) -> None: + import _pytest.config + + session.perform_collect() + invocation_dir = config.invocation_params.dir + tw = _pytest.config.create_terminal_writer(config) + verbose = config.get_verbosity() + + fm = session._fixturemanager + + available = [] + seen: set[tuple[str, str]] = set() + + for argname, fixturedefs in fm._arg2fixturedefs.items(): + assert fixturedefs is not None + if not fixturedefs: + continue + for fixturedef in fixturedefs: + loc = getlocation(fixturedef.func, invocation_dir) + if (fixturedef.argname, loc) in seen: + continue + seen.add((fixturedef.argname, loc)) + available.append( + ( + len(fixturedef.baseid), + fixturedef.func.__module__, + _pretty_fixture_path(invocation_dir, fixturedef.func), + fixturedef.argname, + fixturedef, + ) + ) + + available.sort() + currentmodule = None + for baseid, module, prettypath, argname, fixturedef in available: + if currentmodule != module: + if not module.startswith("_pytest."): + tw.line() + tw.sep("-", f"fixtures defined from {module}") + currentmodule = module + if verbose <= 0 and argname.startswith("_"): + continue + tw.write(f"{argname}", green=True) + if fixturedef.scope != "function": + tw.write(f" [{fixturedef.scope} scope]", cyan=True) + tw.write(f" -- {prettypath}", yellow=True) + tw.write("\n") + doc = inspect.getdoc(fixturedef.func) + if doc: + write_docstring( + tw, doc.split("\n\n", maxsplit=1)[0] if verbose <= 0 else doc + ) + else: + tw.line(" no docstring available", red=True) + tw.line() + + +def write_docstring(tw: TerminalWriter, doc: str, indent: str = " ") -> None: + for line in doc.split("\n"): + tw.line(indent + line) diff --git a/src/_pytest/freeze_support.py b/src/_pytest/freeze_support.py index 9f8ea231fed..959ff071d86 100644 --- a/src/_pytest/freeze_support.py +++ b/src/_pytest/freeze_support.py @@ -1,12 +1,13 @@ """Provides a function to report all internal modules for using freezing tools.""" + +from __future__ import annotations + +from collections.abc import Iterator import types -from typing import Iterator -from typing import List -from typing import Union -def freeze_includes() -> List[str]: +def freeze_includes() -> list[str]: """Return a list of module names used by pytest that should be included by cx_freeze.""" import _pytest @@ -16,7 +17,7 @@ def freeze_includes() -> List[str]: def _iter_all_modules( - package: Union[str, types.ModuleType], + package: str | types.ModuleType, prefix: str = "", ) -> Iterator[str]: """Iterate over the names of all modules that can be found in the given @@ -34,7 +35,7 @@ def _iter_all_modules( else: # Type ignored because typeshed doesn't define ModuleType.__path__ # (only defined on packages). - package_path = package.__path__ # type: ignore[attr-defined] + package_path = package.__path__ path, prefix = package_path[0], package.__name__ + "." for _, name, is_package in pkgutil.iter_modules([path]): if is_package: diff --git a/src/_pytest/helpconfig.py b/src/_pytest/helpconfig.py index 430870608bd..b5ac0e6a50c 100644 --- a/src/_pytest/helpconfig.py +++ b/src/_pytest/helpconfig.py @@ -1,16 +1,19 @@ +# mypy: allow-untyped-defs """Version info, help messages, tracing configuration.""" + +from __future__ import annotations + +from argparse import Action +from collections.abc import Generator import os import sys -from argparse import Action -from typing import List -from typing import Optional -from typing import Union -import pytest from _pytest.config import Config from _pytest.config import ExitCode from _pytest.config import PrintHelp from _pytest.config.argparsing import Parser +from _pytest.terminal import TerminalReporter +import pytest class HelpAction(Action): @@ -52,14 +55,14 @@ def pytest_addoption(parser: Parser) -> None: help="Display pytest version and information about plugins. " "When given twice, also display information about plugins.", ) - group._addoption( + group._addoption( # private to use reserved lower-case short option "-h", "--help", action=HelpAction, dest="help", help="Show help message and configuration info", ) - group._addoption( + group._addoption( # private to use reserved lower-case short option "-p", action="append", dest="plugins", @@ -67,7 +70,14 @@ def pytest_addoption(parser: Parser) -> None: metavar="name", help="Early-load given plugin module name or entry point (multi-allowed). " "To avoid loading of plugins, use the `no:` prefix, e.g. " - "`no:doctest`.", + "`no:doctest`. See also --disable-plugin-autoload.", + ) + group.addoption( + "--disable-plugin-autoload", + action="store_true", + default=False, + help="Disable plugin auto-loading through entry point packaging metadata. " + "Only plugins explicitly specified in -p or env var PYTEST_PLUGINS will be loaded.", ) group.addoption( "--traceconfig", @@ -87,7 +97,7 @@ def pytest_addoption(parser: Parser) -> None: "This file is opened with 'w' and truncated as a result, care advised. " "Default: pytestdebug.log.", ) - group._addoption( + group._addoption( # private to use reserved lower-case short option "-o", "--override-ini", dest="override_ini", @@ -97,44 +107,43 @@ def pytest_addoption(parser: Parser) -> None: ) -@pytest.hookimpl(hookwrapper=True) -def pytest_cmdline_parse(): - outcome = yield - config: Config = outcome.get_result() +@pytest.hookimpl(wrapper=True) +def pytest_cmdline_parse() -> Generator[None, Config, Config]: + config = yield if config.option.debug: # --debug | --debug was provided. path = config.option.debug debugfile = open(path, "w", encoding="utf-8") debugfile.write( - "versions pytest-%s, " - "python-%s\ncwd=%s\nargs=%s\n\n" - % ( + "versions pytest-{}, " + "python-{}\ninvocation_dir={}\ncwd={}\nargs={}\n\n".format( pytest.__version__, ".".join(map(str, sys.version_info)), + config.invocation_params.dir, os.getcwd(), config.invocation_params.args, ) ) config.trace.root.setwriter(debugfile.write) undo_tracing = config.pluginmanager.enable_tracing() - sys.stderr.write("writing pytest debug information to %s\n" % path) + sys.stderr.write(f"writing pytest debug information to {path}\n") def unset_tracing() -> None: debugfile.close() - sys.stderr.write("wrote pytest debug information to %s\n" % debugfile.name) + sys.stderr.write(f"wrote pytest debug information to {debugfile.name}\n") config.trace.root.setwriter(None) undo_tracing() config.add_cleanup(unset_tracing) + return config + def showversion(config: Config) -> None: if config.option.version > 1: sys.stdout.write( - "This is pytest version {}, imported from {}\n".format( - pytest.__version__, pytest.__file__ - ) + f"This is pytest version {pytest.__version__}, imported from {pytest.__file__}\n" ) plugininfo = getpluginversioninfo(config) if plugininfo: @@ -144,7 +153,7 @@ def showversion(config: Config) -> None: sys.stdout.write(f"pytest {pytest.__version__}\n") -def pytest_cmdline_main(config: Config) -> Optional[Union[int, ExitCode]]: +def pytest_cmdline_main(config: Config) -> int | ExitCode | None: if config.option.version > 0: showversion(config) return 0 @@ -159,7 +168,10 @@ def pytest_cmdline_main(config: Config) -> Optional[Union[int, ExitCode]]: def showhelp(config: Config) -> None: import textwrap - reporter = config.pluginmanager.get_plugin("terminalreporter") + reporter: TerminalReporter | None = config.pluginmanager.get_plugin( + "terminalreporter" + ) + assert reporter is not None tw = reporter._tw tw.write(config._parser.optparser.format_help()) tw.line() @@ -179,7 +191,7 @@ def showhelp(config: Config) -> None: if help is None: raise TypeError(f"help argument cannot be None for {name}") spec = f"{name} ({type}):" - tw.write(" %s" % spec) + tw.write(f" {spec}") spec_len = len(spec) if spec_len > (indent_len - 3): # Display help starting at a new line. @@ -207,6 +219,12 @@ def showhelp(config: Config) -> None: tw.line() tw.line("Environment variables:") vars = [ + ( + "CI", + "When set (regardless of value), pytest knows it is running in a " + "CI process and does not truncate summary info", + ), + ("BUILD_NUMBER", "Equivalent to CI"), ("PYTEST_ADDOPTS", "Extra command line options"), ("PYTEST_PLUGINS", "Comma-separated plugins to load during startup"), ("PYTEST_DISABLE_PLUGIN_AUTOLOAD", "Set to disable plugin auto-loading"), @@ -227,17 +245,16 @@ def showhelp(config: Config) -> None: for warningreport in reporter.stats.get("warnings", []): tw.line("warning : " + warningreport.message, red=True) - return conftest_options = [("pytest_plugins", "list of plugin names to load")] -def getpluginversioninfo(config: Config) -> List[str]: +def getpluginversioninfo(config: Config) -> list[str]: lines = [] plugininfo = config.pluginmanager.list_plugin_distinfo() if plugininfo: - lines.append("setuptools registered plugins:") + lines.append("registered third-party plugins:") for plugin, dist in plugininfo: loc = getattr(plugin, "__file__", repr(plugin)) content = f"{dist.project_name}-{dist.version} at {loc}" @@ -245,7 +262,7 @@ def getpluginversioninfo(config: Config) -> List[str]: return lines -def pytest_report_header(config: Config) -> List[str]: +def pytest_report_header(config: Config) -> list[str]: lines = [] if config.option.debug or config.option.traceconfig: lines.append(f"using: pytest-{pytest.__version__}") diff --git a/src/_pytest/hookspec.py b/src/_pytest/hookspec.py index 1f7c368f792..12653ea11fe 100644 --- a/src/_pytest/hookspec.py +++ b/src/_pytest/hookspec.py @@ -1,31 +1,33 @@ +# mypy: allow-untyped-defs +# ruff: noqa: T100 """Hook specifications for pytest plugins which are invoked by pytest itself and by builtin plugins.""" + +from __future__ import annotations + +from collections.abc import Mapping +from collections.abc import Sequence from pathlib import Path from typing import Any -from typing import Dict -from typing import List -from typing import Mapping -from typing import Optional -from typing import Sequence -from typing import Tuple from typing import TYPE_CHECKING -from typing import Union from pluggy import HookspecMarker -from _pytest.deprecated import WARNING_CMDLINE_PREPARSE_HOOK +from .deprecated import HOOK_LEGACY_PATH_ARG + if TYPE_CHECKING: import pdb + from typing import Literal import warnings - from typing_extensions import Literal - from _pytest._code.code import ExceptionRepr from _pytest._code.code import ExceptionInfo + from _pytest._code.code import ExceptionRepr + from _pytest.compat import LEGACY_PATH + from _pytest.config import _PluggyPlugin from _pytest.config import Config from _pytest.config import ExitCode from _pytest.config import PytestPluginManager - from _pytest.config import _PluggyPlugin from _pytest.config.argparsing import Parser from _pytest.fixtures import FixtureDef from _pytest.fixtures import SubRequest @@ -42,7 +44,6 @@ from _pytest.runner import CallInfo from _pytest.terminal import TerminalReporter from _pytest.terminal import TestShortLogReport - from _pytest.compat import LEGACY_PATH hookspec = HookspecMarker("pytest") @@ -53,51 +54,62 @@ @hookspec(historic=True) -def pytest_addhooks(pluginmanager: "PytestPluginManager") -> None: +def pytest_addhooks(pluginmanager: PytestPluginManager) -> None: """Called at plugin registration time to allow adding new hooks via a call to - ``pluginmanager.add_hookspecs(module_or_class, prefix)``. + :func:`pluginmanager.add_hookspecs(module_or_class, prefix) `. - :param pytest.PytestPluginManager pluginmanager: The pytest plugin manager. + :param pluginmanager: The pytest plugin manager. .. note:: - This hook is incompatible with ``hookwrapper=True``. + This hook is incompatible with hook wrappers. + + Use in conftest plugins + ======================= + + If a conftest plugin implements this hook, it will be called immediately + when the conftest is registered. """ @hookspec(historic=True) def pytest_plugin_registered( - plugin: "_PluggyPlugin", manager: "PytestPluginManager" + plugin: _PluggyPlugin, + plugin_name: str, + manager: PytestPluginManager, ) -> None: """A new pytest plugin got registered. :param plugin: The plugin module or instance. - :param pytest.PytestPluginManager manager: pytest plugin manager. + :param plugin_name: The name by which the plugin is registered. + :param manager: The pytest plugin manager. .. note:: - This hook is incompatible with ``hookwrapper=True``. + This hook is incompatible with hook wrappers. + + Use in conftest plugins + ======================= + + If a conftest plugin implements this hook, it will be called immediately + when the conftest is registered, once for each plugin registered thus far + (including itself!), and for all plugins thereafter when they are + registered. """ @hookspec(historic=True) -def pytest_addoption(parser: "Parser", pluginmanager: "PytestPluginManager") -> None: +def pytest_addoption(parser: Parser, pluginmanager: PytestPluginManager) -> None: """Register argparse-style options and ini-style config values, called once at the beginning of a test run. - .. note:: - - This function should be implemented only in plugins or ``conftest.py`` - files situated at the tests root directory due to how pytest - :ref:`discovers plugins during startup `. - - :param pytest.Parser parser: + :param parser: To add command line options, call :py:func:`parser.addoption(...) `. To add ini-file values call :py:func:`parser.addini(...) `. - :param pytest.PytestPluginManager pluginmanager: - The pytest plugin manager, which can be used to install :py:func:`hookspec`'s - or :py:func:`hookimpl`'s and allow one plugin to call another plugin's hooks + :param pluginmanager: + The pytest plugin manager, which can be used to install :py:func:`~pytest.hookspec`'s + or :py:func:`~pytest.hookimpl`'s and allow one plugin to call another plugin's hooks to change how command line options are added. Options can later be accessed through the @@ -113,24 +125,33 @@ def pytest_addoption(parser: "Parser", pluginmanager: "PytestPluginManager") -> attribute or can be retrieved as the ``pytestconfig`` fixture. .. note:: - This hook is incompatible with ``hookwrapper=True``. + This hook is incompatible with hook wrappers. + + Use in conftest plugins + ======================= + + If a conftest plugin implements this hook, it will be called immediately + when the conftest is registered. + + This hook is only called for :ref:`initial conftests `. """ @hookspec(historic=True) -def pytest_configure(config: "Config") -> None: +def pytest_configure(config: Config) -> None: """Allow plugins and conftest files to perform initial configuration. - This hook is called for every plugin and initial conftest file - after command line options have been parsed. + .. note:: + This hook is incompatible with hook wrappers. - After that, the hook is called for other conftest files as they are - imported. + :param config: The pytest config object. - .. note:: - This hook is incompatible with ``hookwrapper=True``. + Use in conftest plugins + ======================= - :param pytest.Config config: The pytest config object. + This hook is called for every :ref:`initial conftest ` file + after command line options have been parsed. After that, the hook is called + for other conftest files as they are registered. """ @@ -142,62 +163,61 @@ def pytest_configure(config: "Config") -> None: @hookspec(firstresult=True) def pytest_cmdline_parse( - pluginmanager: "PytestPluginManager", args: List[str] -) -> Optional["Config"]: + pluginmanager: PytestPluginManager, args: list[str] +) -> Config | None: """Return an initialized :class:`~pytest.Config`, parsing the specified args. Stops at first non-None result, see :ref:`firstresult`. .. note:: - This hook will only be called for plugin classes passed to the + This hook is only called for plugin classes passed to the ``plugins`` arg when using `pytest.main`_ to perform an in-process test run. :param pluginmanager: The pytest plugin manager. :param args: List of arguments passed on the command line. :returns: A pytest config object. - """ + Use in conftest plugins + ======================= -@hookspec(warn_on_impl=WARNING_CMDLINE_PREPARSE_HOOK) -def pytest_cmdline_preparse(config: "Config", args: List[str]) -> None: - """(**Deprecated**) modify command line arguments before option parsing. + This hook is not called for conftest files. + """ - This hook is considered deprecated and will be removed in a future pytest version. Consider - using :hook:`pytest_load_initial_conftests` instead. - .. note:: - This hook will not be called for ``conftest.py`` files, only for setuptools plugins. +def pytest_load_initial_conftests( + early_config: Config, parser: Parser, args: list[str] +) -> None: + """Called to implement the loading of :ref:`initial conftest files + ` ahead of command line option parsing. - :param config: The pytest config object. + :param early_config: The pytest config object. :param args: Arguments passed on the command line. + :param parser: To add command line options. + + Use in conftest plugins + ======================= + + This hook is not called for conftest files. """ @hookspec(firstresult=True) -def pytest_cmdline_main(config: "Config") -> Optional[Union["ExitCode", int]]: - """Called for performing the main command line action. The default - implementation will invoke the configure hooks and runtest_mainloop. +def pytest_cmdline_main(config: Config) -> ExitCode | int | None: + """Called for performing the main command line action. + + The default implementation will invoke the configure hooks and + :hook:`pytest_runtestloop`. Stops at first non-None result, see :ref:`firstresult`. :param config: The pytest config object. :returns: The exit code. - """ + Use in conftest plugins + ======================= -def pytest_load_initial_conftests( - early_config: "Config", parser: "Parser", args: List[str] -) -> None: - """Called to implement the loading of initial conftest files ahead - of command line option parsing. - - .. note:: - This hook will not be called for ``conftest.py`` files, only for setuptools plugins. - - :param early_config: The pytest config object. - :param args: Arguments passed on the command line. - :param parser: To add command line options. + This hook is only called for :ref:`initial conftests `. """ @@ -207,7 +227,7 @@ def pytest_load_initial_conftests( @hookspec(firstresult=True) -def pytest_collection(session: "Session") -> Optional[object]: +def pytest_collection(session: Session) -> object | None: """Perform the collection phase for the given session. Stops at first non-None result, see :ref:`firstresult`. @@ -240,33 +260,65 @@ def pytest_collection(session: "Session") -> Optional[object]: counter (and returns `None`). :param session: The pytest session object. + + Use in conftest plugins + ======================= + + This hook is only called for :ref:`initial conftests `. """ def pytest_collection_modifyitems( - session: "Session", config: "Config", items: List["Item"] + session: Session, config: Config, items: list[Item] ) -> None: """Called after collection has been performed. May filter or re-order the items in-place. + When items are deselected (filtered out from ``items``), + the hook :hook:`pytest_deselected` must be called explicitly + with the deselected items to properly notify other plugins, + e.g. with ``config.hook.pytest_deselected(items=deselected_items)``. + :param session: The pytest session object. :param config: The pytest config object. :param items: List of item objects. + + Use in conftest plugins + ======================= + + Any conftest plugin can implement this hook. """ -def pytest_collection_finish(session: "Session") -> None: +def pytest_collection_finish(session: Session) -> None: """Called after collection has been performed and modified. :param session: The pytest session object. + + Use in conftest plugins + ======================= + + Any conftest plugin can implement this hook. """ -@hookspec(firstresult=True) +@hookspec( + firstresult=True, + warn_on_impl_args={ + "path": HOOK_LEGACY_PATH_ARG.format( + pylib_path_arg="path", pathlib_path_arg="collection_path" + ), + }, +) def pytest_ignore_collect( - collection_path: Path, path: "LEGACY_PATH", config: "Config" -) -> Optional[bool]: - """Return True to prevent considering this path for collection. + collection_path: Path, path: LEGACY_PATH, config: Config +) -> bool | None: + """Return ``True`` to ignore this path for collection. + + Return ``None`` to let other plugins ignore the path for collection. + + Returning ``False`` will forcefully *not* ignore this path for collection, + without giving a chance for other plugins to ignore this path. This hook is consulted for all files and directories prior to calling more specific hooks. @@ -274,6 +326,7 @@ def pytest_ignore_collect( Stops at first non-None result, see :ref:`firstresult`. :param collection_path: The path to analyze. + :type collection_path: pathlib.Path :param path: The path to analyze (deprecated). :param config: The pytest config object. @@ -281,65 +334,151 @@ def pytest_ignore_collect( The ``collection_path`` parameter was added as a :class:`pathlib.Path` equivalent of the ``path`` parameter. The ``path`` parameter has been deprecated. + + Use in conftest plugins + ======================= + + Any conftest file can implement this hook. For a given collection path, only + conftest files in parent directories of the collection path are consulted + (if the path is a directory, its own conftest file is *not* consulted - a + directory cannot ignore itself!). """ +@hookspec(firstresult=True) +def pytest_collect_directory(path: Path, parent: Collector) -> Collector | None: + """Create a :class:`~pytest.Collector` for the given directory, or None if + not relevant. + + .. versionadded:: 8.0 + + For best results, the returned collector should be a subclass of + :class:`~pytest.Directory`, but this is not required. + + The new node needs to have the specified ``parent`` as a parent. + + Stops at first non-None result, see :ref:`firstresult`. + + :param path: The path to analyze. + :type path: pathlib.Path + + See :ref:`custom directory collectors` for a simple example of use of this + hook. + + Use in conftest plugins + ======================= + + Any conftest file can implement this hook. For a given collection path, only + conftest files in parent directories of the collection path are consulted + (if the path is a directory, its own conftest file is *not* consulted - a + directory cannot collect itself!). + """ + + +@hookspec( + warn_on_impl_args={ + "path": HOOK_LEGACY_PATH_ARG.format( + pylib_path_arg="path", pathlib_path_arg="file_path" + ), + }, +) def pytest_collect_file( - file_path: Path, path: "LEGACY_PATH", parent: "Collector" -) -> "Optional[Collector]": + file_path: Path, path: LEGACY_PATH, parent: Collector +) -> Collector | None: """Create a :class:`~pytest.Collector` for the given path, or None if not relevant. + For best results, the returned collector should be a subclass of + :class:`~pytest.File`, but this is not required. + The new node needs to have the specified ``parent`` as a parent. :param file_path: The path to analyze. + :type file_path: pathlib.Path :param path: The path to collect (deprecated). .. versionchanged:: 7.0.0 The ``file_path`` parameter was added as a :class:`pathlib.Path` equivalent of the ``path`` parameter. The ``path`` parameter has been deprecated. + + Use in conftest plugins + ======================= + + Any conftest file can implement this hook. For a given file path, only + conftest files in parent directories of the file path are consulted. """ # logging hooks for collection -def pytest_collectstart(collector: "Collector") -> None: +def pytest_collectstart(collector: Collector) -> None: """Collector starts collecting. :param collector: The collector. + + Use in conftest plugins + ======================= + + Any conftest file can implement this hook. For a given collector, only + conftest files in the collector's directory and its parent directories are + consulted. """ -def pytest_itemcollected(item: "Item") -> None: +def pytest_itemcollected(item: Item) -> None: """We just collected a test item. :param item: The item. + + Use in conftest plugins + ======================= + + Any conftest file can implement this hook. For a given item, only conftest + files in the item's directory and its parent directories are consulted. """ -def pytest_collectreport(report: "CollectReport") -> None: +def pytest_collectreport(report: CollectReport) -> None: """Collector finished collecting. :param report: The collect report. + + Use in conftest plugins + ======================= + + Any conftest file can implement this hook. For a given collector, only + conftest files in the collector's directory and its parent directories are + consulted. """ -def pytest_deselected(items: Sequence["Item"]) -> None: +def pytest_deselected(items: Sequence[Item]) -> None: """Called for deselected test items, e.g. by keyword. + Note that this hook has two integration aspects for plugins: + + - it can be *implemented* to be notified of deselected items + - it must be *called* from :hook:`pytest_collection_modifyitems` + implementations when items are deselected (to properly notify other plugins). + May be called multiple times. :param items: The items. + + Use in conftest plugins + ======================= + + Any conftest file can implement this hook. """ @hookspec(firstresult=True) -def pytest_make_collect_report(collector: "Collector") -> "Optional[CollectReport]": +def pytest_make_collect_report(collector: Collector) -> CollectReport | None: """Perform :func:`collector.collect() ` and return a :class:`~pytest.CollectReport`. @@ -347,6 +486,13 @@ def pytest_make_collect_report(collector: "Collector") -> "Optional[CollectRepor :param collector: The collector. + + Use in conftest plugins + ======================= + + Any conftest file can implement this hook. For a given collector, only + conftest files in the collector's directory and its parent directories are + consulted. """ @@ -355,10 +501,17 @@ def pytest_make_collect_report(collector: "Collector") -> "Optional[CollectRepor # ------------------------------------------------------------------------- -@hookspec(firstresult=True) +@hookspec( + firstresult=True, + warn_on_impl_args={ + "path": HOOK_LEGACY_PATH_ARG.format( + pylib_path_arg="path", pathlib_path_arg="module_path" + ), + }, +) def pytest_pycollect_makemodule( - module_path: Path, path: "LEGACY_PATH", parent -) -> Optional["Module"]: + module_path: Path, path: LEGACY_PATH, parent +) -> Module | None: """Return a :class:`pytest.Module` collector or None for the given path. This hook will be called for each matching test module path. @@ -368,6 +521,7 @@ def pytest_pycollect_makemodule( Stops at first non-None result, see :ref:`firstresult`. :param module_path: The path of the module to collect. + :type module_path: pathlib.Path :param path: The path of the module to collect (deprecated). .. versionchanged:: 7.0.0 @@ -375,13 +529,20 @@ def pytest_pycollect_makemodule( equivalent of the ``path`` parameter. The ``path`` parameter has been deprecated in favor of ``fspath``. + + Use in conftest plugins + ======================= + + Any conftest file can implement this hook. For a given parent collector, + only conftest files in the collector's directory and its parent directories + are consulted. """ @hookspec(firstresult=True) def pytest_pycollect_makeitem( - collector: Union["Module", "Class"], name: str, obj: object -) -> Union[None, "Item", "Collector", List[Union["Item", "Collector"]]]: + collector: Module | Class, name: str, obj: object +) -> None | Item | Collector | list[Item | Collector]: """Return a custom item/collector for a Python object in a module, or None. Stops at first non-None result, see :ref:`firstresult`. @@ -394,32 +555,51 @@ def pytest_pycollect_makeitem( The object. :returns: The created items/collectors. + + Use in conftest plugins + ======================= + + Any conftest file can implement this hook. For a given collector, only + conftest files in the collector's directory and its parent directories + are consulted. """ @hookspec(firstresult=True) -def pytest_pyfunc_call(pyfuncitem: "Function") -> Optional[object]: +def pytest_pyfunc_call(pyfuncitem: Function) -> object | None: """Call underlying test function. Stops at first non-None result, see :ref:`firstresult`. :param pyfuncitem: The function item. + + Use in conftest plugins + ======================= + + Any conftest file can implement this hook. For a given item, only + conftest files in the item's directory and its parent directories + are consulted. """ -def pytest_generate_tests(metafunc: "Metafunc") -> None: +def pytest_generate_tests(metafunc: Metafunc) -> None: """Generate (multiple) parametrized calls to a test function. :param metafunc: The :class:`~pytest.Metafunc` helper for the test function. + + Use in conftest plugins + ======================= + + Any conftest file can implement this hook. For a given function definition, + only conftest files in the functions's directory and its parent directories + are consulted. """ @hookspec(firstresult=True) -def pytest_make_parametrize_id( - config: "Config", val: object, argname: str -) -> Optional[str]: +def pytest_make_parametrize_id(config: Config, val: object, argname: str) -> str | None: """Return a user-friendly string representation of the given ``val`` that will be used by @pytest.mark.parametrize calls, or None if the hook doesn't know about ``val``. @@ -430,7 +610,12 @@ def pytest_make_parametrize_id( :param config: The pytest config object. :param val: The parametrized value. - :param str argname: The automatic parameter name produced by pytest. + :param argname: The automatic parameter name produced by pytest. + + Use in conftest plugins + ======================= + + Any conftest file can implement this hook. """ @@ -440,7 +625,7 @@ def pytest_make_parametrize_id( @hookspec(firstresult=True) -def pytest_runtestloop(session: "Session") -> Optional[object]: +def pytest_runtestloop(session: Session) -> object | None: """Perform the main runtest loop (after collection finished). The default hook implementation performs the runtest protocol for all items @@ -457,13 +642,16 @@ def pytest_runtestloop(session: "Session") -> Optional[object]: Stops at first non-None result, see :ref:`firstresult`. The return value is not used, but only stops further processing. + + Use in conftest plugins + ======================= + + Any conftest file can implement this hook. """ @hookspec(firstresult=True) -def pytest_runtest_protocol( - item: "Item", nextitem: "Optional[Item]" -) -> Optional[object]: +def pytest_runtest_protocol(item: Item, nextitem: Item | None) -> object | None: """Perform the runtest protocol for a single test item. The default runtest protocol is this (see individual hooks for full details): @@ -476,7 +664,7 @@ def pytest_runtest_protocol( - ``pytest_runtest_logreport(report)`` - ``pytest_exception_interact(call, report)`` if an interactive exception occurred - - Call phase, if the the setup passed and the ``setuponly`` pytest option is not set: + - Call phase, if the setup passed and the ``setuponly`` pytest option is not set: - ``call = pytest_runtest_call(item)`` (wrapped in ``CallInfo(when="call")``) - ``report = pytest_runtest_makereport(item, call)`` - ``pytest_runtest_logreport(report)`` @@ -495,12 +683,15 @@ def pytest_runtest_protocol( Stops at first non-None result, see :ref:`firstresult`. The return value is not used, but only stops further processing. + + Use in conftest plugins + ======================= + + Any conftest file can implement this hook. """ -def pytest_runtest_logstart( - nodeid: str, location: Tuple[str, Optional[int], str] -) -> None: +def pytest_runtest_logstart(nodeid: str, location: tuple[str, int | None, str]) -> None: """Called at the start of running the runtest protocol for a single item. See :hook:`pytest_runtest_protocol` for a description of the runtest protocol. @@ -509,11 +700,17 @@ def pytest_runtest_logstart( :param location: A tuple of ``(filename, lineno, testname)`` where ``filename`` is a file path relative to ``config.rootpath`` and ``lineno`` is 0-based. + + Use in conftest plugins + ======================= + + Any conftest file can implement this hook. For a given item, only conftest + files in the item's directory and its parent directories are consulted. """ def pytest_runtest_logfinish( - nodeid: str, location: Tuple[str, Optional[int], str] + nodeid: str, location: tuple[str, int | None, str] ) -> None: """Called at the end of running the runtest protocol for a single item. @@ -523,10 +720,16 @@ def pytest_runtest_logfinish( :param location: A tuple of ``(filename, lineno, testname)`` where ``filename`` is a file path relative to ``config.rootpath`` and ``lineno`` is 0-based. + + Use in conftest plugins + ======================= + + Any conftest file can implement this hook. For a given item, only conftest + files in the item's directory and its parent directories are consulted. """ -def pytest_runtest_setup(item: "Item") -> None: +def pytest_runtest_setup(item: Item) -> None: """Called to perform the setup phase for a test item. The default implementation runs ``setup()`` on ``item`` and all of its @@ -536,20 +739,32 @@ def pytest_runtest_setup(item: "Item") -> None: :param item: The item. + + Use in conftest plugins + ======================= + + Any conftest file can implement this hook. For a given item, only conftest + files in the item's directory and its parent directories are consulted. """ -def pytest_runtest_call(item: "Item") -> None: +def pytest_runtest_call(item: Item) -> None: """Called to run the test for test item (the call phase). The default implementation calls ``item.runtest()``. :param item: The item. + + Use in conftest plugins + ======================= + + Any conftest file can implement this hook. For a given item, only conftest + files in the item's directory and its parent directories are consulted. """ -def pytest_runtest_teardown(item: "Item", nextitem: Optional["Item"]) -> None: +def pytest_runtest_teardown(item: Item, nextitem: Item | None) -> None: """Called to perform the teardown phase for a test item. The default implementation runs the finalizers and calls ``teardown()`` @@ -564,13 +779,17 @@ def pytest_runtest_teardown(item: "Item", nextitem: Optional["Item"]) -> None: scheduled). This argument is used to perform exact teardowns, i.e. calling just enough finalizers so that nextitem only needs to call setup functions. + + Use in conftest plugins + ======================= + + Any conftest file can implement this hook. For a given item, only conftest + files in the item's directory and its parent directories are consulted. """ @hookspec(firstresult=True) -def pytest_runtest_makereport( - item: "Item", call: "CallInfo[None]" -) -> Optional["TestReport"]: +def pytest_runtest_makereport(item: Item, call: CallInfo[None]) -> TestReport | None: """Called to create a :class:`~pytest.TestReport` for each of the setup, call and teardown runtest phases of a test item. @@ -580,39 +799,63 @@ def pytest_runtest_makereport( :param call: The :class:`~pytest.CallInfo` for the phase. Stops at first non-None result, see :ref:`firstresult`. + + Use in conftest plugins + ======================= + + Any conftest file can implement this hook. For a given item, only conftest + files in the item's directory and its parent directories are consulted. """ -def pytest_runtest_logreport(report: "TestReport") -> None: +def pytest_runtest_logreport(report: TestReport) -> None: """Process the :class:`~pytest.TestReport` produced for each of the setup, call and teardown runtest phases of an item. See :hook:`pytest_runtest_protocol` for a description of the runtest protocol. + + Use in conftest plugins + ======================= + + Any conftest file can implement this hook. For a given item, only conftest + files in the item's directory and its parent directories are consulted. """ @hookspec(firstresult=True) def pytest_report_to_serializable( - config: "Config", - report: Union["CollectReport", "TestReport"], -) -> Optional[Dict[str, Any]]: + config: Config, + report: CollectReport | TestReport, +) -> dict[str, Any] | None: """Serialize the given report object into a data structure suitable for sending over the wire, e.g. converted to JSON. :param config: The pytest config object. :param report: The report. + + Use in conftest plugins + ======================= + + Any conftest file can implement this hook. The exact details may depend + on the plugin which calls the hook. """ @hookspec(firstresult=True) def pytest_report_from_serializable( - config: "Config", - data: Dict[str, Any], -) -> Optional[Union["CollectReport", "TestReport"]]: + config: Config, + data: dict[str, Any], +) -> CollectReport | TestReport | None: """Restore a report object previously serialized with :hook:`pytest_report_to_serializable`. :param config: The pytest config object. + + Use in conftest plugins + ======================= + + Any conftest file can implement this hook. The exact details may depend + on the plugin which calls the hook. """ @@ -623,11 +866,11 @@ def pytest_report_from_serializable( @hookspec(firstresult=True) def pytest_fixture_setup( - fixturedef: "FixtureDef[Any]", request: "SubRequest" -) -> Optional[object]: + fixturedef: FixtureDef[Any], request: SubRequest +) -> object | None: """Perform fixture setup execution. - :param fixturdef: + :param fixturedef: The fixture definition object. :param request: The fixture request object. @@ -640,20 +883,34 @@ def pytest_fixture_setup( If the fixture function returns None, other implementations of this hook function will continue to be called, according to the behavior of the :ref:`firstresult` option. + + Use in conftest plugins + ======================= + + Any conftest file can implement this hook. For a given fixture, only + conftest files in the fixture scope's directory and its parent directories + are consulted. """ def pytest_fixture_post_finalizer( - fixturedef: "FixtureDef[Any]", request: "SubRequest" + fixturedef: FixtureDef[Any], request: SubRequest ) -> None: """Called after fixture teardown, but before the cache is cleared, so the fixture result ``fixturedef.cached_result`` is still available (not ``None``). - :param fixturdef: + :param fixturedef: The fixture definition object. :param request: The fixture request object. + + Use in conftest plugins + ======================= + + Any conftest file can implement this hook. For a given fixture, only + conftest files in the fixture scope's directory and its parent directories + are consulted. """ @@ -662,29 +919,44 @@ def pytest_fixture_post_finalizer( # ------------------------------------------------------------------------- -def pytest_sessionstart(session: "Session") -> None: +def pytest_sessionstart(session: Session) -> None: """Called after the ``Session`` object has been created and before performing collection and entering the run test loop. :param session: The pytest session object. + + Use in conftest plugins + ======================= + + This hook is only called for :ref:`initial conftests `. """ def pytest_sessionfinish( - session: "Session", - exitstatus: Union[int, "ExitCode"], + session: Session, + exitstatus: int | ExitCode, ) -> None: """Called after whole test run finished, right before returning the exit status to the system. :param session: The pytest session object. :param exitstatus: The status which pytest will return to the system. + + Use in conftest plugins + ======================= + + Any conftest file can implement this hook. """ -def pytest_unconfigure(config: "Config") -> None: +def pytest_unconfigure(config: Config) -> None: """Called before test process is exited. :param config: The pytest config object. + + Use in conftest plugins + ======================= + + Any conftest file can implement this hook. """ @@ -694,8 +966,8 @@ def pytest_unconfigure(config: "Config") -> None: def pytest_assertrepr_compare( - config: "Config", op: str, left: object, right: object -) -> Optional[List[str]]: + config: Config, op: str, left: object, right: object +) -> list[str] | None: """Return explanation for comparisons in failing assert expressions. Return None for no custom explanation, otherwise return a list @@ -707,10 +979,16 @@ def pytest_assertrepr_compare( :param op: The operator, e.g. `"=="`, `"!="`, `"not in"`. :param left: The left operand. :param right: The right operand. + + Use in conftest plugins + ======================= + + Any conftest file can implement this hook. For a given item, only conftest + files in the item's directory and its parent directories are consulted. """ -def pytest_assertion_pass(item: "Item", lineno: int, orig: str, expl: str) -> None: +def pytest_assertion_pass(item: Item, lineno: int, orig: str, expl: str) -> None: """Called whenever an assertion passes. .. versionadded:: 5.0 @@ -735,6 +1013,12 @@ def pytest_assertion_pass(item: "Item", lineno: int, orig: str, expl: str) -> No :param lineno: Line number of the assert statement. :param orig: String with the original assertion. :param expl: String with the assert explanation. + + Use in conftest plugins + ======================= + + Any conftest file can implement this hook. For a given item, only conftest + files in the item's directory and its parent directories are consulted. """ @@ -743,13 +1027,21 @@ def pytest_assertion_pass(item: "Item", lineno: int, orig: str, expl: str) -> No # ------------------------------------------------------------------------- +@hookspec( + warn_on_impl_args={ + "startdir": HOOK_LEGACY_PATH_ARG.format( + pylib_path_arg="startdir", pathlib_path_arg="start_path" + ), + }, +) def pytest_report_header( # type:ignore[empty-body] - config: "Config", start_path: Path, startdir: "LEGACY_PATH" -) -> Union[str, List[str]]: + config: Config, start_path: Path, startdir: LEGACY_PATH +) -> str | list[str]: """Return a string or list of strings to be displayed as header info for terminal reporting. :param config: The pytest config object. :param start_path: The starting dir. + :type start_path: pathlib.Path :param startdir: The starting dir (deprecated). .. note:: @@ -759,25 +1051,31 @@ def pytest_report_header( # type:ignore[empty-body] If you want to have your line(s) displayed first, use :ref:`trylast=True `. - .. note:: - - This function should be implemented only in plugins or ``conftest.py`` - files situated at the tests root directory due to how pytest - :ref:`discovers plugins during startup `. - .. versionchanged:: 7.0.0 The ``start_path`` parameter was added as a :class:`pathlib.Path` equivalent of the ``startdir`` parameter. The ``startdir`` parameter has been deprecated. + + Use in conftest plugins + ======================= + + This hook is only called for :ref:`initial conftests `. """ +@hookspec( + warn_on_impl_args={ + "startdir": HOOK_LEGACY_PATH_ARG.format( + pylib_path_arg="startdir", pathlib_path_arg="start_path" + ), + }, +) def pytest_report_collectionfinish( # type:ignore[empty-body] - config: "Config", + config: Config, start_path: Path, - startdir: "LEGACY_PATH", - items: Sequence["Item"], -) -> Union[str, List[str]]: + startdir: LEGACY_PATH, + items: Sequence[Item], +) -> str | list[str]: """Return a string or list of strings to be displayed after collection has finished successfully. @@ -787,6 +1085,7 @@ def pytest_report_collectionfinish( # type:ignore[empty-body] :param config: The pytest config object. :param start_path: The starting dir. + :type start_path: pathlib.Path :param startdir: The starting dir (deprecated). :param items: List of pytest items that are going to be executed; this list should not be modified. @@ -801,13 +1100,18 @@ def pytest_report_collectionfinish( # type:ignore[empty-body] The ``start_path`` parameter was added as a :class:`pathlib.Path` equivalent of the ``startdir`` parameter. The ``startdir`` parameter has been deprecated. + + Use in conftest plugins + ======================= + + Any conftest plugin can implement this hook. """ @hookspec(firstresult=True) def pytest_report_teststatus( # type:ignore[empty-body] - report: Union["CollectReport", "TestReport"], config: "Config" -) -> "TestShortLogReport | Tuple[str, str, Union[str, Tuple[str, Mapping[str, bool]]]]": + report: CollectReport | TestReport, config: Config +) -> TestShortLogReport | tuple[str, str, str | tuple[str, Mapping[str, bool]]]: """Return result-category, shortletter and verbose word for status reporting. @@ -829,13 +1133,18 @@ def pytest_report_teststatus( # type:ignore[empty-body] :returns: The test status. Stops at first non-None result, see :ref:`firstresult`. + + Use in conftest plugins + ======================= + + Any conftest plugin can implement this hook. """ def pytest_terminal_summary( - terminalreporter: "TerminalReporter", - exitstatus: "ExitCode", - config: "Config", + terminalreporter: TerminalReporter, + exitstatus: ExitCode, + config: Config, ) -> None: """Add a section to terminal summary reporting. @@ -845,21 +1154,26 @@ def pytest_terminal_summary( .. versionadded:: 4.2 The ``config`` parameter. + + Use in conftest plugins + ======================= + + Any conftest plugin can implement this hook. """ @hookspec(historic=True) def pytest_warning_recorded( - warning_message: "warnings.WarningMessage", - when: "Literal['config', 'collect', 'runtest']", + warning_message: warnings.WarningMessage, + when: Literal["config", "collect", "runtest"], nodeid: str, - location: Optional[Tuple[str, int, str]], + location: tuple[str, int, str] | None, ) -> None: """Process a warning captured by the internal pytest warnings plugin. :param warning_message: - The captured warning. This is the same object produced by :py:func:`warnings.catch_warnings`, and contains - the same attributes as the parameters of :py:func:`warnings.showwarning`. + The captured warning. This is the same object produced by :class:`warnings.catch_warnings`, + and contains the same attributes as the parameters of :py:func:`warnings.showwarning`. :param when: Indicates when the warning was captured. Possible values: @@ -869,7 +1183,8 @@ def pytest_warning_recorded( * ``"runtest"``: during test execution. :param nodeid: - Full id of the item. + Full id of the item. Empty string for warnings that are not specific to + a particular node. :param location: When available, holds information about the execution context of the captured @@ -877,6 +1192,13 @@ def pytest_warning_recorded( when the execution context is at the module level. .. versionadded:: 6.0 + + Use in conftest plugins + ======================= + + Any conftest file can implement this hook. If the warning is specific to a + particular node, only conftest files in parent directories of the node are + consulted. """ @@ -886,8 +1208,8 @@ def pytest_warning_recorded( def pytest_markeval_namespace( # type:ignore[empty-body] - config: "Config", -) -> Dict[str, Any]: + config: Config, +) -> dict[str, Any]: """Called when constructing the globals dictionary used for evaluating string conditions in xfail/skipif markers. @@ -900,6 +1222,12 @@ def pytest_markeval_namespace( # type:ignore[empty-body] :param config: The pytest config object. :returns: A dictionary of additional globals to add. + + Use in conftest plugins + ======================= + + Any conftest file can implement this hook. For a given item, only conftest + files in parent directories of the item are consulted. """ @@ -909,9 +1237,9 @@ def pytest_markeval_namespace( # type:ignore[empty-body] def pytest_internalerror( - excrepr: "ExceptionRepr", - excinfo: "ExceptionInfo[BaseException]", -) -> Optional[bool]: + excrepr: ExceptionRepr, + excinfo: ExceptionInfo[BaseException], +) -> bool | None: """Called for internal errors. Return True to suppress the fallback handling of printing an @@ -919,31 +1247,41 @@ def pytest_internalerror( :param excrepr: The exception repr object. :param excinfo: The exception info. + + Use in conftest plugins + ======================= + + Any conftest plugin can implement this hook. """ def pytest_keyboard_interrupt( - excinfo: "ExceptionInfo[Union[KeyboardInterrupt, Exit]]", + excinfo: ExceptionInfo[KeyboardInterrupt | Exit], ) -> None: """Called for keyboard interrupt. :param excinfo: The exception info. + + Use in conftest plugins + ======================= + + Any conftest plugin can implement this hook. """ def pytest_exception_interact( - node: Union["Item", "Collector"], - call: "CallInfo[Any]", - report: Union["CollectReport", "TestReport"], + node: Item | Collector, + call: CallInfo[Any], + report: CollectReport | TestReport, ) -> None: """Called when an exception was raised which can potentially be interactively handled. May be called during collection (see :hook:`pytest_make_collect_report`), - in which case ``report`` is a :class:`CollectReport`. + in which case ``report`` is a :class:`~pytest.CollectReport`. May be called during runtest of an item (see :hook:`pytest_runtest_protocol`), - in which case ``report`` is a :class:`TestReport`. + in which case ``report`` is a :class:`~pytest.TestReport`. This hook is not called if the exception that was raised is an internal exception like ``skip.Exception``. @@ -954,10 +1292,16 @@ def pytest_exception_interact( The call information. Contains the exception. :param report: The collection or test report. + + Use in conftest plugins + ======================= + + Any conftest file can implement this hook. For a given node, only conftest + files in parent directories of the node are consulted. """ -def pytest_enter_pdb(config: "Config", pdb: "pdb.Pdb") -> None: +def pytest_enter_pdb(config: Config, pdb: pdb.Pdb) -> None: """Called upon pdb.set_trace(). Can be used by plugins to take special action just before the python @@ -965,10 +1309,15 @@ def pytest_enter_pdb(config: "Config", pdb: "pdb.Pdb") -> None: :param config: The pytest config object. :param pdb: The Pdb instance. + + Use in conftest plugins + ======================= + + Any conftest plugin can implement this hook. """ -def pytest_leave_pdb(config: "Config", pdb: "pdb.Pdb") -> None: +def pytest_leave_pdb(config: Config, pdb: pdb.Pdb) -> None: """Called when leaving pdb (e.g. with continue after pdb.set_trace()). Can be used by plugins to take special action just after the python @@ -976,4 +1325,9 @@ def pytest_leave_pdb(config: "Config", pdb: "pdb.Pdb") -> None: :param config: The pytest config object. :param pdb: The Pdb instance. + + Use in conftest plugins + ======================= + + Any conftest plugin can implement this hook. """ diff --git a/src/_pytest/junitxml.py b/src/_pytest/junitxml.py index 9242d46d9df..dc35e3aac15 100644 --- a/src/_pytest/junitxml.py +++ b/src/_pytest/junitxml.py @@ -1,3 +1,4 @@ +# mypy: allow-untyped-defs """Report test results in JUnit-XML format, for use with Jenkins and build integration servers. @@ -6,21 +7,16 @@ Output conforms to https://github.com/jenkinsci/xunit-plugin/blob/master/src/main/resources/org/jenkinsci/plugins/xunit/types/model/xsd/junit-10.xsd """ + +from __future__ import annotations + +from collections.abc import Callable import functools import os import platform import re import xml.etree.ElementTree as ET -from datetime import datetime -from typing import Callable -from typing import Dict -from typing import List -from typing import Match -from typing import Optional -from typing import Tuple -from typing import Union -import pytest from _pytest import nodes from _pytest import timing from _pytest._code.code import ExceptionRepr @@ -32,6 +28,7 @@ from _pytest.reports import TestReport from _pytest.stash import StashKey from _pytest.terminal import TerminalReporter +import pytest xml_key = StashKey["LogXML"]() @@ -48,18 +45,18 @@ def bin_xml_escape(arg: object) -> str: The idea is to escape visually for the user rather than for XML itself. """ - def repl(matchobj: Match[str]) -> str: + def repl(matchobj: re.Match[str]) -> str: i = ord(matchobj.group()) if i <= 0xFF: - return "#x%02X" % i + return f"#x{i:02X}" else: - return "#x%04X" % i + return f"#x{i:04X}" # The spec range of valid chars is: # Char ::= #x9 | #xA | #xD | [#x20-#xD7FF] | [#xE000-#xFFFD] | [#x10000-#x10FFFF] # For an unknown(?) reason, we disallow #x7F (DEL) as well. illegal_xml_re = ( - "[^\u0009\u000A\u000D\u0020-\u007E\u0080-\uD7FF\uE000-\uFFFD\u10000-\u10FFFF]" + "[^\u0009\u000a\u000d\u0020-\u007e\u0080-\ud7ff\ue000-\ufffd\u10000-\u10ffff]" ) return re.sub(illegal_xml_re, repl, str(arg)) @@ -74,10 +71,10 @@ def merge_family(left, right) -> None: left.update(result) -families = {} -families["_base"] = {"testcase": ["classname", "name"]} -families["_base_legacy"] = {"testcase": ["file", "line", "url"]} - +families = { # pylint: disable=dict-init-mutate + "_base": {"testcase": ["classname", "name"]}, + "_base_legacy": {"testcase": ["file", "line", "url"]}, +} # xUnit 1.x inherits legacy attributes. families["xunit1"] = families["_base"].copy() merge_family(families["xunit1"], families["_base_legacy"]) @@ -87,15 +84,15 @@ def merge_family(left, right) -> None: class _NodeReporter: - def __init__(self, nodeid: Union[str, TestReport], xml: "LogXML") -> None: + def __init__(self, nodeid: str | TestReport, xml: LogXML) -> None: self.id = nodeid self.xml = xml self.add_stats = self.xml.add_stats self.family = self.xml.family self.duration = 0.0 - self.properties: List[Tuple[str, str]] = [] - self.nodes: List[ET.Element] = [] - self.attrs: Dict[str, str] = {} + self.properties: list[tuple[str, str]] = [] + self.nodes: list[ET.Element] = [] + self.attrs: dict[str, str] = {} def append(self, node: ET.Element) -> None: self.xml.add_stats(node.tag) @@ -107,7 +104,7 @@ def add_property(self, name: str, value: object) -> None: def add_attribute(self, name: str, value: object) -> None: self.attrs[str(name)] = bin_xml_escape(value) - def make_properties_node(self) -> Optional[ET.Element]: + def make_properties_node(self) -> ET.Element | None: """Return a Junit node containing custom properties, if any.""" if self.properties: properties = ET.Element("properties") @@ -122,7 +119,7 @@ def record_testreport(self, testreport: TestReport) -> None: classnames = names[:-1] if self.xml.prefix: classnames.insert(0, self.xml.prefix) - attrs: Dict[str, str] = { + attrs: dict[str, str] = { "classname": ".".join(classnames), "name": bin_xml_escape(names[-1]), "file": testreport.location[0], @@ -141,20 +138,20 @@ def record_testreport(self, testreport: TestReport) -> None: # Filter out attributes not permitted by this test family. # Including custom attributes because they are not valid here. temp_attrs = {} - for key in self.attrs.keys(): + for key in self.attrs: if key in families[self.family]["testcase"]: temp_attrs[key] = self.attrs[key] self.attrs = temp_attrs def to_xml(self) -> ET.Element: - testcase = ET.Element("testcase", self.attrs, time="%.3f" % self.duration) + testcase = ET.Element("testcase", self.attrs, time=f"{self.duration:.3f}") properties = self.make_properties_node() if properties is not None: testcase.append(properties) testcase.extend(self.nodes) return testcase - def _add_simple(self, tag: str, message: str, data: Optional[str] = None) -> None: + def _add_simple(self, tag: str, message: str, data: str | None = None) -> None: node = ET.Element(tag, message=message) node.text = bin_xml_escape(data) self.append(node) @@ -199,7 +196,7 @@ def append_failure(self, report: TestReport) -> None: self._add_simple("skipped", "xfail-marked test passes unexpectedly") else: assert report.longrepr is not None - reprcrash: Optional[ReprFileLocation] = getattr( + reprcrash: ReprFileLocation | None = getattr( report.longrepr, "reprcrash", None ) if reprcrash is not None: @@ -219,9 +216,7 @@ def append_collect_skipped(self, report: TestReport) -> None: def append_error(self, report: TestReport) -> None: assert report.longrepr is not None - reprcrash: Optional[ReprFileLocation] = getattr( - report.longrepr, "reprcrash", None - ) + reprcrash: ReprFileLocation | None = getattr(report.longrepr, "reprcrash", None) if reprcrash is not None: reason = reprcrash.message else: @@ -248,7 +243,9 @@ def append_skipped(self, report: TestReport) -> None: skipreason = skipreason[9:] details = f"{filename}:{lineno}: {skipreason}" - skipped = ET.Element("skipped", type="pytest.skip", message=skipreason) + skipped = ET.Element( + "skipped", type="pytest.skip", message=bin_xml_escape(skipreason) + ) skipped.text = bin_xml_escape(details) self.append(skipped) self.write_captured_output(report) @@ -258,7 +255,7 @@ def finalize(self) -> None: self.__dict__.clear() # Type ignored because mypy doesn't like overriding a method. # Also the return value doesn't match... - self.to_xml = lambda: data # type: ignore[assignment] + self.to_xml = lambda: data # type: ignore[method-assign] def _warn_incompatibility_with_xunit2( @@ -271,9 +268,7 @@ def _warn_incompatibility_with_xunit2( if xml is not None and xml.family not in ("xunit1", "legacy"): request.node.warn( PytestWarning( - "{fixture_name} is incompatible with junit_family '{family}' (use 'legacy' or 'xunit1')".format( - fixture_name=fixture_name, family=xml.family - ) + f"{fixture_name} is incompatible with junit_family '{xml.family}' (use 'legacy' or 'xunit1')" ) ) @@ -365,17 +360,16 @@ def test_foo(record_testsuite_property): `pytest-xdist `__ plugin. See :issue:`7767` for details. """ - __tracebackhide__ = True def record_func(name: str, value: object) -> None: - """No-op function in case --junitxml was not passed in the command-line.""" + """No-op function in case --junit-xml was not passed in the command-line.""" __tracebackhide__ = True _check_record_param_type("name", name) xml = request.config.stash.get(xml_key, None) if xml is not None: - record_func = xml.add_global_property # noqa + record_func = xml.add_global_property return record_func @@ -450,7 +444,7 @@ def pytest_unconfigure(config: Config) -> None: config.pluginmanager.unregister(xml) -def mangle_test_address(address: str) -> List[str]: +def mangle_test_address(address: str) -> list[str]: path, possible_open_bracket, params = address.partition("[") names = path.split("::") # Convert file path to dotted path. @@ -465,7 +459,7 @@ class LogXML: def __init__( self, logfile, - prefix: Optional[str], + prefix: str | None, suite_name: str = "pytest", logging: str = "no", report_duration: str = "total", @@ -480,17 +474,15 @@ def __init__( self.log_passing_tests = log_passing_tests self.report_duration = report_duration self.family = family - self.stats: Dict[str, int] = dict.fromkeys( + self.stats: dict[str, int] = dict.fromkeys( ["error", "passed", "failure", "skipped"], 0 ) - self.node_reporters: Dict[ - Tuple[Union[str, TestReport], object], _NodeReporter - ] = {} - self.node_reporters_ordered: List[_NodeReporter] = [] - self.global_properties: List[Tuple[str, str]] = [] + self.node_reporters: dict[tuple[str | TestReport, object], _NodeReporter] = {} + self.node_reporters_ordered: list[_NodeReporter] = [] + self.global_properties: list[tuple[str, str]] = [] # List of reports that failed on call but teardown is pending. - self.open_reports: List[TestReport] = [] + self.open_reports: list[TestReport] = [] self.cnt_double_fail_tests = 0 # Replaces convenience family with real family. @@ -502,11 +494,15 @@ def finalize(self, report: TestReport) -> None: # Local hack to handle xdist report order. workernode = getattr(report, "node", None) reporter = self.node_reporters.pop((nodeid, workernode)) + + for propname, propvalue in report.user_properties: + reporter.add_property(propname, str(propvalue)) + if reporter is not None: reporter.finalize() - def node_reporter(self, report: Union[TestReport, str]) -> _NodeReporter: - nodeid: Union[str, TestReport] = getattr(report, "nodeid", report) + def node_reporter(self, report: TestReport | str) -> _NodeReporter: + nodeid: str | TestReport = getattr(report, "nodeid", report) # Local hack to handle xdist report order. workernode = getattr(report, "node", None) @@ -599,9 +595,6 @@ def pytest_runtest_logreport(self, report: TestReport) -> None: reporter = self._opentestcase(report) reporter.write_captured_output(report) - for propname, propvalue in report.user_properties: - reporter.add_property(propname, str(propvalue)) - self.finalize(report) report_wid = getattr(report, "worker_id", None) report_ii = getattr(report, "item_index", None) @@ -623,7 +616,7 @@ def pytest_runtest_logreport(self, report: TestReport) -> None: def update_testcase_duration(self, report: TestReport) -> None: """Accumulate total duration for nodeid from given report and update the Junit.testcase with the new total if already created.""" - if self.report_duration == "total" or report.when == self.report_duration: + if self.report_duration in {"total", report.when}: reporter = self.node_reporter(report) reporter.duration += getattr(report, "duration", 0.0) @@ -641,7 +634,7 @@ def pytest_internalerror(self, excrepr: ExceptionRepr) -> None: reporter._add_simple("error", "internal error", str(excrepr)) def pytest_sessionstart(self) -> None: - self.suite_start_time = timing.time() + self.suite_start = timing.Instant() def pytest_sessionfinish(self) -> None: dirname = os.path.dirname(os.path.abspath(self.logfile)) @@ -649,8 +642,7 @@ def pytest_sessionfinish(self) -> None: os.makedirs(dirname, exist_ok=True) with open(self.logfile, "w", encoding="utf-8") as logfile: - suite_stop_time = timing.time() - suite_time_delta = suite_stop_time - self.suite_start_time + duration = self.suite_start.elapsed() numtests = ( self.stats["passed"] @@ -668,8 +660,8 @@ def pytest_sessionfinish(self) -> None: failures=str(self.stats["failure"]), skipped=str(self.stats["skipped"]), tests=str(numtests), - time="%.3f" % suite_time_delta, - timestamp=datetime.fromtimestamp(self.suite_start_time).isoformat(), + time=f"{duration.seconds:.3f}", + timestamp=self.suite_start.as_utc().astimezone().isoformat(), hostname=platform.node(), ) global_properties = self._get_global_properties_node() @@ -678,6 +670,7 @@ def pytest_sessionfinish(self) -> None: for node_reporter in self.node_reporters_ordered: suite_node.append(node_reporter.to_xml()) testsuites = ET.Element("testsuites") + testsuites.set("name", "pytest tests") testsuites.append(suite_node) logfile.write(ET.tostring(testsuites, encoding="unicode")) @@ -689,7 +682,7 @@ def add_global_property(self, name: str, value: object) -> None: _check_record_param_type("name", name) self.global_properties.append((name, bin_xml_escape(value))) - def _get_global_properties_node(self) -> Optional[ET.Element]: + def _get_global_properties_node(self) -> ET.Element | None: """Return a Junit node containing custom properties, if any.""" if self.global_properties: properties = ET.Element("properties") diff --git a/src/_pytest/legacypath.py b/src/_pytest/legacypath.py index af1d0c07e3c..59e8ef6e742 100644 --- a/src/_pytest/legacypath.py +++ b/src/_pytest/legacypath.py @@ -1,17 +1,19 @@ +# mypy: allow-untyped-defs """Add backward compatibility support for the legacy py path type.""" + +from __future__ import annotations + import dataclasses +from pathlib import Path import shlex import subprocess -from pathlib import Path -from typing import List -from typing import Optional +from typing import Final +from typing import final from typing import TYPE_CHECKING -from typing import Union from iniconfig import SectionWrapper from _pytest.cacheprovider import Cache -from _pytest.compat import final from _pytest.compat import LEGACY_PATH from _pytest.compat import legacy_path from _pytest.config import Config @@ -31,9 +33,8 @@ from _pytest.terminal import TerminalReporter from _pytest.tmpdir import TempPathFactory -if TYPE_CHECKING: - from typing_extensions import Final +if TYPE_CHECKING: import pexpect @@ -48,8 +49,8 @@ class Testdir: __test__ = False - CLOSE_STDIN: "Final" = Pytester.CLOSE_STDIN - TimeoutExpired: "Final" = Pytester.TimeoutExpired + CLOSE_STDIN: Final = Pytester.CLOSE_STDIN + TimeoutExpired: Final = Pytester.TimeoutExpired def __init__(self, pytester: Pytester, *, _ispytest: bool = False) -> None: check_ispytest(_ispytest) @@ -89,7 +90,6 @@ def chdir(self) -> None: return self._pytester.chdir() def finalize(self) -> None: - """See :meth:`Pytester._finalize`.""" return self._pytester._finalize() def makefile(self, ext, *args, **kwargs) -> LEGACY_PATH: @@ -144,7 +144,7 @@ def copy_example(self, name=None) -> LEGACY_PATH: """See :meth:`Pytester.copy_example`.""" return legacy_path(self._pytester.copy_example(name)) - def getnode(self, config: Config, arg) -> Optional[Union[Item, Collector]]: + def getnode(self, config: Config, arg) -> Item | Collector | None: """See :meth:`Pytester.getnode`.""" return self._pytester.getnode(config, arg) @@ -152,7 +152,7 @@ def getpathnode(self, path): """See :meth:`Pytester.getpathnode`.""" return self._pytester.getpathnode(path) - def genitems(self, colitems: List[Union[Item, Collector]]) -> List[Item]: + def genitems(self, colitems: list[Item | Collector]) -> list[Item]: """See :meth:`Pytester.genitems`.""" return self._pytester.genitems(colitems) @@ -204,9 +204,7 @@ def getmodulecol(self, source, configargs=(), withinit=False): source, configargs=configargs, withinit=withinit ) - def collect_by_name( - self, modcol: Collector, name: str - ) -> Optional[Union[Item, Collector]]: + def collect_by_name(self, modcol: Collector, name: str) -> Item | Collector | None: """See :meth:`Pytester.collect_by_name`.""" return self._pytester.collect_by_name(modcol, name) @@ -237,13 +235,11 @@ def runpytest_subprocess(self, *args, timeout=None) -> RunResult: """See :meth:`Pytester.runpytest_subprocess`.""" return self._pytester.runpytest_subprocess(*args, timeout=timeout) - def spawn_pytest( - self, string: str, expect_timeout: float = 10.0 - ) -> "pexpect.spawn": + def spawn_pytest(self, string: str, expect_timeout: float = 10.0) -> pexpect.spawn: """See :meth:`Pytester.spawn_pytest`.""" return self._pytester.spawn_pytest(string, expect_timeout=expect_timeout) - def spawn(self, cmd: str, expect_timeout: float = 10.0) -> "pexpect.spawn": + def spawn(self, cmd: str, expect_timeout: float = 10.0) -> pexpect.spawn: """See :meth:`Pytester.spawn`.""" return self._pytester.spawn(cmd, expect_timeout=expect_timeout) @@ -270,7 +266,7 @@ def testdir(pytester: Pytester) -> Testdir: @final @dataclasses.dataclass class TempdirFactory: - """Backward compatibility wrapper that implements :class:`py.path.local` + """Backward compatibility wrapper that implements ``py.path.local`` for :class:`TempPathFactory`. .. note:: @@ -289,11 +285,11 @@ def __init__( self._tmppath_factory = tmppath_factory def mktemp(self, basename: str, numbered: bool = True) -> LEGACY_PATH: - """Same as :meth:`TempPathFactory.mktemp`, but returns a :class:`py.path.local` object.""" + """Same as :meth:`TempPathFactory.mktemp`, but returns a ``py.path.local`` object.""" return legacy_path(self._tmppath_factory.mktemp(basename, numbered).resolve()) def getbasetemp(self) -> LEGACY_PATH: - """Same as :meth:`TempPathFactory.getbasetemp`, but returns a :class:`py.path.local` object.""" + """Same as :meth:`TempPathFactory.getbasetemp`, but returns a ``py.path.local`` object.""" return legacy_path(self._tmppath_factory.getbasetemp().resolve()) @@ -308,16 +304,11 @@ def tmpdir_factory(request: FixtureRequest) -> TempdirFactory: @staticmethod @fixture def tmpdir(tmp_path: Path) -> LEGACY_PATH: - """Return a temporary directory path object which is unique to each test - function invocation, created as a sub directory of the base temporary - directory. - - By default, a new base temporary directory is created each test session, - and old bases are removed after 3 sessions, to aid in debugging. If - ``--basetemp`` is used then it is cleared each session. See :ref:`base - temporary directory`. - - The returned object is a `legacy_path`_ object. + """Return a temporary directory (as `legacy_path`_ object) + which is unique to each test function invocation. + The temporary directory is created as a subdirectory + of the base temporary directory, with configurable retention, + as discussed in :ref:`temporary directory location and retention`. .. note:: These days, it is preferred to use ``tmp_path``. @@ -373,7 +364,7 @@ def Config_rootdir(self: Config) -> LEGACY_PATH: return legacy_path(str(self.rootpath)) -def Config_inifile(self: Config) -> Optional[LEGACY_PATH]: +def Config_inifile(self: Config) -> LEGACY_PATH | None: """The path to the :ref:`configfile `. Prefer to use :attr:`inipath`, which is a :class:`pathlib.Path`. @@ -383,7 +374,7 @@ def Config_inifile(self: Config) -> Optional[LEGACY_PATH]: return legacy_path(str(self.inipath)) if self.inipath else None -def Session_stardir(self: Session) -> LEGACY_PATH: +def Session_startdir(self: Session) -> LEGACY_PATH: """The path from which pytest was invoked. Prefer to use ``startpath`` which is a :class:`pathlib.Path`. @@ -393,9 +384,7 @@ def Session_stardir(self: Session) -> LEGACY_PATH: return legacy_path(self.startpath) -def Config__getini_unknown_type( - self, name: str, type: str, value: Union[str, List[str]] -): +def Config__getini_unknown_type(self, name: str, type: str, value: str | list[str]): if type == "pathlist": # TODO: This assert is probably not valid in all cases. assert self.inipath is not None @@ -438,7 +427,7 @@ def pytest_load_initial_conftests(early_config: Config) -> None: mp.setattr(Config, "inifile", property(Config_inifile), raising=False) # Add Session.startdir property. - mp.setattr(Session, "startdir", property(Session_stardir), raising=False) + mp.setattr(Session, "startdir", property(Session_startdir), raising=False) # Add pathlist configuration type. mp.setattr(Config, "_getini_unknown_type", Config__getini_unknown_type) diff --git a/src/_pytest/logging.py b/src/_pytest/logging.py index 83813466016..e4fed579d21 100644 --- a/src/_pytest/logging.py +++ b/src/_pytest/logging.py @@ -1,31 +1,33 @@ +# mypy: allow-untyped-defs """Access and control log capturing.""" -import io -import logging -import os -import re + +from __future__ import annotations + +from collections.abc import Generator +from collections.abc import Mapping +from collections.abc import Set as AbstractSet from contextlib import contextmanager from contextlib import nullcontext from datetime import datetime from datetime import timedelta from datetime import timezone +import io from io import StringIO +import logging from logging import LogRecord +import os from pathlib import Path -from typing import AbstractSet -from typing import Dict -from typing import Generator -from typing import List -from typing import Mapping -from typing import Optional -from typing import Tuple +import re +from types import TracebackType +from typing import final +from typing import Generic +from typing import Literal from typing import TYPE_CHECKING from typing import TypeVar -from typing import Union from _pytest import nodes from _pytest._io import TerminalWriter from _pytest.capture import CaptureManager -from _pytest.compat import final from _pytest.config import _strtobool from _pytest.config import Config from _pytest.config import create_terminal_writer @@ -39,10 +41,9 @@ from _pytest.stash import StashKey from _pytest.terminal import TerminalReporter + if TYPE_CHECKING: logging_StreamHandler = logging.StreamHandler[StringIO] - - from typing_extensions import Literal else: logging_StreamHandler = logging.StreamHandler @@ -50,7 +51,7 @@ DEFAULT_LOG_DATE_FORMAT = "%H:%M:%S" _ANSI_ESCAPE_SEQ = re.compile(r"\x1b\[[\d;]+m") caplog_handler_key = StashKey["LogCaptureHandler"]() -caplog_records_key = StashKey[Dict[str, List[logging.LogRecord]]]() +caplog_records_key = StashKey[dict[str, list[logging.LogRecord]]]() def _remove_ansi_escape_sequences(text: str) -> str: @@ -63,13 +64,14 @@ class DatetimeFormatter(logging.Formatter): :func:`time.strftime` in case of microseconds in format string. """ - def formatTime(self, record: LogRecord, datefmt=None) -> str: + def formatTime(self, record: LogRecord, datefmt: str | None = None) -> str: if datefmt and "%f" in datefmt: ct = self.converter(record.created) tz = timezone(timedelta(seconds=ct.tm_gmtoff), ct.tm_zone) # Construct `datetime.datetime` object from `struct_time` # and msecs information from `record` - dt = datetime(*ct[0:6], microsecond=round(record.msecs * 1000), tzinfo=tz) + # Using int() instead of round() to avoid it exceeding 1_000_000 and causing a ValueError (#11861). + dt = datetime(*ct[0:6], microsecond=int(record.msecs * 1000), tzinfo=tz) return dt.strftime(datefmt) # Use `logging.Formatter` for non-microsecond formats return super().formatTime(record, datefmt) @@ -94,7 +96,7 @@ def __init__(self, terminalwriter: TerminalWriter, *args, **kwargs) -> None: super().__init__(*args, **kwargs) self._terminalwriter = terminalwriter self._original_fmt = self._style._fmt - self._level_to_fmt_mapping: Dict[int, str] = {} + self._level_to_fmt_mapping: dict[int, str] = {} for level, color_opts in self.LOGLEVEL_COLOROPTS.items(): self.add_color_level(level, *color_opts) @@ -112,7 +114,6 @@ def add_color_level(self, level: int, *color_opts: str) -> None: .. warning:: This is an experimental API. """ - assert self._fmt is not None levelname_fmt_match = self.LEVELNAME_FMT_REGEX.search(self._fmt) if not levelname_fmt_match: @@ -143,12 +144,12 @@ class PercentStyleMultiline(logging.PercentStyle): formats the message as if each line were logged separately. """ - def __init__(self, fmt: str, auto_indent: Union[int, str, bool, None]) -> None: + def __init__(self, fmt: str, auto_indent: int | str | bool | None) -> None: super().__init__(fmt) self._auto_indent = self._get_auto_indent(auto_indent) @staticmethod - def _get_auto_indent(auto_indent_option: Union[int, str, bool, None]) -> int: + def _get_auto_indent(auto_indent_option: int | str | bool | None) -> int: """Determine the current auto indentation setting. Specify auto indent behavior (on/off/fixed) by passing in @@ -179,7 +180,6 @@ def _get_auto_indent(auto_indent_option: Union[int, str, bool, None]) -> int: 0 (auto-indent turned off) or >0 (explicitly set indentation position). """ - if auto_indent_option is None: return 0 elif isinstance(auto_indent_option, bool): @@ -206,7 +206,7 @@ def format(self, record: logging.LogRecord) -> str: if "\n" in record.message: if hasattr(record, "auto_indent"): # Passed in from the "extra={}" kwarg on the call to logging.log(). - auto_indent = self._get_auto_indent(record.auto_indent) # type: ignore[attr-defined] + auto_indent = self._get_auto_indent(record.auto_indent) else: auto_indent = self._auto_indent @@ -295,6 +295,13 @@ def add_option_ini(option, dest, default=None, type=None, **kwargs): default=None, help="Path to a file when logging will be written to", ) + add_option_ini( + "--log-file-mode", + dest="log_file_mode", + default="w", + choices=["w", "a"], + help="Log file open mode", + ) add_option_ini( "--log-file-level", dest="log_file_level", @@ -304,13 +311,13 @@ def add_option_ini(option, dest, default=None, type=None, **kwargs): add_option_ini( "--log-file-format", dest="log_file_format", - default=DEFAULT_LOG_FORMAT, + default=None, help="Log format used by the logging module", ) add_option_ini( "--log-file-date-format", dest="log_file_date_format", - default=DEFAULT_LOG_DATE_FORMAT, + default=None, help="Log date format used by the logging module", ) add_option_ini( @@ -332,16 +339,16 @@ def add_option_ini(option, dest, default=None, type=None, **kwargs): # Not using @contextmanager for performance reasons. -class catching_logs: +class catching_logs(Generic[_HandlerType]): """Context manager that prepares the whole logging machinery properly.""" __slots__ = ("handler", "level", "orig_level") - def __init__(self, handler: _HandlerType, level: Optional[int] = None) -> None: + def __init__(self, handler: _HandlerType, level: int | None = None) -> None: self.handler = handler self.level = level - def __enter__(self): + def __enter__(self) -> _HandlerType: root_logger = logging.getLogger() if self.level is not None: self.handler.setLevel(self.level) @@ -351,7 +358,12 @@ def __enter__(self): root_logger.setLevel(min(self.orig_level, self.level)) return self.handler - def __exit__(self, type, value, traceback): + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: root_logger = logging.getLogger() if self.level is not None: root_logger.setLevel(self.orig_level) @@ -364,7 +376,7 @@ class LogCaptureHandler(logging_StreamHandler): def __init__(self) -> None: """Create a new log handler.""" super().__init__(StringIO()) - self.records: List[logging.LogRecord] = [] + self.records: list[logging.LogRecord] = [] def emit(self, record: logging.LogRecord) -> None: """Keep the log records in a list in addition to the log text.""" @@ -385,7 +397,7 @@ def handleError(self, record: logging.LogRecord) -> None: # The default behavior of logging is to print "Logging error" # to stderr with the call stack and some extra details. # pytest wants to make such mistakes visible during testing. - raise + raise # noqa: PLE0704 @final @@ -395,10 +407,10 @@ class LogCaptureFixture: def __init__(self, item: nodes.Node, *, _ispytest: bool = False) -> None: check_ispytest(_ispytest) self._item = item - self._initial_handler_level: Optional[int] = None + self._initial_handler_level: int | None = None # Dict of log name -> log level. - self._initial_logger_levels: Dict[Optional[str], int] = {} - self._initial_disabled_logging_level: Optional[int] = None + self._initial_logger_levels: dict[str | None, int] = {} + self._initial_disabled_logging_level: int | None = None def _finalize(self) -> None: """Finalize the fixture. @@ -422,8 +434,8 @@ def handler(self) -> LogCaptureHandler: return self._item.stash[caplog_handler_key] def get_records( - self, when: "Literal['setup', 'call', 'teardown']" - ) -> List[logging.LogRecord]: + self, when: Literal["setup", "call", "teardown"] + ) -> list[logging.LogRecord]: """Get the logging records for one of the possible test phases. :param when: @@ -442,12 +454,12 @@ def text(self) -> str: return _remove_ansi_escape_sequences(self.handler.stream.getvalue()) @property - def records(self) -> List[logging.LogRecord]: + def records(self) -> list[logging.LogRecord]: """The list of log records.""" return self.handler.records @property - def record_tuples(self) -> List[Tuple[str, int, str]]: + def record_tuples(self) -> list[tuple[str, int, str]]: """A list of a stripped down version of log records intended for use in assertion comparison. @@ -458,7 +470,7 @@ def record_tuples(self) -> List[Tuple[str, int, str]]: return [(r.name, r.levelno, r.getMessage()) for r in self.records] @property - def messages(self) -> List[str]: + def messages(self) -> list[str]: """A list of format-interpolated log messages. Unlike 'records', which contains the format string and parameters for @@ -481,7 +493,7 @@ def clear(self) -> None: self.handler.clear() def _force_enable_logging( - self, level: Union[int, str], logger_obj: logging.Logger + self, level: int | str, logger_obj: logging.Logger ) -> int: """Enable the desired logging level if the global level was disabled via ``logging.disabled``. @@ -497,7 +509,7 @@ def _force_enable_logging( :return: The original disabled logging level. """ - original_disable_level: int = logger_obj.manager.disable # type: ignore[attr-defined] + original_disable_level: int = logger_obj.manager.disable if isinstance(level, str): # Try to translate the level string to an int for `logging.disable()` @@ -514,7 +526,7 @@ def _force_enable_logging( return original_disable_level - def set_level(self, level: Union[int, str], logger: Optional[str] = None) -> None: + def set_level(self, level: int | str, logger: str | None = None) -> None: """Set the threshold level of a logger for the duration of a test. Logging messages which are less severe than this level will not be captured. @@ -523,7 +535,7 @@ def set_level(self, level: Union[int, str], logger: Optional[str] = None) -> Non The levels of the loggers changed by this function will be restored to their initial values at the end of the test. - Will enable the requested logging level if it was disabled via :meth:`logging.disable`. + Will enable the requested logging level if it was disabled via :func:`logging.disable`. :param level: The level. :param logger: The logger to update. If not given, the root logger. @@ -540,14 +552,12 @@ def set_level(self, level: Union[int, str], logger: Optional[str] = None) -> Non self._initial_disabled_logging_level = initial_disabled_logging_level @contextmanager - def at_level( - self, level: Union[int, str], logger: Optional[str] = None - ) -> Generator[None, None, None]: + def at_level(self, level: int | str, logger: str | None = None) -> Generator[None]: """Context manager that sets the level for capturing of logs. After the end of the 'with' statement the level is restored to its original value. - Will enable the requested logging level if it was disabled via :meth:`logging.disable`. + Will enable the requested logging level if it was disabled via :func:`logging.disable`. :param level: The level. :param logger: The logger to update. If not given, the root logger. @@ -565,9 +575,25 @@ def at_level( self.handler.setLevel(handler_orig_level) logging.disable(original_disable_level) + @contextmanager + def filtering(self, filter_: logging.Filter) -> Generator[None]: + """Context manager that temporarily adds the given filter to the caplog's + :meth:`handler` for the 'with' statement block, and removes that filter at the + end of the block. + + :param filter_: A custom :class:`logging.Filter` object. + + .. versionadded:: 7.5 + """ + self.handler.addFilter(filter_) + try: + yield + finally: + self.handler.removeFilter(filter_) + @fixture -def caplog(request: FixtureRequest) -> Generator[LogCaptureFixture, None, None]: +def caplog(request: FixtureRequest) -> Generator[LogCaptureFixture]: """Access and control log capturing. Captured logs are available through the following properties/methods:: @@ -583,7 +609,7 @@ def caplog(request: FixtureRequest) -> Generator[LogCaptureFixture, None, None]: result._finalize() -def get_log_level_for_setting(config: Config, *setting_names: str) -> Optional[int]: +def get_log_level_for_setting(config: Config, *setting_names: str) -> int | None: for setting_name in setting_names: log_level = config.getoption(setting_name) if log_level is None: @@ -600,9 +626,9 @@ def get_log_level_for_setting(config: Config, *setting_names: str) -> Optional[i except ValueError as e: # Python logging does not recognise this as a logging level raise UsageError( - "'{}' is not recognized as a logging level name for " - "'{}'. Please consider passing the " - "logging level num instead.".format(log_level, setting_name) + f"'{log_level}' is not recognized as a logging level name for " + f"'{setting_name}'. Please consider passing the " + "logging level num instead." ) from e @@ -636,14 +662,19 @@ def __init__(self, config: Config) -> None: self.report_handler.setFormatter(self.formatter) # File logging. - self.log_file_level = get_log_level_for_setting(config, "log_file_level") + self.log_file_level = get_log_level_for_setting( + config, "log_file_level", "log_level" + ) log_file = get_option_ini(config, "log_file") or os.devnull if log_file != os.devnull: directory = os.path.dirname(os.path.abspath(log_file)) if not os.path.isdir(directory): os.makedirs(directory) - self.log_file_handler = _FileHandler(log_file, mode="w", encoding="UTF-8") + self.log_file_mode = get_option_ini(config, "log_file_mode") or "w" + self.log_file_handler = _FileHandler( + log_file, mode=self.log_file_mode, encoding="UTF-8" + ) log_file_format = get_option_ini(config, "log_file_format", "log_format") log_file_date_format = get_option_ini( config, "log_file_date_format", "log_date_format" @@ -660,11 +691,13 @@ def __init__(self, config: Config) -> None: ) if self._log_cli_enabled(): terminal_reporter = config.pluginmanager.get_plugin("terminalreporter") + # Guaranteed by `_log_cli_enabled()`. + assert terminal_reporter is not None capture_manager = config.pluginmanager.get_plugin("capturemanager") # if capturemanager plugin is disabled, live logging still works. - self.log_cli_handler: Union[ - _LiveLoggingStreamHandler, _LiveLoggingNullHandler - ] = _LiveLoggingStreamHandler(terminal_reporter, capture_manager) + self.log_cli_handler: ( + _LiveLoggingStreamHandler | _LiveLoggingNullHandler + ) = _LiveLoggingStreamHandler(terminal_reporter, capture_manager) else: self.log_cli_handler = _LiveLoggingNullHandler() log_cli_formatter = self._create_formatter( @@ -675,7 +708,7 @@ def __init__(self, config: Config) -> None: self.log_cli_handler.setFormatter(log_cli_formatter) self._disable_loggers(loggers_to_disable=config.option.logger_disable) - def _disable_loggers(self, loggers_to_disable: List[str]) -> None: + def _disable_loggers(self, loggers_to_disable: list[str]) -> None: if not loggers_to_disable: return @@ -718,12 +751,12 @@ def set_log_path(self, fname: str) -> None: fpath.parent.mkdir(exist_ok=True, parents=True) # https://github.com/python/mypy/issues/11193 - stream: io.TextIOWrapper = fpath.open(mode="w", encoding="UTF-8") # type: ignore[assignment] + stream: io.TextIOWrapper = fpath.open(mode=self.log_file_mode, encoding="UTF-8") # type: ignore[assignment] old_stream = self.log_file_handler.setStream(stream) if old_stream: old_stream.close() - def _log_cli_enabled(self): + def _log_cli_enabled(self) -> bool: """Return whether live logging is enabled.""" enabled = self._config.getoption( "--log-cli-level" @@ -738,35 +771,34 @@ def _log_cli_enabled(self): return True - @hookimpl(hookwrapper=True, tryfirst=True) - def pytest_sessionstart(self) -> Generator[None, None, None]: + @hookimpl(wrapper=True, tryfirst=True) + def pytest_sessionstart(self) -> Generator[None]: self.log_cli_handler.set_when("sessionstart") with catching_logs(self.log_cli_handler, level=self.log_cli_level): with catching_logs(self.log_file_handler, level=self.log_file_level): - yield + return (yield) - @hookimpl(hookwrapper=True, tryfirst=True) - def pytest_collection(self) -> Generator[None, None, None]: + @hookimpl(wrapper=True, tryfirst=True) + def pytest_collection(self) -> Generator[None]: self.log_cli_handler.set_when("collection") with catching_logs(self.log_cli_handler, level=self.log_cli_level): with catching_logs(self.log_file_handler, level=self.log_file_level): - yield + return (yield) - @hookimpl(hookwrapper=True) - def pytest_runtestloop(self, session: Session) -> Generator[None, None, None]: + @hookimpl(wrapper=True) + def pytest_runtestloop(self, session: Session) -> Generator[None, object, object]: if session.config.option.collectonly: - yield - return + return (yield) - if self._log_cli_enabled() and self._config.getoption("verbose") < 1: + if self._log_cli_enabled() and self._config.get_verbosity() < 1: # The verbose flag is needed to avoid messy test progress output. self._config.option.verbose = 1 with catching_logs(self.log_cli_handler, level=self.log_cli_level): with catching_logs(self.log_file_handler, level=self.log_file_level): - yield # Run all the tests. + return (yield) # Run all the tests. @hookimpl def pytest_runtest_logstart(self) -> None: @@ -777,58 +809,68 @@ def pytest_runtest_logstart(self) -> None: def pytest_runtest_logreport(self) -> None: self.log_cli_handler.set_when("logreport") - def _runtest_for(self, item: nodes.Item, when: str) -> Generator[None, None, None]: + @contextmanager + def _runtest_for(self, item: nodes.Item, when: str) -> Generator[None]: """Implement the internals of the pytest_runtest_xxx() hooks.""" - with catching_logs( - self.caplog_handler, - level=self.log_level, - ) as caplog_handler, catching_logs( - self.report_handler, - level=self.log_level, - ) as report_handler: + with ( + catching_logs( + self.caplog_handler, + level=self.log_level, + ) as caplog_handler, + catching_logs( + self.report_handler, + level=self.log_level, + ) as report_handler, + ): caplog_handler.reset() report_handler.reset() item.stash[caplog_records_key][when] = caplog_handler.records item.stash[caplog_handler_key] = caplog_handler - yield - - log = report_handler.stream.getvalue().strip() - item.add_report_section(when, "log", log) + try: + yield + finally: + log = report_handler.stream.getvalue().strip() + item.add_report_section(when, "log", log) - @hookimpl(hookwrapper=True) - def pytest_runtest_setup(self, item: nodes.Item) -> Generator[None, None, None]: + @hookimpl(wrapper=True) + def pytest_runtest_setup(self, item: nodes.Item) -> Generator[None]: self.log_cli_handler.set_when("setup") - empty: Dict[str, List[logging.LogRecord]] = {} + empty: dict[str, list[logging.LogRecord]] = {} item.stash[caplog_records_key] = empty - yield from self._runtest_for(item, "setup") + with self._runtest_for(item, "setup"): + yield - @hookimpl(hookwrapper=True) - def pytest_runtest_call(self, item: nodes.Item) -> Generator[None, None, None]: + @hookimpl(wrapper=True) + def pytest_runtest_call(self, item: nodes.Item) -> Generator[None]: self.log_cli_handler.set_when("call") - yield from self._runtest_for(item, "call") + with self._runtest_for(item, "call"): + yield - @hookimpl(hookwrapper=True) - def pytest_runtest_teardown(self, item: nodes.Item) -> Generator[None, None, None]: + @hookimpl(wrapper=True) + def pytest_runtest_teardown(self, item: nodes.Item) -> Generator[None]: self.log_cli_handler.set_when("teardown") - yield from self._runtest_for(item, "teardown") - del item.stash[caplog_records_key] - del item.stash[caplog_handler_key] + try: + with self._runtest_for(item, "teardown"): + yield + finally: + del item.stash[caplog_records_key] + del item.stash[caplog_handler_key] @hookimpl def pytest_runtest_logfinish(self) -> None: self.log_cli_handler.set_when("finish") - @hookimpl(hookwrapper=True, tryfirst=True) - def pytest_sessionfinish(self) -> Generator[None, None, None]: + @hookimpl(wrapper=True, tryfirst=True) + def pytest_sessionfinish(self) -> Generator[None]: self.log_cli_handler.set_when("sessionfinish") with catching_logs(self.log_cli_handler, level=self.log_cli_level): with catching_logs(self.log_file_handler, level=self.log_file_level): - yield + return (yield) @hookimpl def pytest_unconfigure(self) -> None: @@ -861,7 +903,7 @@ class _LiveLoggingStreamHandler(logging_StreamHandler): def __init__( self, terminal_reporter: TerminalReporter, - capture_manager: Optional[CaptureManager], + capture_manager: CaptureManager | None, ) -> None: super().__init__(stream=terminal_reporter) # type: ignore[arg-type] self.capture_manager = capture_manager @@ -873,7 +915,7 @@ def reset(self) -> None: """Reset the handler; should be called before the start of each test.""" self._first_record_emitted = False - def set_when(self, when: Optional[str]) -> None: + def set_when(self, when: str | None) -> None: """Prepare for the given test phase (setup/call/teardown).""" self._when = when self._section_name_shown = False diff --git a/src/_pytest/main.py b/src/_pytest/main.py index 155d4300e2c..dac084b553a 100644 --- a/src/_pytest/main.py +++ b/src/_pytest/main.py @@ -1,29 +1,31 @@ """Core implementation of the testing process: init, session, runtest loop.""" + +from __future__ import annotations + import argparse +from collections.abc import Callable +from collections.abc import Iterable +from collections.abc import Iterator +from collections.abc import Sequence +from collections.abc import Set as AbstractSet import dataclasses import fnmatch import functools import importlib +import importlib.util import os -import sys from pathlib import Path -from typing import Callable -from typing import Dict -from typing import FrozenSet -from typing import Iterator -from typing import List -from typing import Optional -from typing import Sequence -from typing import Set -from typing import Tuple -from typing import Type +import sys +from typing import final +from typing import Literal +from typing import overload from typing import TYPE_CHECKING -from typing import Union +import warnings + +import pluggy -import _pytest._code from _pytest import nodes -from _pytest.compat import final -from _pytest.compat import overload +import _pytest._code from _pytest.config import Config from _pytest.config import directory_arg from _pytest.config import ExitCode @@ -31,48 +33,29 @@ from _pytest.config import PytestPluginManager from _pytest.config import UsageError from _pytest.config.argparsing import Parser -from _pytest.fixtures import FixtureManager +from _pytest.config.compat import PathAwareHookProxy from _pytest.outcomes import exit from _pytest.pathlib import absolutepath from _pytest.pathlib import bestrelpath from _pytest.pathlib import fnmatch_ex -from _pytest.pathlib import visit +from _pytest.pathlib import safe_exists +from _pytest.pathlib import scandir from _pytest.reports import CollectReport from _pytest.reports import TestReport from _pytest.runner import collect_one_node from _pytest.runner import SetupState +from _pytest.warning_types import PytestWarning if TYPE_CHECKING: - from typing_extensions import Literal + from typing_extensions import Self + + from _pytest.fixtures import FixtureManager def pytest_addoption(parser: Parser) -> None: - parser.addini( - "norecursedirs", - "Directory patterns to avoid for recursion", - type="args", - default=[ - "*.egg", - ".*", - "_darcs", - "build", - "CVS", - "dist", - "node_modules", - "venv", - "{arch}", - ], - ) - parser.addini( - "testpaths", - "Directories to search for tests when no files or directories are given on the " - "command line", - type="args", - default=[], - ) group = parser.getgroup("general", "Running and selection options") - group._addoption( + group._addoption( # private to use reserved lower-case short option "-x", "--exitfirst", action="store_const", @@ -80,21 +63,7 @@ def pytest_addoption(parser: Parser) -> None: const=1, help="Exit instantly on first error or failed test", ) - group = parser.getgroup("pytest-warnings") group.addoption( - "-W", - "--pythonwarnings", - action="append", - help="Set which warnings to report, see -W option of Python itself", - ) - parser.addini( - "filterwarnings", - type="linelist", - help="Each line specifies a pattern for " - "warnings.filterwarnings. " - "Processed after -W/--pythonwarnings.", - ) - group._addoption( "--maxfail", metavar="num", action="store", @@ -103,46 +72,37 @@ def pytest_addoption(parser: Parser) -> None: default=0, help="Exit after first num failures or errors", ) - group._addoption( + group.addoption( "--strict-config", action="store_true", help="Any warnings encountered while parsing the `pytest` section of the " "configuration file raise errors", ) - group._addoption( + group.addoption( "--strict-markers", action="store_true", help="Markers not registered in the `markers` section of the configuration " "file raise errors", ) - group._addoption( + group.addoption( "--strict", action="store_true", help="(Deprecated) alias to --strict-markers", ) - group._addoption( - "-c", - "--config-file", - metavar="FILE", - type=str, - dest="inifilename", - help="Load configuration from `FILE` instead of trying to locate one of the " - "implicit configuration files.", - ) - group._addoption( - "--continue-on-collection-errors", - action="store_true", - default=False, - dest="continue_on_collection_errors", - help="Force test execution even if collection errors occur", + + group = parser.getgroup("pytest-warnings") + group.addoption( + "-W", + "--pythonwarnings", + action="append", + help="Set which warnings to report, see -W option of Python itself", ) - group._addoption( - "--rootdir", - action="store", - dest="rootdir", - help="Define root directory for tests. Can be relative path: 'root_dir', './root_dir', " - "'root_dir/another_dir/'; absolute path: '/home/user/root_dir'; path with variables: " - "'$HOME/root_dir'.", + parser.addini( + "filterwarnings", + type="linelist", + help="Each line specifies a pattern for " + "warnings.filterwarnings. " + "Processed after -W/--pythonwarnings.", ) group = parser.getgroup("collect", "collection") @@ -206,6 +166,13 @@ def pytest_addoption(parser: Parser) -> None: default=False, help="Don't ignore tests in a local virtualenv directory", ) + group.addoption( + "--continue-on-collection-errors", + action="store_true", + default=False, + dest="continue_on_collection_errors", + help="Force test execution even if collection errors occur", + ) group.addoption( "--import-mode", default="prepend", @@ -214,8 +181,60 @@ def pytest_addoption(parser: Parser) -> None: help="Prepend/append to sys.path when importing test modules and conftest " "files. Default: prepend.", ) + parser.addini( + "norecursedirs", + "Directory patterns to avoid for recursion", + type="args", + default=[ + "*.egg", + ".*", + "_darcs", + "build", + "CVS", + "dist", + "node_modules", + "venv", + "{arch}", + ], + ) + parser.addini( + "testpaths", + "Directories to search for tests when no files or directories are given on the " + "command line", + type="args", + default=[], + ) + parser.addini( + "collect_imported_tests", + "Whether to collect tests in imported modules outside `testpaths`", + type="bool", + default=True, + ) + parser.addini( + "consider_namespace_packages", + type="bool", + default=False, + help="Consider namespace packages when resolving module names during import", + ) group = parser.getgroup("debugconfig", "test session debugging and configuration") + group._addoption( # private to use reserved lower-case short option + "-c", + "--config-file", + metavar="FILE", + type=str, + dest="inifilename", + help="Load configuration from `FILE` instead of trying to locate one of the " + "implicit configuration files.", + ) + group.addoption( + "--rootdir", + action="store", + dest="rootdir", + help="Define root directory for tests. Can be relative path: 'root_dir', './root_dir', " + "'root_dir/another_dir/'; absolute path: '/home/user/root_dir'; path with variables: " + "'$HOME/root_dir'.", + ) group.addoption( "--basetemp", dest="basetemp", @@ -255,8 +274,8 @@ def is_ancestor(base: Path, query: Path) -> bool: def wrap_session( - config: Config, doit: Callable[[Config, "Session"], Optional[Union[int, ExitCode]]] -) -> Union[int, ExitCode]: + config: Config, doit: Callable[[Config, Session], int | ExitCode | None] +) -> int | ExitCode: """Skeleton command line program.""" session = Session.from_config(config) session.exitstatus = ExitCode.OK @@ -275,7 +294,7 @@ def wrap_session( session.exitstatus = ExitCode.TESTS_FAILED except (KeyboardInterrupt, exit.Exception): excinfo = _pytest._code.ExceptionInfo.from_current() - exitstatus: Union[int, ExitCode] = ExitCode.INTERRUPTED + exitstatus: int | ExitCode = ExitCode.INTERRUPTED if isinstance(excinfo.value, exit.Exception): if excinfo.value.returncode is not None: exitstatus = excinfo.value.returncode @@ -313,11 +332,11 @@ def wrap_session( return session.exitstatus -def pytest_cmdline_main(config: Config) -> Union[int, ExitCode]: +def pytest_cmdline_main(config: Config) -> int | ExitCode: return wrap_session(config, _main) -def _main(config: Config, session: "Session") -> Optional[Union[int, ExitCode]]: +def _main(config: Config, session: Session) -> int | ExitCode | None: """Default command line protocol for initialization, session, running tests and reporting.""" config.hook.pytest_collection(session=session) @@ -330,15 +349,14 @@ def _main(config: Config, session: "Session") -> Optional[Union[int, ExitCode]]: return None -def pytest_collection(session: "Session") -> None: +def pytest_collection(session: Session) -> None: session.perform_collect() -def pytest_runtestloop(session: "Session") -> bool: +def pytest_runtestloop(session: Session) -> bool: if session.testsfailed and not session.config.option.continue_on_collection_errors: raise session.Interrupted( - "%d error%s during collection" - % (session.testsfailed, "s" if session.testsfailed != 1 else "") + f"{session.testsfailed} error{'s' if session.testsfailed != 1 else ''} during collection" ) if session.config.option.collectonly: @@ -356,27 +374,31 @@ def pytest_runtestloop(session: "Session") -> bool: def _in_venv(path: Path) -> bool: """Attempt to detect if ``path`` is the root of a Virtual Environment by - checking for the existence of the appropriate activate script.""" - bindir = path.joinpath("Scripts" if sys.platform.startswith("win") else "bin") + checking for the existence of the pyvenv.cfg file. + + [https://peps.python.org/pep-0405/] + + For regression protection we also check for conda environments that do not include pyenv.cfg yet -- + https://github.com/conda/conda/issues/13337 is the conda issue tracking adding pyenv.cfg. + + Checking for the `conda-meta/history` file per https://github.com/pytest-dev/pytest/issues/12652#issuecomment-2246336902. + + """ try: - if not bindir.is_dir(): - return False + return ( + path.joinpath("pyvenv.cfg").is_file() + or path.joinpath("conda-meta", "history").is_file() + ) except OSError: return False - activates = ( - "activate", - "activate.csh", - "activate.fish", - "Activate", - "Activate.bat", - "Activate.ps1", - ) - return any(fname.name in activates for fname in bindir.iterdir()) -def pytest_ignore_collect(collection_path: Path, config: Config) -> Optional[bool]: +def pytest_ignore_collect(collection_path: Path, config: Config) -> bool | None: + if collection_path.name == "__pycache__": + return True + ignore_paths = config._getconftest_pathlist( - "collect_ignore", path=collection_path.parent, rootpath=config.rootpath + "collect_ignore", path=collection_path.parent ) ignore_paths = ignore_paths or [] excludeopt = config.getoption("ignore") @@ -387,7 +409,7 @@ def pytest_ignore_collect(collection_path: Path, config: Config) -> Optional[boo return True ignore_globs = config._getconftest_pathlist( - "collect_ignore_glob", path=collection_path.parent, rootpath=config.rootpath + "collect_ignore_glob", path=collection_path.parent ) ignore_globs = ignore_globs or [] excludeglobopt = config.getoption("ignore_glob") @@ -409,7 +431,13 @@ def pytest_ignore_collect(collection_path: Path, config: Config) -> Optional[boo return None -def pytest_collection_modifyitems(items: List[nodes.Item], config: Config) -> None: +def pytest_collect_directory( + path: Path, parent: nodes.Collector +) -> nodes.Collector | None: + return Dir.from_parent(parent, path=path) + + +def pytest_collection_modifyitems(items: list[nodes.Item], config: Config) -> None: deselect_prefixes = tuple(config.getoption("deselect") or []) if not deselect_prefixes: return @@ -428,11 +456,15 @@ def pytest_collection_modifyitems(items: List[nodes.Item], config: Config) -> No class FSHookProxy: - def __init__(self, pm: PytestPluginManager, remove_mods) -> None: + def __init__( + self, + pm: PytestPluginManager, + remove_mods: AbstractSet[object], + ) -> None: self.pm = pm self.remove_mods = remove_mods - def __getattr__(self, name: str): + def __getattr__(self, name: str) -> pluggy.HookCaller: x = self.pm.subset_hook_caller(name, remove_plugins=self.remove_mods) self.__dict__[name] = x return x @@ -449,7 +481,7 @@ class Failed(Exception): @dataclasses.dataclass -class _bestrelpath_cache(Dict[Path, str]): +class _bestrelpath_cache(dict[Path, str]): __slots__ = ("path",) path: Path @@ -461,17 +493,75 @@ def __missing__(self, path: Path) -> str: @final -class Session(nodes.FSCollector): +class Dir(nodes.Directory): + """Collector of files in a file system directory. + + .. versionadded:: 8.0 + + .. note:: + + Python directories with an `__init__.py` file are instead collected by + :class:`~pytest.Package` by default. Both are :class:`~pytest.Directory` + collectors. + """ + + @classmethod + def from_parent( # type: ignore[override] + cls, + parent: nodes.Collector, + *, + path: Path, + ) -> Self: + """The public constructor. + + :param parent: The parent collector of this Dir. + :param path: The directory's path. + :type path: pathlib.Path + """ + return super().from_parent(parent=parent, path=path) + + def collect(self) -> Iterable[nodes.Item | nodes.Collector]: + config = self.config + col: nodes.Collector | None + cols: Sequence[nodes.Collector] + ihook = self.ihook + for direntry in scandir(self.path): + if direntry.is_dir(): + path = Path(direntry.path) + if not self.session.isinitpath(path, with_parents=True): + if ihook.pytest_ignore_collect(collection_path=path, config=config): + continue + col = ihook.pytest_collect_directory(path=path, parent=self) + if col is not None: + yield col + + elif direntry.is_file(): + path = Path(direntry.path) + if not self.session.isinitpath(path): + if ihook.pytest_ignore_collect(collection_path=path, config=config): + continue + cols = ihook.pytest_collect_file(file_path=path, parent=self) + yield from cols + + +@final +class Session(nodes.Collector): + """The root of the collection tree. + + ``Session`` collects the initial paths given as arguments to pytest. + """ + Interrupted = Interrupted Failed = Failed # Set on the session by runner.pytest_sessionstart. _setupstate: SetupState # Set on the session by fixtures.pytest_sessionstart. _fixturemanager: FixtureManager - exitstatus: Union[int, ExitCode] + exitstatus: int | ExitCode def __init__(self, config: Config) -> None: super().__init__( + name="", path=config.rootpath, fspath=None, parent=None, @@ -481,28 +571,68 @@ def __init__(self, config: Config) -> None: ) self.testsfailed = 0 self.testscollected = 0 - self.shouldstop: Union[bool, str] = False - self.shouldfail: Union[bool, str] = False + self._shouldstop: bool | str = False + self._shouldfail: bool | str = False self.trace = config.trace.root.get("collection") - self._initialpaths: FrozenSet[Path] = frozenset() + self._initialpaths: frozenset[Path] = frozenset() + self._initialpaths_with_parents: frozenset[Path] = frozenset() + self._notfound: list[tuple[str, Sequence[nodes.Collector]]] = [] + self._initial_parts: list[CollectionArgument] = [] + self._collection_cache: dict[nodes.Collector, CollectReport] = {} + self.items: list[nodes.Item] = [] - self._bestrelpathcache: Dict[Path, str] = _bestrelpath_cache(config.rootpath) + self._bestrelpathcache: dict[Path, str] = _bestrelpath_cache(config.rootpath) self.config.pluginmanager.register(self, name="session") @classmethod - def from_config(cls, config: Config) -> "Session": + def from_config(cls, config: Config) -> Session: session: Session = cls._create(config=config) return session def __repr__(self) -> str: - return "<%s %s exitstatus=%r testsfailed=%d testscollected=%d>" % ( - self.__class__.__name__, - self.name, - getattr(self, "exitstatus", ""), - self.testsfailed, - self.testscollected, - ) + return ( + f"<{self.__class__.__name__} {self.name} " + f"exitstatus=%r " + f"testsfailed={self.testsfailed} " + f"testscollected={self.testscollected}>" + ) % getattr(self, "exitstatus", "") + + @property + def shouldstop(self) -> bool | str: + return self._shouldstop + + @shouldstop.setter + def shouldstop(self, value: bool | str) -> None: + # The runner checks shouldfail and assumes that if it is set we are + # definitely stopping, so prevent unsetting it. + if value is False and self._shouldstop: + warnings.warn( + PytestWarning( + "session.shouldstop cannot be unset after it has been set; ignoring." + ), + stacklevel=2, + ) + return + self._shouldstop = value + + @property + def shouldfail(self) -> bool | str: + return self._shouldfail + + @shouldfail.setter + def shouldfail(self, value: bool | str) -> None: + # The runner checks shouldfail and assumes that if it is set we are + # definitely stopping, so prevent unsetting it. + if value is False and self._shouldfail: + warnings.warn( + PytestWarning( + "session.shouldfail cannot be unset after it has been set; ignoring." + ), + stacklevel=2, + ) + return + self._shouldfail = value @property def startpath(self) -> Path: @@ -524,92 +654,100 @@ def pytest_collectstart(self) -> None: raise self.Interrupted(self.shouldstop) @hookimpl(tryfirst=True) - def pytest_runtest_logreport( - self, report: Union[TestReport, CollectReport] - ) -> None: + def pytest_runtest_logreport(self, report: TestReport | CollectReport) -> None: if report.failed and not hasattr(report, "wasxfail"): self.testsfailed += 1 maxfail = self.config.getvalue("maxfail") if maxfail and self.testsfailed >= maxfail: - self.shouldfail = "stopping after %d failures" % (self.testsfailed) + self.shouldfail = f"stopping after {self.testsfailed} failures" pytest_collectreport = pytest_runtest_logreport - def isinitpath(self, path: Union[str, "os.PathLike[str]"]) -> bool: + def isinitpath( + self, + path: str | os.PathLike[str], + *, + with_parents: bool = False, + ) -> bool: + """Is path an initial path? + + An initial path is a path explicitly given to pytest on the command + line. + + :param with_parents: + If set, also return True if the path is a parent of an initial path. + + .. versionchanged:: 8.0 + Added the ``with_parents`` parameter. + """ # Optimization: Path(Path(...)) is much slower than isinstance. path_ = path if isinstance(path, Path) else Path(path) - return path_ in self._initialpaths + if with_parents: + return path_ in self._initialpaths_with_parents + else: + return path_ in self._initialpaths - def gethookproxy(self, fspath: "os.PathLike[str]"): + def gethookproxy(self, fspath: os.PathLike[str]) -> pluggy.HookRelay: # Optimization: Path(Path(...)) is much slower than isinstance. path = fspath if isinstance(fspath, Path) else Path(fspath) pm = self.config.pluginmanager # Check if we have the common case of running # hooks with all conftest.py files. - my_conftestmodules = pm._getconftestmodules( - path, - self.config.getoption("importmode"), - rootpath=self.config.rootpath, - ) + my_conftestmodules = pm._getconftestmodules(path) remove_mods = pm._conftest_plugins.difference(my_conftestmodules) + proxy: pluggy.HookRelay if remove_mods: - # One or more conftests are not in use at this fspath. - from .config.compat import PathAwareHookProxy - - proxy = PathAwareHookProxy(FSHookProxy(pm, remove_mods)) + # One or more conftests are not in use at this path. + proxy = PathAwareHookProxy(FSHookProxy(pm, remove_mods)) # type: ignore[arg-type,assignment] else: # All plugins are active for this fspath. proxy = self.config.hook return proxy - def _recurse(self, direntry: "os.DirEntry[str]") -> bool: - if direntry.name == "__pycache__": - return False - fspath = Path(direntry.path) - ihook = self.gethookproxy(fspath.parent) - if ihook.pytest_ignore_collect(collection_path=fspath, config=self.config): - return False - return True - - def _collectfile( - self, fspath: Path, handle_dupes: bool = True + def _collect_path( + self, + path: Path, + path_cache: dict[Path, Sequence[nodes.Collector]], ) -> Sequence[nodes.Collector]: - assert ( - fspath.is_file() - ), "{!r} is not a file (isdir={!r}, exists={!r}, islink={!r})".format( - fspath, fspath.is_dir(), fspath.exists(), fspath.is_symlink() - ) - ihook = self.gethookproxy(fspath) - if not self.isinitpath(fspath): - if ihook.pytest_ignore_collect(collection_path=fspath, config=self.config): - return () + """Create a Collector for the given path. - if handle_dupes: - keepduplicates = self.config.getoption("keepduplicates") - if not keepduplicates: - duplicate_paths = self.config.pluginmanager._duplicatepaths - if fspath in duplicate_paths: - return () - else: - duplicate_paths.add(fspath) + `path_cache` makes it so the same Collectors are returned for the same + path. + """ + if path in path_cache: + return path_cache[path] - return ihook.pytest_collect_file(file_path=fspath, parent=self) # type: ignore[no-any-return] + if path.is_dir(): + ihook = self.gethookproxy(path.parent) + col: nodes.Collector | None = ihook.pytest_collect_directory( + path=path, parent=self + ) + cols: Sequence[nodes.Collector] = (col,) if col is not None else () + + elif path.is_file(): + ihook = self.gethookproxy(path) + cols = ihook.pytest_collect_file(file_path=path, parent=self) + + else: + # Broken symlink or invalid/missing file. + cols = () + + path_cache[path] = cols + return cols @overload def perform_collect( - self, args: Optional[Sequence[str]] = ..., genitems: "Literal[True]" = ... - ) -> Sequence[nodes.Item]: - ... + self, args: Sequence[str] | None = ..., genitems: Literal[True] = ... + ) -> Sequence[nodes.Item]: ... @overload - def perform_collect( # noqa: F811 - self, args: Optional[Sequence[str]] = ..., genitems: bool = ... - ) -> Sequence[Union[nodes.Item, nodes.Collector]]: - ... - - def perform_collect( # noqa: F811 - self, args: Optional[Sequence[str]] = None, genitems: bool = True - ) -> Sequence[Union[nodes.Item, nodes.Collector]]: + def perform_collect( + self, args: Sequence[str] | None = ..., genitems: bool = ... + ) -> Sequence[nodes.Item | nodes.Collector]: ... + + def perform_collect( + self, args: Sequence[str] | None = None, genitems: bool = True + ) -> Sequence[nodes.Item | nodes.Collector]: """Perform the collection phase for this session. This is called by the default :hook:`pytest_collection` hook @@ -629,24 +767,29 @@ def perform_collect( # noqa: F811 self.trace("perform_collect", self, args) self.trace.root.indent += 1 - self._notfound: List[Tuple[str, Sequence[nodes.Collector]]] = [] - self._initial_parts: List[Tuple[Path, List[str]]] = [] - self.items: List[nodes.Item] = [] - hook = self.config.hook - items: Sequence[Union[nodes.Item, nodes.Collector]] = self.items + self._notfound = [] + self._initial_parts = [] + self._collection_cache = {} + self.items = [] + items: Sequence[nodes.Item | nodes.Collector] = self.items try: - initialpaths: List[Path] = [] + initialpaths: list[Path] = [] + initialpaths_with_parents: list[Path] = [] for arg in args: - fspath, parts = resolve_collection_argument( + collection_argument = resolve_collection_argument( self.config.invocation_params.dir, arg, as_pypath=self.config.option.pyargs, ) - self._initial_parts.append((fspath, parts)) - initialpaths.append(fspath) + self._initial_parts.append(collection_argument) + initialpaths.append(collection_argument.path) + initialpaths_with_parents.append(collection_argument.path) + initialpaths_with_parents.extend(collection_argument.path.parents) self._initialpaths = frozenset(initialpaths) + self._initialpaths_with_parents = frozenset(initialpaths_with_parents) + rep = collect_one_node(self) self.ihook.pytest_collectreport(report=rep) self.trace.root.indent -= 1 @@ -655,12 +798,13 @@ def perform_collect( # noqa: F811 for arg, collectors in self._notfound: if collectors: errors.append( - f"not found: {arg}\n(no name {arg!r} in any of {collectors!r})" + f"not found: {arg}\n(no match in any of {collectors!r})" ) else: errors.append(f"found no collectors for {arg}") raise UsageError(*errors) + if not genitems: items = rep.result else: @@ -673,193 +817,200 @@ def perform_collect( # noqa: F811 session=self, config=self.config, items=items ) finally: + self._notfound = [] + self._initial_parts = [] + self._collection_cache = {} hook.pytest_collection_finish(session=self) - self.testscollected = len(items) - return items + if genitems: + self.testscollected = len(items) - def collect(self) -> Iterator[Union[nodes.Item, nodes.Collector]]: - from _pytest.python import Package + return items - # Keep track of any collected nodes in here, so we don't duplicate fixtures. - node_cache1: Dict[Path, Sequence[nodes.Collector]] = {} - node_cache2: Dict[Tuple[Type[nodes.Collector], Path], nodes.Collector] = {} + def _collect_one_node( + self, + node: nodes.Collector, + handle_dupes: bool = True, + ) -> tuple[CollectReport, bool]: + if node in self._collection_cache and handle_dupes: + rep = self._collection_cache[node] + return rep, True + else: + rep = collect_one_node(node) + self._collection_cache[node] = rep + return rep, False - # Keep track of any collected collectors in matchnodes paths, so they - # are not collected more than once. - matchnodes_cache: Dict[Tuple[Type[nodes.Collector], str], CollectReport] = {} + def collect(self) -> Iterator[nodes.Item | nodes.Collector]: + # This is a cache for the root directories of the initial paths. + # We can't use collection_cache for Session because of its special + # role as the bootstrapping collector. + path_cache: dict[Path, Sequence[nodes.Collector]] = {} - # Directories of pkgs with dunder-init files. - pkg_roots: Dict[Path, Package] = {} + pm = self.config.pluginmanager - for argpath, names in self._initial_parts: - self.trace("processing argument", (argpath, names)) + for collection_argument in self._initial_parts: + self.trace("processing argument", collection_argument) self.trace.root.indent += 1 - # Start with a Session root, and delve to argpath item (dir or file) - # and stack all Packages found on the way. - # No point in finding packages when collecting doctests. - if not self.config.getoption("doctestmodules", False): - pm = self.config.pluginmanager - for parent in (argpath, *argpath.parents): - if not pm._is_in_confcutdir(argpath): - break + argpath = collection_argument.path + names = collection_argument.parts + module_name = collection_argument.module_name - if parent.is_dir(): - pkginit = parent / "__init__.py" - if pkginit.is_file() and pkginit not in node_cache1: - col = self._collectfile(pkginit, handle_dupes=False) - if col: - if isinstance(col[0], Package): - pkg_roots[parent] = col[0] - node_cache1[col[0].path] = [col[0]] - - # If it's a directory argument, recurse and look for any Subpackages. - # Let the Package collector deal with subnodes, don't collect here. + # resolve_collection_argument() ensures this. if argpath.is_dir(): assert not names, f"invalid arg {(argpath, names)!r}" - seen_dirs: Set[Path] = set() - for direntry in visit(argpath, self._recurse): - if not direntry.is_file(): - continue - - path = Path(direntry.path) - dirpath = path.parent - - if dirpath not in seen_dirs: - # Collect packages first. - seen_dirs.add(dirpath) - pkginit = dirpath / "__init__.py" - if pkginit.exists(): - for x in self._collectfile(pkginit): - yield x - if isinstance(x, Package): - pkg_roots[dirpath] = x - if dirpath in pkg_roots: - # Do not collect packages here. - continue - - for x in self._collectfile(path): - key2 = (type(x), x.path) - if key2 in node_cache2: - yield node_cache2[key2] - else: - node_cache2[key2] = x - yield x + paths = [argpath] + # Add relevant parents of the path, from the root, e.g. + # /a/b/c.py -> [/, /a, /a/b, /a/b/c.py] + if module_name is None: + # Paths outside of the confcutdir should not be considered. + for path in argpath.parents: + if not pm._is_in_confcutdir(path): + break + paths.insert(0, path) else: - assert argpath.is_file() - - if argpath in node_cache1: - col = node_cache1[argpath] - else: - collect_root = pkg_roots.get(argpath.parent, self) - col = collect_root._collectfile(argpath, handle_dupes=False) - if col: - node_cache1[argpath] = col - - matching = [] - work: List[ - Tuple[Sequence[Union[nodes.Item, nodes.Collector]], Sequence[str]] - ] = [(col, names)] - while work: - self.trace("matchnodes", col, names) - self.trace.root.indent += 1 - - matchnodes, matchnames = work.pop() - for node in matchnodes: - if not matchnames: - matching.append(node) - continue - if not isinstance(node, nodes.Collector): - continue - key = (type(node), node.nodeid) - if key in matchnodes_cache: - rep = matchnodes_cache[key] - else: - rep = collect_one_node(node) - matchnodes_cache[key] = rep - if rep.passed: - submatchnodes = [] - for r in rep.result: - # TODO: Remove parametrized workaround once collection structure contains - # parametrization. - if ( - r.name == matchnames[0] - or r.name.split("[")[0] == matchnames[0] - ): - submatchnodes.append(r) - if submatchnodes: - work.append((submatchnodes, matchnames[1:])) - else: - # Report collection failures here to avoid failing to run some test - # specified in the command line because the module could not be - # imported (#134). - node.ihook.pytest_collectreport(report=rep) - - self.trace("matchnodes finished -> ", len(matching), "nodes") - self.trace.root.indent -= 1 - - if not matching: - report_arg = "::".join((str(argpath), *names)) - self._notfound.append((report_arg, col)) + # For --pyargs arguments, only consider paths matching the module + # name. Paths beyond the package hierarchy are not included. + module_name_parts = module_name.split(".") + for i, path in enumerate(argpath.parents, 2): + if i > len(module_name_parts) or path.stem != module_name_parts[-i]: + break + paths.insert(0, path) + + # Start going over the parts from the root, collecting each level + # and discarding all nodes which don't match the level's part. + any_matched_in_initial_part = False + notfound_collectors = [] + work: list[tuple[nodes.Collector | nodes.Item, list[Path | str]]] = [ + (self, [*paths, *names]) + ] + while work: + matchnode, matchparts = work.pop() + + # Pop'd all of the parts, this is a match. + if not matchparts: + yield matchnode + any_matched_in_initial_part = True continue - # If __init__.py was the only file requested, then the matched - # node will be the corresponding Package (by default), and the - # first yielded item will be the __init__ Module itself, so - # just use that. If this special case isn't taken, then all the - # files in the package will be yielded. - if argpath.name == "__init__.py" and isinstance(matching[0], Package): - try: - yield next(iter(matching[0].collect())) - except StopIteration: - # The package collects nothing with only an __init__.py - # file in it, which gets ignored by the default - # "python_files" option. - pass + # Should have been matched by now, discard. + if not isinstance(matchnode, nodes.Collector): continue - yield from matching + # Collect this level of matching. + # Collecting Session (self) is done directly to avoid endless + # recursion to this function. + subnodes: Sequence[nodes.Collector | nodes.Item] + if isinstance(matchnode, Session): + assert isinstance(matchparts[0], Path) + subnodes = matchnode._collect_path(matchparts[0], path_cache) + else: + # For backward compat, files given directly multiple + # times on the command line should not be deduplicated. + handle_dupes = not ( + len(matchparts) == 1 + and isinstance(matchparts[0], Path) + and matchparts[0].is_file() + ) + rep, duplicate = self._collect_one_node(matchnode, handle_dupes) + if not duplicate and not rep.passed: + # Report collection failures here to avoid failing to + # run some test specified in the command line because + # the module could not be imported (#134). + matchnode.ihook.pytest_collectreport(report=rep) + if not rep.passed: + continue + subnodes = rep.result + + # Prune this level. + any_matched_in_collector = False + for node in reversed(subnodes): + # Path part e.g. `/a/b/` in `/a/b/test_file.py::TestIt::test_it`. + if isinstance(matchparts[0], Path): + is_match = node.path == matchparts[0] + if sys.platform == "win32" and not is_match: + # In case the file paths do not match, fallback to samefile() to + # account for short-paths on Windows (#11895). + same_file = os.path.samefile(node.path, matchparts[0]) + # We don't want to match links to the current node, + # otherwise we would match the same file more than once (#12039). + is_match = same_file and ( + os.path.islink(node.path) + == os.path.islink(matchparts[0]) + ) + + # Name part e.g. `TestIt` in `/a/b/test_file.py::TestIt::test_it`. + else: + # TODO: Remove parametrized workaround once collection structure contains + # parametrization. + is_match = ( + node.name == matchparts[0] + or node.name.split("[")[0] == matchparts[0] + ) + if is_match: + work.append((node, matchparts[1:])) + any_matched_in_collector = True + + if not any_matched_in_collector: + notfound_collectors.append(matchnode) + + if not any_matched_in_initial_part: + report_arg = "::".join((str(argpath), *names)) + self._notfound.append((report_arg, notfound_collectors)) self.trace.root.indent -= 1 - def genitems( - self, node: Union[nodes.Item, nodes.Collector] - ) -> Iterator[nodes.Item]: + def genitems(self, node: nodes.Item | nodes.Collector) -> Iterator[nodes.Item]: self.trace("genitems", node) if isinstance(node, nodes.Item): node.ihook.pytest_itemcollected(item=node) yield node else: assert isinstance(node, nodes.Collector) - rep = collect_one_node(node) + keepduplicates = self.config.getoption("keepduplicates") + # For backward compat, dedup only applies to files. + handle_dupes = not (keepduplicates and isinstance(node, nodes.File)) + rep, duplicate = self._collect_one_node(node, handle_dupes) + if duplicate and not keepduplicates: + return if rep.passed: for subnode in rep.result: yield from self.genitems(subnode) - node.ihook.pytest_collectreport(report=rep) + if not duplicate: + node.ihook.pytest_collectreport(report=rep) -def search_pypath(module_name: str) -> str: - """Search sys.path for the given a dotted module name, and return its file system path.""" +def search_pypath(module_name: str) -> str | None: + """Search sys.path for the given a dotted module name, and return its file + system path if found.""" try: spec = importlib.util.find_spec(module_name) # AttributeError: looks like package module, but actually filename # ImportError: module does not exist # ValueError: not a module name except (AttributeError, ImportError, ValueError): - return module_name + return None if spec is None or spec.origin is None or spec.origin == "namespace": - return module_name + return None elif spec.submodule_search_locations: return os.path.dirname(spec.origin) else: return spec.origin +@dataclasses.dataclass(frozen=True) +class CollectionArgument: + """A resolved collection argument.""" + + path: Path + parts: Sequence[str] + module_name: str | None + + def resolve_collection_argument( invocation_path: Path, arg: str, *, as_pypath: bool = False -) -> Tuple[Path, List[str]]: +) -> CollectionArgument: """Parse path arguments optionally containing selection parts and return (fspath, names). Command-line arguments can point to files and/or directories, and optionally contain @@ -867,9 +1018,13 @@ def resolve_collection_argument( "pkg/tests/test_foo.py::TestClass::test_foo" - This function ensures the path exists, and returns a tuple: + This function ensures the path exists, and returns a resolved `CollectionArgument`: - (Path("/full/path/to/pkg/tests/test_foo.py"), ["TestClass", "test_foo"]) + CollectionArgument( + path=Path("/full/path/to/pkg/tests/test_foo.py"), + parts=["TestClass", "test_foo"], + module_name=None, + ) When as_pypath is True, expects that the command-line argument actually contains module paths instead of file-system paths: @@ -877,7 +1032,13 @@ def resolve_collection_argument( "pkg.tests.test_foo::TestClass::test_foo" In which case we search sys.path for a matching module, and then return the *path* to the - found module. + found module, which may look like this: + + CollectionArgument( + path=Path("/home/u/myvenv/lib/site-packages/pkg/tests/test_foo.py"), + parts=["TestClass", "test_foo"], + module_name="pkg.tests.test_foo", + ) If the path doesn't exist, raise UsageError. If the path is a directory and selection parts are present, raise UsageError. @@ -886,11 +1047,15 @@ def resolve_collection_argument( strpath, *parts = base.split("::") if parts: parts[-1] = f"{parts[-1]}{squacket}{rest}" + module_name = None if as_pypath: - strpath = search_pypath(strpath) + pyarg_strpath = search_pypath(strpath) + if pyarg_strpath is not None: + module_name = strpath + strpath = pyarg_strpath fspath = invocation_path / strpath fspath = absolutepath(fspath) - if not fspath.exists(): + if not safe_exists(fspath): msg = ( "module or package not found: {arg} (missing __init__.py?)" if as_pypath @@ -904,4 +1069,8 @@ def resolve_collection_argument( else "directory argument cannot contain :: selection parts: {arg}" ) raise UsageError(msg.format(arg=arg)) - return fspath, parts + return CollectionArgument( + path=fspath, + parts=parts, + module_name=module_name, + ) diff --git a/src/_pytest/mark/__init__.py b/src/_pytest/mark/__init__.py index de46b4c8a75..068c7410a46 100644 --- a/src/_pytest/mark/__init__.py +++ b/src/_pytest/mark/__init__.py @@ -1,16 +1,21 @@ """Generic mechanism for marking and selecting python functions.""" + +from __future__ import annotations + +import collections +from collections.abc import Collection +from collections.abc import Iterable +from collections.abc import Set as AbstractSet import dataclasses -from typing import AbstractSet -from typing import Collection -from typing import List from typing import Optional from typing import TYPE_CHECKING -from typing import Union from .expression import Expression from .expression import ParseError +from .structures import _HiddenParam from .structures import EMPTY_PARAMETERSET_OPTION from .structures import get_empty_parameterset_mark +from .structures import HIDDEN_PARAM from .structures import Mark from .structures import MARK_GEN from .structures import MarkDecorator @@ -20,14 +25,17 @@ from _pytest.config import ExitCode from _pytest.config import hookimpl from _pytest.config import UsageError +from _pytest.config.argparsing import NOT_SET from _pytest.config.argparsing import Parser from _pytest.stash import StashKey + if TYPE_CHECKING: from _pytest.nodes import Item __all__ = [ + "HIDDEN_PARAM", "MARK_GEN", "Mark", "MarkDecorator", @@ -42,8 +50,8 @@ def param( *values: object, - marks: Union[MarkDecorator, Collection[Union[MarkDecorator, Mark]]] = (), - id: Optional[str] = None, + marks: MarkDecorator | Collection[MarkDecorator | Mark] = (), + id: str | _HiddenParam | None = None, ) -> ParameterSet: """Specify a parameter in `pytest.mark.parametrize`_ calls or :ref:`parametrized fixtures `. @@ -61,22 +69,34 @@ def test_eval(test_input, expected): assert eval(test_input) == expected :param values: Variable args of the values of the parameter set, in order. - :param marks: A single mark or a list of marks to be applied to this parameter set. - :param id: The id to attribute to this parameter set. + + :param marks: + A single mark or a list of marks to be applied to this parameter set. + + :ref:`pytest.mark.usefixtures ` cannot be added via this parameter. + + :type id: str | Literal[pytest.HIDDEN_PARAM] | None + :param id: + The id to attribute to this parameter set. + + .. versionadded:: 8.4 + :ref:`hidden-param` means to hide the parameter set + from the test name. Can only be used at most 1 time, as + test names need to be unique. """ return ParameterSet.param(*values, marks=marks, id=id) def pytest_addoption(parser: Parser) -> None: group = parser.getgroup("general") - group._addoption( + group._addoption( # private to use reserved lower-case short option "-k", action="store", dest="keyword", default="", metavar="EXPRESSION", help="Only run tests which match the given substring expression. " - "An expression is a Python evaluatable expression " + "An expression is a Python evaluable expression " "where all names are substring-matched against test names " "and their parent classes. Example: -k 'test_method or test_" "other' matches all test functions and classes whose name " @@ -89,7 +109,7 @@ def pytest_addoption(parser: Parser) -> None: "The matching is case-insensitive.", ) - group._addoption( + group._addoption( # private to use reserved lower-case short option "-m", action="store", dest="markexpr", @@ -105,12 +125,12 @@ def pytest_addoption(parser: Parser) -> None: help="show markers (builtin, plugin and per-project ones).", ) - parser.addini("markers", "Markers for test functions", "linelist") + parser.addini("markers", "Register new markers for test functions", "linelist") parser.addini(EMPTY_PARAMETERSET_OPTION, "Default marker for empty parametersets") @hookimpl(tryfirst=True) -def pytest_cmdline_main(config: Config) -> Optional[Union[int, ExitCode]]: +def pytest_cmdline_main(config: Config) -> int | ExitCode | None: import _pytest.config if config.option.markers: @@ -120,7 +140,7 @@ def pytest_cmdline_main(config: Config) -> Optional[Union[int, ExitCode]]: parts = line.split(":", 1) name = parts[0] rest = parts[1] if len(parts) == 2 else "" - tw.write("@pytest.mark.%s:" % name, bold=True) + tw.write(f"@pytest.mark.{name}:", bold=True) tw.line(rest) tw.line() config._ensure_unconfigure() @@ -149,15 +169,22 @@ class KeywordMatcher: _names: AbstractSet[str] @classmethod - def from_item(cls, item: "Item") -> "KeywordMatcher": + def from_item(cls, item: Item) -> KeywordMatcher: mapped_names = set() - # Add the names of the current item and any parent items. + # Add the names of the current item and any parent items, + # except the Session and root Directory's which are not + # interesting for matching. import pytest for node in item.listchain(): - if not isinstance(node, pytest.Session): - mapped_names.add(node.name) + if isinstance(node, pytest.Session): + continue + if isinstance(node, pytest.Directory) and isinstance( + node.parent, pytest.Session + ): + continue + mapped_names.add(node.name) # Add the names added as extra keywords to current or parent items. mapped_names.update(item.listextrakeywords()) @@ -172,17 +199,14 @@ def from_item(cls, item: "Item") -> "KeywordMatcher": return cls(mapped_names) - def __call__(self, subname: str) -> bool: + def __call__(self, subname: str, /, **kwargs: str | int | bool | None) -> bool: + if kwargs: + raise UsageError("Keyword expressions do not support call parameters.") subname = subname.lower() - names = (name.lower() for name in self._names) - - for name in names: - if subname in name: - return True - return False + return any(subname in name.lower() for name in self._names) -def deselect_by_keyword(items: "List[Item]", config: Config) -> None: +def deselect_by_keyword(items: list[Item], config: Config) -> None: keywordexpr = config.option.keyword.lstrip() if not keywordexpr: return @@ -209,29 +233,37 @@ class MarkMatcher: Tries to match on any marker names, attached to the given colitem. """ - __slots__ = ("own_mark_names",) + __slots__ = ("own_mark_name_mapping",) - own_mark_names: AbstractSet[str] + own_mark_name_mapping: dict[str, list[Mark]] @classmethod - def from_item(cls, item: "Item") -> "MarkMatcher": - mark_names = {mark.name for mark in item.iter_markers()} - return cls(mark_names) - - def __call__(self, name: str) -> bool: - return name in self.own_mark_names + def from_markers(cls, markers: Iterable[Mark]) -> MarkMatcher: + mark_name_mapping = collections.defaultdict(list) + for mark in markers: + mark_name_mapping[mark.name].append(mark) + return cls(mark_name_mapping) + + def __call__(self, name: str, /, **kwargs: str | int | bool | None) -> bool: + if not (matches := self.own_mark_name_mapping.get(name, [])): + return False + + for mark in matches: # pylint: disable=consider-using-any-or-all + if all(mark.kwargs.get(k, NOT_SET) == v for k, v in kwargs.items()): + return True + return False -def deselect_by_mark(items: "List[Item]", config: Config) -> None: +def deselect_by_mark(items: list[Item], config: Config) -> None: matchexpr = config.option.markexpr if not matchexpr: return expr = _parse_expression(matchexpr, "Wrong expression passed to '-m'") - remaining: List[Item] = [] - deselected: List[Item] = [] + remaining: list[Item] = [] + deselected: list[Item] = [] for item in items: - if expr.evaluate(MarkMatcher.from_item(item)): + if expr.evaluate(MarkMatcher.from_markers(item.iter_markers())): remaining.append(item) else: deselected.append(item) @@ -247,7 +279,7 @@ def _parse_expression(expr: str, exc_message: str) -> Expression: raise UsageError(f"{exc_message}: {expr}: {e}") from None -def pytest_collection_modifyitems(items: "List[Item]", config: Config) -> None: +def pytest_collection_modifyitems(items: list[Item], config: Config) -> None: deselect_by_keyword(items, config) deselect_by_mark(items, config) @@ -260,8 +292,8 @@ def pytest_configure(config: Config) -> None: if empty_parameterset not in ("skip", "xfail", "fail_at_collect", None, ""): raise UsageError( - "{!s} must be one of skip, xfail or fail_at_collect" - " but it is {!r}".format(EMPTY_PARAMETERSET_OPTION, empty_parameterset) + f"{EMPTY_PARAMETERSET_OPTION!s} must be one of skip, xfail or fail_at_collect" + f" but it is {empty_parameterset!r}" ) diff --git a/src/_pytest/mark/expression.py b/src/_pytest/mark/expression.py index 9287bcee50c..743a46bcc17 100644 --- a/src/_pytest/mark/expression.py +++ b/src/_pytest/mark/expression.py @@ -5,32 +5,36 @@ expression: expr? EOF expr: and_expr ('or' and_expr)* and_expr: not_expr ('and' not_expr)* -not_expr: 'not' not_expr | '(' expr ')' | ident +not_expr: 'not' not_expr | '(' expr ')' | ident kwargs? + ident: (\w|:|\+|-|\.|\[|\]|\\|/)+ +kwargs: ('(' name '=' value ( ', ' name '=' value )* ')') +name: a valid ident, but not a reserved keyword +value: (unescaped) string literal | (-)?[0-9]+ | 'False' | 'True' | 'None' The semantics are: - Empty expression evaluates to False. -- ident evaluates to True of False according to a provided matcher function. +- ident evaluates to True or False according to a provided matcher function. - or/and/not evaluate according to the usual boolean semantics. +- ident with parentheses and keyword arguments evaluates to True or False according to a provided matcher function. """ + +from __future__ import annotations + import ast +from collections.abc import Iterator +from collections.abc import Mapping +from collections.abc import Sequence import dataclasses import enum +import keyword import re -import sys import types -from typing import Callable -from typing import Iterator -from typing import Mapping +from typing import Literal from typing import NoReturn -from typing import Optional -from typing import Sequence - -if sys.version_info >= (3, 8): - astNameConstant = ast.Constant -else: - astNameConstant = ast.NameConstant +from typing import overload +from typing import Protocol __all__ = [ @@ -47,11 +51,14 @@ class TokenType(enum.Enum): NOT = "not" IDENT = "identifier" EOF = "end of input" + EQUAL = "=" + STRING = "string literal" + COMMA = "," @dataclasses.dataclass(frozen=True) class Token: - __slots__ = ("type", "value", "pos") + __slots__ = ("pos", "type", "value") type: TokenType value: str pos: int @@ -73,7 +80,7 @@ def __str__(self) -> str: class Scanner: - __slots__ = ("tokens", "current") + __slots__ = ("current", "tokens") def __init__(self, input: str) -> None: self.tokens = self.lex(input) @@ -90,6 +97,27 @@ def lex(self, input: str) -> Iterator[Token]: elif input[pos] == ")": yield Token(TokenType.RPAREN, ")", pos) pos += 1 + elif input[pos] == "=": + yield Token(TokenType.EQUAL, "=", pos) + pos += 1 + elif input[pos] == ",": + yield Token(TokenType.COMMA, ",", pos) + pos += 1 + elif (quote_char := input[pos]) in ("'", '"'): + end_quote_pos = input.find(quote_char, pos + 1) + if end_quote_pos == -1: + raise ParseError( + pos + 1, + f'closing quote "{quote_char}" is missing', + ) + value = input[pos : end_quote_pos + 1] + if (backslash_pos := input.find("\\")) != -1: + raise ParseError( + backslash_pos + 1, + r'escaping with "\" not supported in marker expression', + ) + yield Token(TokenType.STRING, value, pos) + pos += len(value) else: match = re.match(r"(:?\w|:|\+|-|\.|\[|\]|\\|/)+", input[pos:]) if match: @@ -110,7 +138,15 @@ def lex(self, input: str) -> Iterator[Token]: ) yield Token(TokenType.EOF, "", pos) - def accept(self, type: TokenType, *, reject: bool = False) -> Optional[Token]: + @overload + def accept(self, type: TokenType, *, reject: Literal[True]) -> Token: ... + + @overload + def accept( + self, type: TokenType, *, reject: Literal[False] = False + ) -> Token | None: ... + + def accept(self, type: TokenType, *, reject: bool = False) -> Token | None: if self.current.type is type: token = self.current if token.type is not TokenType.EOF: @@ -138,7 +174,7 @@ def reject(self, expected: Sequence[TokenType]) -> NoReturn: def expression(s: Scanner) -> ast.Expression: if s.accept(TokenType.EOF): - ret: ast.expr = astNameConstant(False) + ret: ast.expr = ast.Constant(False) else: ret = expr(s) s.accept(TokenType.EOF, reject=True) @@ -170,18 +206,85 @@ def not_expr(s: Scanner) -> ast.expr: return ret ident = s.accept(TokenType.IDENT) if ident: - return ast.Name(IDENT_PREFIX + ident.value, ast.Load()) + name = ast.Name(IDENT_PREFIX + ident.value, ast.Load()) + if s.accept(TokenType.LPAREN): + ret = ast.Call(func=name, args=[], keywords=all_kwargs(s)) + s.accept(TokenType.RPAREN, reject=True) + else: + ret = name + return ret + s.reject((TokenType.NOT, TokenType.LPAREN, TokenType.IDENT)) -class MatcherAdapter(Mapping[str, bool]): +BUILTIN_MATCHERS = {"True": True, "False": False, "None": None} + + +def single_kwarg(s: Scanner) -> ast.keyword: + keyword_name = s.accept(TokenType.IDENT, reject=True) + if not keyword_name.value.isidentifier(): + raise ParseError( + keyword_name.pos + 1, + f"not a valid python identifier {keyword_name.value}", + ) + if keyword.iskeyword(keyword_name.value): + raise ParseError( + keyword_name.pos + 1, + f"unexpected reserved python keyword `{keyword_name.value}`", + ) + s.accept(TokenType.EQUAL, reject=True) + + if value_token := s.accept(TokenType.STRING): + value: str | int | bool | None = value_token.value[1:-1] # strip quotes + else: + value_token = s.accept(TokenType.IDENT, reject=True) + if (number := value_token.value).isdigit() or ( + number.startswith("-") and number[1:].isdigit() + ): + value = int(number) + elif value_token.value in BUILTIN_MATCHERS: + value = BUILTIN_MATCHERS[value_token.value] + else: + raise ParseError( + value_token.pos + 1, + f'unexpected character/s "{value_token.value}"', + ) + + ret = ast.keyword(keyword_name.value, ast.Constant(value)) + return ret + + +def all_kwargs(s: Scanner) -> list[ast.keyword]: + ret = [single_kwarg(s)] + while s.accept(TokenType.COMMA): + ret.append(single_kwarg(s)) + return ret + + +class MatcherCall(Protocol): + def __call__(self, name: str, /, **kwargs: str | int | bool | None) -> bool: ... + + +@dataclasses.dataclass +class MatcherNameAdapter: + matcher: MatcherCall + name: str + + def __bool__(self) -> bool: + return self.matcher(self.name) + + def __call__(self, **kwargs: str | int | bool | None) -> bool: + return self.matcher(self.name, **kwargs) + + +class MatcherAdapter(Mapping[str, MatcherNameAdapter]): """Adapts a matcher function to a locals mapping as required by eval().""" - def __init__(self, matcher: Callable[[str], bool]) -> None: + def __init__(self, matcher: MatcherCall) -> None: self.matcher = matcher - def __getitem__(self, key: str) -> bool: - return self.matcher(key[len(IDENT_PREFIX) :]) + def __getitem__(self, key: str) -> MatcherNameAdapter: + return MatcherNameAdapter(matcher=self.matcher, name=key[len(IDENT_PREFIX) :]) def __iter__(self) -> Iterator[str]: raise NotImplementedError() @@ -202,7 +305,7 @@ def __init__(self, code: types.CodeType) -> None: self.code = code @classmethod - def compile(self, input: str) -> "Expression": + def compile(cls, input: str) -> Expression: """Compile a match expression. :param input: The input expression - one line. @@ -215,7 +318,7 @@ def compile(self, input: str) -> "Expression": ) return Expression(code) - def evaluate(self, matcher: Callable[[str], bool]) -> bool: + def evaluate(self, matcher: MatcherCall) -> bool: """Evaluate the match expression. :param matcher: @@ -224,5 +327,5 @@ def evaluate(self, matcher: Callable[[str], bool]) -> bool: :returns: Whether the expression matches or not. """ - ret: bool = eval(self.code, {"__builtins__": {}}, MatcherAdapter(matcher)) + ret: bool = bool(eval(self.code, {"__builtins__": {}}, MatcherAdapter(matcher))) return ret diff --git a/src/_pytest/mark/structures.py b/src/_pytest/mark/structures.py index 8dbff1dc93a..f9261076ad0 100644 --- a/src/_pytest/mark/structures.py +++ b/src/_pytest/mark/structures.py @@ -1,36 +1,38 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + import collections.abc +from collections.abc import Callable +from collections.abc import Collection +from collections.abc import Iterable +from collections.abc import Iterator +from collections.abc import Mapping +from collections.abc import MutableMapping +from collections.abc import Sequence import dataclasses +import enum import inspect -import warnings from typing import Any -from typing import Callable -from typing import Collection -from typing import Iterable -from typing import Iterator -from typing import List -from typing import Mapping -from typing import MutableMapping +from typing import final from typing import NamedTuple -from typing import Optional from typing import overload -from typing import Sequence -from typing import Set -from typing import Tuple -from typing import Type from typing import TYPE_CHECKING from typing import TypeVar from typing import Union +import warnings from .._code import getfslineno -from ..compat import ascii_escaped -from ..compat import final from ..compat import NOTSET from ..compat import NotSetType from _pytest.config import Config from _pytest.deprecated import check_ispytest +from _pytest.deprecated import MARKED_FIXTURE from _pytest.outcomes import fail +from _pytest.raises import AbstractRaises +from _pytest.scope import _ScopeName from _pytest.warning_types import PytestUnknownMarkWarning + if TYPE_CHECKING: from ..nodes import Node @@ -38,33 +40,37 @@ EMPTY_PARAMETERSET_OPTION = "empty_parameter_set_mark" +# Singleton type for HIDDEN_PARAM, as described in: +# https://www.python.org/dev/peps/pep-0484/#support-for-singleton-types-in-unions +class _HiddenParam(enum.Enum): + token = 0 + + +#: Can be used as a parameter set id to hide it from the test name. +HIDDEN_PARAM = _HiddenParam.token + + def istestfunc(func) -> bool: return callable(func) and getattr(func, "__name__", "") != "" def get_empty_parameterset_mark( config: Config, argnames: Sequence[str], func -) -> "MarkDecorator": +) -> MarkDecorator: from ..nodes import Collector - fs, lineno = getfslineno(func) - reason = "got empty parameter set %r, function %s at %s:%d" % ( - argnames, - func.__name__, - fs, - lineno, - ) + argslisting = ", ".join(argnames) + fs, lineno = getfslineno(func) + reason = f"got empty parameter set for ({argslisting})" requested_mark = config.getini(EMPTY_PARAMETERSET_OPTION) if requested_mark in ("", None, "skip"): mark = MARK_GEN.skip(reason=reason) elif requested_mark == "xfail": mark = MARK_GEN.xfail(reason=reason, run=False) elif requested_mark == "fail_at_collect": - f_name = func.__name__ - _, lineno = getfslineno(func) raise Collector.CollectError( - "Empty parameter set in '%s' at line %d" % (f_name, lineno + 1) + f"Empty parameter set in '{func.__name__}' at line {lineno + 1}" ) else: raise LookupError(requested_mark) @@ -72,34 +78,68 @@ def get_empty_parameterset_mark( class ParameterSet(NamedTuple): - values: Sequence[Union[object, NotSetType]] - marks: Collection[Union["MarkDecorator", "Mark"]] - id: Optional[str] + """A set of values for a set of parameters along with associated marks and + an optional ID for the set. + + Examples:: + + pytest.param(1, 2, 3) + # ParameterSet(values=(1, 2, 3), marks=(), id=None) + + pytest.param("hello", id="greeting") + # ParameterSet(values=("hello",), marks=(), id="greeting") + + # Parameter set with marks + pytest.param(42, marks=pytest.mark.xfail) + # ParameterSet(values=(42,), marks=(MarkDecorator(...),), id=None) + + # From parametrize mark (parameter names + list of parameter sets) + pytest.mark.parametrize( + ("a", "b", "expected"), + [ + (1, 2, 3), + pytest.param(40, 2, 42, id="everything"), + ], + ) + # ParameterSet(values=(1, 2, 3), marks=(), id=None) + # ParameterSet(values=(2, 2, 3), marks=(), id="everything") + """ + + values: Sequence[object | NotSetType] + marks: Collection[MarkDecorator | Mark] + id: str | _HiddenParam | None @classmethod def param( cls, *values: object, - marks: Union["MarkDecorator", Collection[Union["MarkDecorator", "Mark"]]] = (), - id: Optional[str] = None, - ) -> "ParameterSet": + marks: MarkDecorator | Collection[MarkDecorator | Mark] = (), + id: str | _HiddenParam | None = None, + ) -> ParameterSet: if isinstance(marks, MarkDecorator): marks = (marks,) else: assert isinstance(marks, collections.abc.Collection) + if any(i.name == "usefixtures" for i in marks): + raise ValueError( + "pytest.param cannot add pytest.mark.usefixtures; see " + "https://docs.pytest.org/en/stable/reference/reference.html#pytest-param" + ) if id is not None: - if not isinstance(id, str): - raise TypeError(f"Expected id to be a string, got {type(id)}: {id!r}") - id = ascii_escaped(id) + if not isinstance(id, str) and id is not HIDDEN_PARAM: + raise TypeError( + "Expected id to be a string or a `pytest.HIDDEN_PARAM` sentinel, " + f"got {type(id)}: {id!r}", + ) return cls(values, marks, id) @classmethod def extract_from( cls, - parameterset: Union["ParameterSet", Sequence[object], object], + parameterset: ParameterSet | Sequence[object] | object, force_tuple: bool = False, - ) -> "ParameterSet": + ) -> ParameterSet: """Extract from an object or objects. :param parameterset: @@ -110,7 +150,6 @@ def extract_from( Enforce tuple wrapping so single argument tuple values don't get decomposed and break tests. """ - if isinstance(parameterset, cls): return parameterset if force_tuple: @@ -125,11 +164,11 @@ def extract_from( @staticmethod def _parse_parametrize_args( - argnames: Union[str, Sequence[str]], - argvalues: Iterable[Union["ParameterSet", Sequence[object], object]], + argnames: str | Sequence[str], + argvalues: Iterable[ParameterSet | Sequence[object] | object], *args, **kwargs, - ) -> Tuple[Sequence[str], bool]: + ) -> tuple[Sequence[str], bool]: if isinstance(argnames, str): argnames = [x.strip() for x in argnames.split(",") if x.strip()] force_tuple = len(argnames) == 1 @@ -139,9 +178,9 @@ def _parse_parametrize_args( @staticmethod def _parse_parametrize_parameters( - argvalues: Iterable[Union["ParameterSet", Sequence[object], object]], + argvalues: Iterable[ParameterSet | Sequence[object] | object], force_tuple: bool, - ) -> List["ParameterSet"]: + ) -> list[ParameterSet]: return [ ParameterSet.extract_from(x, force_tuple=force_tuple) for x in argvalues ] @@ -149,12 +188,12 @@ def _parse_parametrize_parameters( @classmethod def _for_parametrize( cls, - argnames: Union[str, Sequence[str]], - argvalues: Iterable[Union["ParameterSet", Sequence[object], object]], + argnames: str | Sequence[str], + argvalues: Iterable[ParameterSet | Sequence[object] | object], func, config: Config, nodeid: str, - ) -> Tuple[Sequence[str], List["ParameterSet"]]: + ) -> tuple[Sequence[str], list[ParameterSet]]: argnames, force_tuple = cls._parse_parametrize_args(argnames, argvalues) parameters = cls._parse_parametrize_parameters(argvalues, force_tuple) del argvalues @@ -184,7 +223,9 @@ def _for_parametrize( # parameter set with NOTSET values, with the "empty parameter set" mark applied to it. mark = get_empty_parameterset_mark(config, argnames, func) parameters.append( - ParameterSet(values=(NOTSET,) * len(argnames), marks=[mark], id=None) + ParameterSet( + values=(NOTSET,) * len(argnames), marks=[mark], id="NOTSET" + ) ) return argnames, parameters @@ -197,24 +238,24 @@ class Mark: #: Name of the mark. name: str #: Positional arguments of the mark decorator. - args: Tuple[Any, ...] + args: tuple[Any, ...] #: Keyword arguments of the mark decorator. kwargs: Mapping[str, Any] #: Source Mark for ids with parametrize Marks. - _param_ids_from: Optional["Mark"] = dataclasses.field(default=None, repr=False) + _param_ids_from: Mark | None = dataclasses.field(default=None, repr=False) #: Resolved/generated ids with parametrize Marks. - _param_ids_generated: Optional[Sequence[str]] = dataclasses.field( + _param_ids_generated: Sequence[str] | None = dataclasses.field( default=None, repr=False ) def __init__( self, name: str, - args: Tuple[Any, ...], + args: tuple[Any, ...], kwargs: Mapping[str, Any], - param_ids_from: Optional["Mark"] = None, - param_ids_generated: Optional[Sequence[str]] = None, + param_ids_from: Mark | None = None, + param_ids_generated: Sequence[str] | None = None, *, _ispytest: bool = False, ) -> None: @@ -230,7 +271,7 @@ def __init__( def _has_param_ids(self) -> bool: return "ids" in self.kwargs or len(self.args) >= 4 - def combined_with(self, other: "Mark") -> "Mark": + def combined_with(self, other: Mark) -> Mark: """Return a new Mark which is a combination of this Mark and another Mark. @@ -242,7 +283,7 @@ def combined_with(self, other: "Mark") -> "Mark": assert self.name == other.name # Remember source of ids with parametrize Marks. - param_ids_from: Optional[Mark] = None + param_ids_from: Mark | None = None if self.name == "parametrize": if other._has_param_ids(): param_ids_from = other @@ -270,8 +311,8 @@ class MarkDecorator: ``MarkDecorators`` are created with ``pytest.mark``:: - mark1 = pytest.mark.NAME # Simple MarkDecorator - mark2 = pytest.mark.NAME(name1=value) # Parametrized MarkDecorator + mark1 = pytest.mark.NAME # Simple MarkDecorator + mark2 = pytest.mark.NAME(name1=value) # Parametrized MarkDecorator and can then be applied as decorators to test functions:: @@ -313,7 +354,7 @@ def name(self) -> str: return self.mark.name @property - def args(self) -> Tuple[Any, ...]: + def args(self) -> tuple[Any, ...]: """Alias for mark.args.""" return self.mark.args @@ -327,7 +368,7 @@ def markname(self) -> str: """:meta private:""" return self.name # for backward-compat (2.4.1 had this attr) - def with_args(self, *args: object, **kwargs: object) -> "MarkDecorator": + def with_args(self, *args: object, **kwargs: object) -> MarkDecorator: """Return a MarkDecorator with extra arguments added. Unlike calling the MarkDecorator, with_args() can be used even @@ -340,11 +381,11 @@ def with_args(self, *args: object, **kwargs: object) -> "MarkDecorator": # return type. Not much we can do about that. Thankfully mypy picks # the first match so it works out even if we break the rules. @overload - def __call__(self, arg: Markable) -> Markable: # type: ignore[misc] + def __call__(self, arg: Markable) -> Markable: # type: ignore[overload-overlap] pass @overload - def __call__(self, *args: object, **kwargs: object) -> "MarkDecorator": + def __call__(self, *args: object, **kwargs: object) -> MarkDecorator: pass def __call__(self, *args: object, **kwargs: object): @@ -352,17 +393,22 @@ def __call__(self, *args: object, **kwargs: object): if args and not kwargs: func = args[0] is_class = inspect.isclass(func) - if len(args) == 1 and (istestfunc(func) or is_class): - store_mark(func, self.mark) + # For staticmethods/classmethods, the marks are eventually fetched from the + # function object, not the descriptor, so unwrap. + unwrapped_func = func + if isinstance(func, (staticmethod, classmethod)): + unwrapped_func = func.__func__ + if len(args) == 1 and (istestfunc(unwrapped_func) or is_class): + store_mark(unwrapped_func, self.mark, stacklevel=3) return func return self.with_args(*args, **kwargs) def get_unpacked_marks( - obj: Union[object, type], + obj: object | type, *, consider_mro: bool = True, -) -> List[Mark]: +) -> list[Mark]: """Obtain the unpacked marks that are stored on an object. If obj is a class and consider_mro is true, return marks applied to @@ -373,7 +419,9 @@ def get_unpacked_marks( if not consider_mro: mark_lists = [obj.__dict__.get("pytestmark", [])] else: - mark_lists = [x.__dict__.get("pytestmark", []) for x in obj.__mro__] + mark_lists = [ + x.__dict__.get("pytestmark", []) for x in reversed(obj.__mro__) + ] mark_list = [] for item in mark_lists: if isinstance(item, list): @@ -390,7 +438,7 @@ def get_unpacked_marks( def normalize_mark_list( - mark_list: Iterable[Union[Mark, MarkDecorator]] + mark_list: Iterable[Mark | MarkDecorator], ) -> Iterable[Mark]: """ Normalize an iterable of Mark or MarkDecorator objects into a list of marks @@ -402,16 +450,22 @@ def normalize_mark_list( for mark in mark_list: mark_obj = getattr(mark, "mark", mark) if not isinstance(mark_obj, Mark): - raise TypeError(f"got {repr(mark_obj)} instead of Mark") + raise TypeError(f"got {mark_obj!r} instead of Mark") yield mark_obj -def store_mark(obj, mark: Mark) -> None: +def store_mark(obj, mark: Mark, *, stacklevel: int = 2) -> None: """Store a Mark on an object. This is used to implement the Mark declarations/decorators correctly. """ assert isinstance(mark, Mark), mark + + from ..fixtures import getfixturemarker + + if getfixturemarker(obj) is not None: + warnings.warn(MARKED_FIXTURE, stacklevel=stacklevel) + # Always reassign name to avoid updating pytestmark in a reference that # was only borrowed. obj.pytestmark = [*get_unpacked_marks(obj, consider_mro=False), mark] @@ -420,59 +474,52 @@ def store_mark(obj, mark: Mark) -> None: # Typing for builtin pytest marks. This is cheating; it gives builtin marks # special privilege, and breaks modularity. But practicality beats purity... if TYPE_CHECKING: - from _pytest.scope import _ScopeName class _SkipMarkDecorator(MarkDecorator): - @overload # type: ignore[override,misc,no-overload-impl] - def __call__(self, arg: Markable) -> Markable: - ... + @overload # type: ignore[override,no-overload-impl] + def __call__(self, arg: Markable) -> Markable: ... @overload - def __call__(self, reason: str = ...) -> "MarkDecorator": - ... + def __call__(self, reason: str = ...) -> MarkDecorator: ... class _SkipifMarkDecorator(MarkDecorator): def __call__( # type: ignore[override] self, - condition: Union[str, bool] = ..., - *conditions: Union[str, bool], + condition: str | bool = ..., + *conditions: str | bool, reason: str = ..., - ) -> MarkDecorator: - ... + ) -> MarkDecorator: ... class _XfailMarkDecorator(MarkDecorator): - @overload # type: ignore[override,misc,no-overload-impl] - def __call__(self, arg: Markable) -> Markable: - ... + @overload # type: ignore[override,no-overload-impl] + def __call__(self, arg: Markable) -> Markable: ... @overload def __call__( self, - condition: Union[str, bool] = ..., - *conditions: Union[str, bool], + condition: str | bool = False, + *conditions: str | bool, reason: str = ..., run: bool = ..., - raises: Union[Type[BaseException], Tuple[Type[BaseException], ...]] = ..., + raises: None + | type[BaseException] + | tuple[type[BaseException], ...] + | AbstractRaises[BaseException] = ..., strict: bool = ..., - ) -> MarkDecorator: - ... + ) -> MarkDecorator: ... class _ParametrizeMarkDecorator(MarkDecorator): def __call__( # type: ignore[override] self, - argnames: Union[str, Sequence[str]], - argvalues: Iterable[Union[ParameterSet, Sequence[object], object]], + argnames: str | Sequence[str], + argvalues: Iterable[ParameterSet | Sequence[object] | object], *, - indirect: Union[bool, Sequence[str]] = ..., - ids: Optional[ - Union[ - Iterable[Union[None, str, float, int, bool]], - Callable[[Any], Optional[object]], - ] - ] = ..., - scope: Optional[_ScopeName] = ..., - ) -> MarkDecorator: - ... + indirect: bool | Sequence[str] = ..., + ids: Iterable[None | str | float | int | bool] + | Callable[[Any], object | None] + | None = ..., + scope: _ScopeName | None = ..., + ) -> MarkDecorator: ... class _UsefixturesMarkDecorator(MarkDecorator): def __call__(self, *fixtures: str) -> MarkDecorator: # type: ignore[override] @@ -492,9 +539,10 @@ class MarkGenerator: import pytest + @pytest.mark.slowtest def test_function(): - pass + pass applies a 'slowtest' :class:`Mark` on ``test_function``. """ @@ -510,8 +558,8 @@ def test_function(): def __init__(self, *, _ispytest: bool = False) -> None: check_ispytest(_ispytest) - self._config: Optional[Config] = None - self._markers: Set[str] = set() + self._config: Config | None = None + self._markers: set[str] = set() def __getattr__(self, name: str) -> MarkDecorator: """Generate a new :class:`MarkDecorator` with the given name.""" @@ -545,9 +593,9 @@ def __getattr__(self, name: str) -> MarkDecorator: fail(f"Unknown '{name}' mark, did you mean 'parametrize'?") warnings.warn( - "Unknown pytest.mark.%s - is this a typo? You can register " + f"Unknown pytest.mark.{name} - is this a typo? You can register " "custom marks to avoid this warning - for details, see " - "https://docs.pytest.org/en/stable/how-to/mark.html" % name, + "https://docs.pytest.org/en/stable/how-to/mark.html", PytestUnknownMarkWarning, 2, ) @@ -560,9 +608,9 @@ def __getattr__(self, name: str) -> MarkDecorator: @final class NodeKeywords(MutableMapping[str, Any]): - __slots__ = ("node", "parent", "_markers") + __slots__ = ("_markers", "node", "parent") - def __init__(self, node: "Node") -> None: + def __init__(self, node: Node) -> None: self.node = node self.parent = node.parent self._markers = {node.name: True} @@ -582,15 +630,13 @@ def __setitem__(self, key: str, value: Any) -> None: # below and use the collections.abc fallback, but that would be slow. def __contains__(self, key: object) -> bool: - return ( - key in self._markers - or self.parent is not None - and key in self.parent.keywords + return key in self._markers or ( + self.parent is not None and key in self.parent.keywords ) def update( # type: ignore[override] self, - other: Union[Mapping[str, Any], Iterable[Tuple[str, Any]]] = (), + other: Mapping[str, Any] | Iterable[tuple[str, Any]] = (), **kwds: Any, ) -> None: self._markers.update(other) diff --git a/src/_pytest/monkeypatch.py b/src/_pytest/monkeypatch.py index 9e51ff33538..1285e571551 100644 --- a/src/_pytest/monkeypatch.py +++ b/src/_pytest/monkeypatch.py @@ -1,24 +1,25 @@ +# mypy: allow-untyped-defs """Monkeypatching and mocking functionality.""" + +from __future__ import annotations + +from collections.abc import Generator +from collections.abc import Mapping +from collections.abc import MutableMapping +from contextlib import contextmanager import os import re import sys -import warnings -from contextlib import contextmanager from typing import Any -from typing import Generator -from typing import List -from typing import Mapping -from typing import MutableMapping -from typing import Optional +from typing import final from typing import overload -from typing import Tuple from typing import TypeVar -from typing import Union +import warnings -from _pytest.compat import final from _pytest.fixtures import fixture from _pytest.warning_types import PytestWarning + RE_IMPORT_ERROR_NAME = re.compile(r"^No module named (.*)$") @@ -27,7 +28,7 @@ @fixture -def monkeypatch() -> Generator["MonkeyPatch", None, None]: +def monkeypatch() -> Generator[MonkeyPatch]: """A convenient fixture for monkey-patching. The fixture provides these methods to modify objects, dictionaries, or @@ -89,14 +90,12 @@ def annotated_getattr(obj: object, name: str, ann: str) -> object: obj = getattr(obj, name) except AttributeError as e: raise AttributeError( - "{!r} object at {} has no attribute {!r}".format( - type(obj).__name__, ann, name - ) + f"{type(obj).__name__!r} object at {ann} has no attribute {name!r}" ) from e return obj -def derive_importpath(import_path: str, raising: bool) -> Tuple[str, object]: +def derive_importpath(import_path: str, raising: bool) -> tuple[str, object]: if not isinstance(import_path, str) or "." not in import_path: raise TypeError(f"must be absolute import path string, not {import_path!r}") module, attr = import_path.rsplit(".", 1) @@ -129,14 +128,14 @@ class MonkeyPatch: """ def __init__(self) -> None: - self._setattr: List[Tuple[object, str, object]] = [] - self._setitem: List[Tuple[Mapping[Any, Any], object, object]] = [] - self._cwd: Optional[str] = None - self._savesyspath: Optional[List[str]] = None + self._setattr: list[tuple[object, str, object]] = [] + self._setitem: list[tuple[Mapping[Any, Any], object, object]] = [] + self._cwd: str | None = None + self._savesyspath: list[str] | None = None @classmethod @contextmanager - def context(cls) -> Generator["MonkeyPatch", None, None]: + def context(cls) -> Generator[MonkeyPatch]: """Context manager that returns a new :class:`MonkeyPatch` object which undoes any patching done inside the ``with`` block upon exit. @@ -168,8 +167,7 @@ def setattr( name: object, value: Notset = ..., raising: bool = ..., - ) -> None: - ... + ) -> None: ... @overload def setattr( @@ -178,13 +176,12 @@ def setattr( name: str, value: object, raising: bool = ..., - ) -> None: - ... + ) -> None: ... def setattr( self, - target: Union[str, object], - name: Union[object, str], + target: str | object, + name: object | str, value: object = notset, raising: bool = True, ) -> None: @@ -255,8 +252,8 @@ def setattr( def delattr( self, - target: Union[object, str], - name: Union[str, Notset] = notset, + target: object | str, + name: str | Notset = notset, raising: bool = True, ) -> None: """Delete attribute ``name`` from ``target``. @@ -311,7 +308,7 @@ def delitem(self, dic: Mapping[K, V], name: K, raising: bool = True) -> None: # Not all Mapping types support indexing, but MutableMapping doesn't support TypedDict del dic[name] # type: ignore[attr-defined] - def setenv(self, name: str, value: str, prepend: Optional[str] = None) -> None: + def setenv(self, name: str, value: str, prepend: str | None = None) -> None: """Set environment variable ``name`` to ``value``. If ``prepend`` is a character, read the current environment variable @@ -321,10 +318,8 @@ def setenv(self, name: str, value: str, prepend: Optional[str] = None) -> None: if not isinstance(value, str): warnings.warn( # type: ignore[unreachable] PytestWarning( - "Value of environment variable {name} type should be str, but got " - "{value!r} (type: {type}); converted to str implicitly".format( - name=name, value=value, type=type(value).__name__ - ) + f"Value of environment variable {name} type should be str, but got " + f"{value!r} (type: {type(value).__name__}); converted to str implicitly" ), stacklevel=2, ) @@ -344,7 +339,6 @@ def delenv(self, name: str, raising: bool = True) -> None: def syspath_prepend(self, path) -> None: """Prepend ``path`` to ``sys.path`` list of import locations.""" - if self._savesyspath is None: self._savesyspath = sys.path[:] sys.path.insert(0, str(path)) @@ -367,7 +361,7 @@ def syspath_prepend(self, path) -> None: invalidate_caches() - def chdir(self, path: Union[str, "os.PathLike[str]"]) -> None: + def chdir(self, path: str | os.PathLike[str]) -> None: """Change the current working directory to the specified path. :param path: diff --git a/src/_pytest/nodes.py b/src/_pytest/nodes.py index dbd6b0a4273..6690f6ab1f8 100644 --- a/src/_pytest/nodes.py +++ b/src/_pytest/nodes.py @@ -1,47 +1,52 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + +import abc +from collections.abc import Callable +from collections.abc import Iterable +from collections.abc import Iterator +from collections.abc import MutableMapping +from functools import cached_property +from functools import lru_cache import os -import warnings -from inspect import signature +import pathlib from pathlib import Path from typing import Any -from typing import Callable from typing import cast -from typing import Iterable -from typing import Iterator -from typing import List -from typing import MutableMapping -from typing import Optional +from typing import NoReturn from typing import overload -from typing import Set -from typing import Tuple -from typing import Type from typing import TYPE_CHECKING from typing import TypeVar -from typing import Union +import warnings + +import pluggy import _pytest._code from _pytest._code import getfslineno from _pytest._code.code import ExceptionInfo from _pytest._code.code import TerminalRepr from _pytest._code.code import Traceback -from _pytest.compat import cached_property +from _pytest._code.code import TracebackStyle from _pytest.compat import LEGACY_PATH +from _pytest.compat import signature from _pytest.config import Config from _pytest.config import ConftestImportFailure -from _pytest.deprecated import FSCOLLECTOR_GETHOOKPROXY_ISINITPATH +from _pytest.config.compat import _check_path from _pytest.deprecated import NODE_CTOR_FSPATH_ARG from _pytest.mark.structures import Mark from _pytest.mark.structures import MarkDecorator from _pytest.mark.structures import NodeKeywords from _pytest.outcomes import fail from _pytest.pathlib import absolutepath -from _pytest.pathlib import commonpath from _pytest.stash import Stash from _pytest.warning_types import PytestWarning + if TYPE_CHECKING: + from typing_extensions import Self + # Imported here due to circular import. from _pytest.main import Session - from _pytest._code.code import _TracebackStyle SEP = "/" @@ -49,63 +54,13 @@ tracebackcutdir = Path(_pytest.__file__).parent -def iterparentnodeids(nodeid: str) -> Iterator[str]: - """Return the parent node IDs of a given node ID, inclusive. - - For the node ID - - "testing/code/test_excinfo.py::TestFormattedExcinfo::test_repr_source" - - the result would be - - "" - "testing" - "testing/code" - "testing/code/test_excinfo.py" - "testing/code/test_excinfo.py::TestFormattedExcinfo" - "testing/code/test_excinfo.py::TestFormattedExcinfo::test_repr_source" - - Note that / components are only considered until the first ::. - """ - pos = 0 - first_colons: Optional[int] = nodeid.find("::") - if first_colons == -1: - first_colons = None - # The root Session node - always present. - yield "" - # Eagerly consume SEP parts until first colons. - while True: - at = nodeid.find(SEP, pos, first_colons) - if at == -1: - break - if at > 0: - yield nodeid[:at] - pos = at + len(SEP) - # Eagerly consume :: parts. - while True: - at = nodeid.find("::", pos) - if at == -1: - break - if at > 0: - yield nodeid[:at] - pos = at + len("::") - # The node ID itself. - if nodeid: - yield nodeid - - -def _check_path(path: Path, fspath: LEGACY_PATH) -> None: - if Path(fspath) != path: - raise ValueError( - f"Path({fspath!r}) != {path!r}\n" - "if both path and fspath are given they need to be equal" - ) +_T = TypeVar("_T") def _imply_path( - node_type: Type["Node"], - path: Optional[Path], - fspath: Optional[LEGACY_PATH], + node_type: type[Node], + path: Path | None, + fspath: LEGACY_PATH | None, ) -> Path: if fspath is not None: warnings.warn( @@ -126,72 +81,87 @@ def _imply_path( _NodeType = TypeVar("_NodeType", bound="Node") -class NodeMeta(type): - def __call__(self, *k, **kw): +class NodeMeta(abc.ABCMeta): + """Metaclass used by :class:`Node` to enforce that direct construction raises + :class:`Failed`. + + This behaviour supports the indirection introduced with :meth:`Node.from_parent`, + the named constructor to be used instead of direct construction. The design + decision to enforce indirection with :class:`NodeMeta` was made as a + temporary aid for refactoring the collection tree, which was diagnosed to + have :class:`Node` objects whose creational patterns were overly entangled. + Once the refactoring is complete, this metaclass can be removed. + + See https://github.com/pytest-dev/pytest/projects/3 for an overview of the + progress on detangling the :class:`Node` classes. + """ + + def __call__(cls, *k, **kw) -> NoReturn: msg = ( "Direct construction of {name} has been deprecated, please use {name}.from_parent.\n" "See " "https://docs.pytest.org/en/stable/deprecations.html#node-construction-changed-to-node-from-parent" " for more details." - ).format(name=f"{self.__module__}.{self.__name__}") + ).format(name=f"{cls.__module__}.{cls.__name__}") fail(msg, pytrace=False) - def _create(self, *k, **kw): + def _create(cls: type[_T], *k, **kw) -> _T: try: - return super().__call__(*k, **kw) + return super().__call__(*k, **kw) # type: ignore[no-any-return,misc] except TypeError: - sig = signature(getattr(self, "__init__")) + sig = signature(getattr(cls, "__init__")) known_kw = {k: v for k, v in kw.items() if k in sig.parameters} from .warning_types import PytestDeprecationWarning warnings.warn( PytestDeprecationWarning( - f"{self} is not using a cooperative constructor and only takes {set(known_kw)}.\n" + f"{cls} is not using a cooperative constructor and only takes {set(known_kw)}.\n" "See https://docs.pytest.org/en/stable/deprecations.html" "#constructors-of-custom-pytest-node-subclasses-should-take-kwargs " "for more details." ) ) - return super().__call__(*k, **known_kw) + return super().__call__(*k, **known_kw) # type: ignore[no-any-return,misc] -class Node(metaclass=NodeMeta): - """Base class for Collector and Item, the components of the test - collection tree. +class Node(abc.ABC, metaclass=NodeMeta): + r"""Base class of :class:`Collector` and :class:`Item`, the components of + the test collection tree. - Collector subclasses have children; Items are leaf nodes. + ``Collector``\'s are the internal nodes of the tree, and ``Item``\'s are the + leaf nodes. """ # Implemented in the legacypath plugin. #: A ``LEGACY_PATH`` copy of the :attr:`path` attribute. Intended for usage #: for methods not migrated to ``pathlib.Path`` yet, such as - #: :meth:`Item.reportinfo`. Will be deprecated in a future release, prefer - #: using :attr:`path` instead. + #: :meth:`Item.reportinfo `. Will be deprecated in + #: a future release, prefer using :attr:`path` instead. fspath: LEGACY_PATH # Use __slots__ to make attribute access faster. # Note that __dict__ is still available. __slots__ = ( + "__dict__", + "_nodeid", + "_store", + "config", "name", "parent", - "config", - "session", "path", - "_nodeid", - "_store", - "__dict__", + "session", ) def __init__( self, name: str, - parent: "Optional[Node]" = None, - config: Optional[Config] = None, - session: "Optional[Session]" = None, - fspath: Optional[LEGACY_PATH] = None, - path: Optional[Path] = None, - nodeid: Optional[str] = None, + parent: Node | None = None, + config: Config | None = None, + session: Session | None = None, + fspath: LEGACY_PATH | None = None, + path: Path | None = None, + nodeid: str | None = None, ) -> None: #: A unique name within the scope of the parent node. self.name: str = name @@ -218,17 +188,17 @@ def __init__( if path is None and fspath is None: path = getattr(parent, "path", None) #: Filesystem path where this node was collected from (can be None). - self.path: Path = _imply_path(type(self), path, fspath=fspath) + self.path: pathlib.Path = _imply_path(type(self), path, fspath=fspath) # The explicit annotation is to avoid publicly exposing NodeKeywords. #: Keywords/markers collected from all scopes. self.keywords: MutableMapping[str, Any] = NodeKeywords(self) #: The marker objects belonging to this node. - self.own_markers: List[Mark] = [] + self.own_markers: list[Mark] = [] #: Allow adding of extra keywords to use for matching. - self.extra_keyword_matches: Set[str] = set() + self.extra_keyword_matches: set[str] = set() if nodeid is not None: assert "::()" not in nodeid @@ -245,7 +215,7 @@ def __init__( self._store = self.stash @classmethod - def from_parent(cls, parent: "Node", **kw): + def from_parent(cls, parent: Node, **kw) -> Self: """Public constructor for Nodes. This indirection got introduced in order to enable removing @@ -263,7 +233,7 @@ def from_parent(cls, parent: "Node", **kw): return cls._create(parent=parent, **kw) @property - def ihook(self): + def ihook(self) -> pluggy.HookRelay: """fspath-sensitive hook proxy used to call pytest hooks.""" return self.session.gethookproxy(self.path) @@ -294,9 +264,7 @@ def warn(self, warning: Warning) -> None: # enforce type checks here to avoid getting a generic type error later otherwise. if not isinstance(warning, Warning): raise ValueError( - "warning must be an instance of Warning or subclass, got {!r}".format( - warning - ) + f"warning must be an instance of Warning or subclass, got {warning!r}" ) path, lineno = get_fslocation_from_item(self) assert lineno is not None @@ -323,23 +291,29 @@ def setup(self) -> None: def teardown(self) -> None: pass - def listchain(self) -> List["Node"]: - """Return list of all parent collectors up to self, starting from - the root of collection tree. + def iter_parents(self) -> Iterator[Node]: + """Iterate over all parent collectors starting from and including self + up to the root of the collection tree. - :returns: The nodes. + .. versionadded:: 8.1 """ + parent: Node | None = self + while parent is not None: + yield parent + parent = parent.parent + + def listchain(self) -> list[Node]: + """Return a list of all parent collectors starting from the root of the + collection tree down to and including self.""" chain = [] - item: Optional[Node] = self + item: Node | None = self while item is not None: chain.append(item) item = item.parent chain.reverse() return chain - def add_marker( - self, marker: Union[str, MarkDecorator], append: bool = True - ) -> None: + def add_marker(self, marker: str | MarkDecorator, append: bool = True) -> None: """Dynamically add a marker object to the node. :param marker: @@ -361,7 +335,7 @@ def add_marker( else: self.own_markers.insert(0, marker_.mark) - def iter_markers(self, name: Optional[str] = None) -> Iterator[Mark]: + def iter_markers(self, name: str | None = None) -> Iterator[Mark]: """Iterate over all markers of the node. :param name: If given, filter the results by the name attribute. @@ -370,29 +344,25 @@ def iter_markers(self, name: Optional[str] = None) -> Iterator[Mark]: return (x[1] for x in self.iter_markers_with_node(name=name)) def iter_markers_with_node( - self, name: Optional[str] = None - ) -> Iterator[Tuple["Node", Mark]]: + self, name: str | None = None + ) -> Iterator[tuple[Node, Mark]]: """Iterate over all markers of the node. :param name: If given, filter the results by the name attribute. :returns: An iterator of (node, mark) tuples. """ - for node in reversed(self.listchain()): + for node in self.iter_parents(): for mark in node.own_markers: if name is None or getattr(mark, "name", None) == name: yield node, mark @overload - def get_closest_marker(self, name: str) -> Optional[Mark]: - ... + def get_closest_marker(self, name: str) -> Mark | None: ... @overload - def get_closest_marker(self, name: str, default: Mark) -> Mark: - ... + def get_closest_marker(self, name: str, default: Mark) -> Mark: ... - def get_closest_marker( - self, name: str, default: Optional[Mark] = None - ) -> Optional[Mark]: + def get_closest_marker(self, name: str, default: Mark | None = None) -> Mark | None: """Return the first marker matching the name, from closest (for example function) to farther level (for example module level). @@ -401,14 +371,14 @@ def get_closest_marker( """ return next(self.iter_markers(name=name), default) - def listextrakeywords(self) -> Set[str]: + def listextrakeywords(self) -> set[str]: """Return a set of all extra keywords in self and any parents.""" - extra_keywords: Set[str] = set() + extra_keywords: set[str] = set() for item in self.listchain(): extra_keywords.update(item.extra_keyword_matches) return extra_keywords - def listnames(self) -> List[str]: + def listnames(self) -> list[str]: return [x.name for x in self.listchain()] def addfinalizer(self, fin: Callable[[], object]) -> None: @@ -420,18 +390,17 @@ def addfinalizer(self, fin: Callable[[], object]) -> None: """ self.session._setupstate.addfinalizer(fin, self) - def getparent(self, cls: Type[_NodeType]) -> Optional[_NodeType]: - """Get the next parent node (including self) which is an instance of + def getparent(self, cls: type[_NodeType]) -> _NodeType | None: + """Get the closest parent node (including self) which is an instance of the given class. :param cls: The node class to search for. :returns: The node, if found. """ - current: Optional[Node] = self - while current and not isinstance(current, cls): - current = current.parent - assert current is None or isinstance(current, cls) - return current + for node in self.iter_parents(): + if isinstance(node, cls): + return node + return None def _traceback_filter(self, excinfo: ExceptionInfo[BaseException]) -> Traceback: return excinfo.traceback @@ -439,19 +408,19 @@ def _traceback_filter(self, excinfo: ExceptionInfo[BaseException]) -> Traceback: def _repr_failure_py( self, excinfo: ExceptionInfo[BaseException], - style: "Optional[_TracebackStyle]" = None, + style: TracebackStyle | None = None, ) -> TerminalRepr: from _pytest.fixtures import FixtureLookupError if isinstance(excinfo.value, ConftestImportFailure): - excinfo = ExceptionInfo.from_exc_info(excinfo.value.excinfo) + excinfo = ExceptionInfo.from_exception(excinfo.value.cause) if isinstance(excinfo.value, fail.Exception): if not excinfo.value.pytrace: style = "value" if isinstance(excinfo.value, FixtureLookupError): return excinfo.value.formatrepr() - tbfilter: Union[bool, Callable[[ExceptionInfo[BaseException]], Traceback]] + tbfilter: bool | Callable[[ExceptionInfo[BaseException]], Traceback] if self.config.getoption("fulltrace", False): style = "long" tbfilter = False @@ -466,11 +435,13 @@ def _repr_failure_py( else: style = "long" - if self.config.getoption("verbose", 0) > 1: + if self.config.get_verbosity() > 1: truncate_locals = False else: truncate_locals = True + truncate_args = False if self.config.get_verbosity() > 2 else True + # excinfo.getrepr() formats paths relative to the CWD if `abspath` is False. # It is possible for a fixture/test to change the CWD while this code runs, which # would then result in the user seeing confusing paths in the failure message. @@ -489,13 +460,14 @@ def _repr_failure_py( style=style, tbfilter=tbfilter, truncate_locals=truncate_locals, + truncate_args=truncate_args, ) def repr_failure( self, excinfo: ExceptionInfo[BaseException], - style: "Optional[_TracebackStyle]" = None, - ) -> Union[str, TerminalRepr]: + style: TracebackStyle | None = None, + ) -> str | TerminalRepr: """Return a representation of a collection or test failure. .. seealso:: :ref:`non-python tests` @@ -505,41 +477,44 @@ def repr_failure( return self._repr_failure_py(excinfo, style) -def get_fslocation_from_item(node: "Node") -> Tuple[Union[str, Path], Optional[int]]: +def get_fslocation_from_item(node: Node) -> tuple[str | Path, int | None]: """Try to extract the actual location from a node, depending on available attributes: * "location": a pair (path, lineno) * "obj": a Python object that the node wraps. - * "fspath": just a path + * "path": just a path :rtype: A tuple of (str|Path, int) with filename and 0-based line number. """ # See Item.location. - location: Optional[Tuple[str, Optional[int], str]] = getattr(node, "location", None) + location: tuple[str, int | None, str] | None = getattr(node, "location", None) if location is not None: return location[:2] obj = getattr(node, "obj", None) if obj is not None: return getfslineno(obj) - return getattr(node, "fspath", "unknown location"), -1 + return getattr(node, "path", "unknown location"), -1 + +class Collector(Node, abc.ABC): + """Base class of all collectors. -class Collector(Node): - """Collector instances create children through collect() and thus - iteratively build a tree.""" + Collector create children through `collect()` and thus iteratively build + the collection tree. + """ class CollectError(Exception): """An error during collection, contains a custom message.""" - def collect(self) -> Iterable[Union["Item", "Collector"]]: - """Return a list of children (items and collectors) for this - collection node.""" + @abc.abstractmethod + def collect(self) -> Iterable[Item | Collector]: + """Collect children (items and collectors) for this collector.""" raise NotImplementedError("abstract") # TODO: This omits the style= parameter which breaks Liskov Substitution. def repr_failure( # type: ignore[override] self, excinfo: ExceptionInfo[BaseException] - ) -> Union[str, TerminalRepr]: + ) -> str | TerminalRepr: """Return a representation of a collection failure. :param excinfo: Exception information for the failure. @@ -564,29 +539,37 @@ def _traceback_filter(self, excinfo: ExceptionInfo[BaseException]) -> Traceback: ntraceback = traceback.cut(path=self.path) if ntraceback == traceback: ntraceback = ntraceback.cut(excludepath=tracebackcutdir) - return excinfo.traceback.filter(excinfo) + return ntraceback.filter(excinfo) return excinfo.traceback -def _check_initialpaths_for_relpath(session: "Session", path: Path) -> Optional[str]: - for initial_path in session._initialpaths: - if commonpath(path, initial_path) == initial_path: - rel = str(path.relative_to(initial_path)) - return "" if rel == "." else rel +@lru_cache(maxsize=1000) +def _check_initialpaths_for_relpath( + initial_paths: frozenset[Path], path: Path +) -> str | None: + if path in initial_paths: + return "" + + for parent in path.parents: + if parent in initial_paths: + return str(path.relative_to(parent)) + return None -class FSCollector(Collector): +class FSCollector(Collector, abc.ABC): + """Base class for filesystem collectors.""" + def __init__( self, - fspath: Optional[LEGACY_PATH] = None, - path_or_parent: Optional[Union[Path, Node]] = None, - path: Optional[Path] = None, - name: Optional[str] = None, - parent: Optional[Node] = None, - config: Optional[Config] = None, - session: Optional["Session"] = None, - nodeid: Optional[str] = None, + fspath: LEGACY_PATH | None = None, + path_or_parent: Path | Node | None = None, + path: Path | None = None, + name: str | None = None, + parent: Node | None = None, + config: Config | None = None, + session: Session | None = None, + nodeid: str | None = None, ) -> None: if path_or_parent: if isinstance(path_or_parent, Node): @@ -617,7 +600,7 @@ def __init__( try: nodeid = str(self.path.relative_to(session.config.rootpath)) except ValueError: - nodeid = _check_initialpaths_for_relpath(session, path) + nodeid = _check_initialpaths_for_relpath(session._initialpaths, path) if nodeid and os.sep != SEP: nodeid = nodeid.replace(os.sep, SEP) @@ -636,31 +619,41 @@ def from_parent( cls, parent, *, - fspath: Optional[LEGACY_PATH] = None, - path: Optional[Path] = None, + fspath: LEGACY_PATH | None = None, + path: Path | None = None, **kw, - ): + ) -> Self: """The public constructor.""" return super().from_parent(parent=parent, fspath=fspath, path=path, **kw) - def gethookproxy(self, fspath: "os.PathLike[str]"): - warnings.warn(FSCOLLECTOR_GETHOOKPROXY_ISINITPATH, stacklevel=2) - return self.session.gethookproxy(fspath) - - def isinitpath(self, path: Union[str, "os.PathLike[str]"]) -> bool: - warnings.warn(FSCOLLECTOR_GETHOOKPROXY_ISINITPATH, stacklevel=2) - return self.session.isinitpath(path) - -class File(FSCollector): +class File(FSCollector, abc.ABC): """Base class for collecting tests from a file. :ref:`non-python tests`. """ -class Item(Node): - """A basic test invocation item. +class Directory(FSCollector, abc.ABC): + """Base class for collecting files from a directory. + + A basic directory collector does the following: goes over the files and + sub-directories in the directory and creates collectors for them by calling + the hooks :hook:`pytest_collect_directory` and :hook:`pytest_collect_file`, + after checking that they are not ignored using + :hook:`pytest_ignore_collect`. + + The default directory collectors are :class:`~pytest.Dir` and + :class:`~pytest.Package`. + + .. versionadded:: 8.0 + + :ref:`custom directory collectors`. + """ + + +class Item(Node, abc.ABC): + """Base class of all test invocation items. Note that for a single function there might be multiple test invocation items. """ @@ -671,9 +664,9 @@ def __init__( self, name, parent=None, - config: Optional[Config] = None, - session: Optional["Session"] = None, - nodeid: Optional[str] = None, + config: Config | None = None, + session: Session | None = None, + nodeid: str | None = None, **kw, ) -> None: # The first two arguments are intentionally passed positionally, @@ -688,11 +681,11 @@ def __init__( nodeid=nodeid, **kw, ) - self._report_sections: List[Tuple[str, str, str]] = [] + self._report_sections: list[tuple[str, str, str]] = [] #: A list of tuples (name, value) that holds user defined properties #: for this test. - self.user_properties: List[Tuple[str, object]] = [] + self.user_properties: list[tuple[str, object]] = [] self._check_item_and_collector_diamond_inheritance() @@ -725,6 +718,7 @@ def _check_item_and_collector_diamond_inheritance(self) -> None: PytestWarning, ) + @abc.abstractmethod def runtest(self) -> None: """Run the test case for this item. @@ -751,7 +745,7 @@ def add_report_section(self, when: str, key: str, content: str) -> None: if content: self._report_sections.append((when, key, content)) - def reportinfo(self) -> Tuple[Union["os.PathLike[str]", str], Optional[int], str]: + def reportinfo(self) -> tuple[os.PathLike[str] | str, int | None, str]: """Get location information for this item for test reports. Returns a tuple with three elements: @@ -765,14 +759,14 @@ def reportinfo(self) -> Tuple[Union["os.PathLike[str]", str], Optional[int], str return self.path, None, "" @cached_property - def location(self) -> Tuple[str, Optional[int], str]: + def location(self) -> tuple[str, int | None, str]: """ Returns a tuple of ``(relfspath, lineno, testname)`` for this item where ``relfspath`` is file path relative to ``config.rootpath`` and lineno is a 0-based line number. """ location = self.reportinfo() - path = absolutepath(os.fspath(location[0])) + path = absolutepath(location[0]) relfspath = self.session._node_location_to_relpath(path) assert type(location[2]) is str return (relfspath, location[1], location[2]) diff --git a/src/_pytest/nose.py b/src/_pytest/nose.py deleted file mode 100644 index 273bd045fb6..00000000000 --- a/src/_pytest/nose.py +++ /dev/null @@ -1,50 +0,0 @@ -"""Run testsuites written for nose.""" -import warnings - -from _pytest.config import hookimpl -from _pytest.deprecated import NOSE_SUPPORT -from _pytest.fixtures import getfixturemarker -from _pytest.nodes import Item -from _pytest.python import Function -from _pytest.unittest import TestCaseFunction - - -@hookimpl(trylast=True) -def pytest_runtest_setup(item: Item) -> None: - if not isinstance(item, Function): - return - # Don't do nose style setup/teardown on direct unittest style classes. - if isinstance(item, TestCaseFunction): - return - - # Capture the narrowed type of item for the teardown closure, - # see https://github.com/python/mypy/issues/2608 - func = item - - call_optional(func.obj, "setup", func.nodeid) - func.addfinalizer(lambda: call_optional(func.obj, "teardown", func.nodeid)) - - # NOTE: Module- and class-level fixtures are handled in python.py - # with `pluginmanager.has_plugin("nose")` checks. - # It would have been nicer to implement them outside of core, but - # it's not straightforward. - - -def call_optional(obj: object, name: str, nodeid: str) -> bool: - method = getattr(obj, name, None) - if method is None: - return False - is_fixture = getfixturemarker(method) is not None - if is_fixture: - return False - if not callable(method): - return False - # Warn about deprecation of this plugin. - method_name = getattr(method, "__name__", str(method)) - warnings.warn( - NOSE_SUPPORT.format(nodeid=nodeid, method=method_name, stage=name), stacklevel=2 - ) - # If there are any problems allow the exception to raise rather than - # silently ignoring it. - method() - return True diff --git a/src/_pytest/outcomes.py b/src/_pytest/outcomes.py index 1be97dda4ea..68ba0543365 100644 --- a/src/_pytest/outcomes.py +++ b/src/_pytest/outcomes.py @@ -1,35 +1,24 @@ """Exception classes and constants handling test outcomes as well as functions creating them.""" + +from __future__ import annotations + +from collections.abc import Callable import sys -import warnings from typing import Any -from typing import Callable from typing import cast from typing import NoReturn -from typing import Optional -from typing import Type +from typing import Protocol from typing import TypeVar -from _pytest.deprecated import KEYWORD_MSG_ARG - -TYPE_CHECKING = False # Avoid circular import through compat. - -if TYPE_CHECKING: - from typing_extensions import Protocol -else: - # typing.Protocol is only available starting from Python 3.8. It is also - # available from typing_extensions, but we don't want a runtime dependency - # on that. So use a dummy runtime implementation. - from typing import Generic - - Protocol = Generic +from .warning_types import PytestDeprecationWarning class OutcomeException(BaseException): """OutcomeException and its subclass instances indicate and contain info about test and collection outcomes.""" - def __init__(self, msg: Optional[str] = None, pytrace: bool = True) -> None: + def __init__(self, msg: str | None = None, pytrace: bool = True) -> None: if msg is not None and not isinstance(msg, str): error_msg = ( # type: ignore[unreachable] "{} expected string as 'msg' parameter, got '{}' instead.\n" @@ -58,7 +47,7 @@ class Skipped(OutcomeException): def __init__( self, - msg: Optional[str] = None, + msg: str | None = None, pytrace: bool = True, allow_module_level: bool = False, *, @@ -81,18 +70,18 @@ class Exit(Exception): """Raised for immediate program exits (no tracebacks/summaries).""" def __init__( - self, msg: str = "unknown reason", returncode: Optional[int] = None + self, msg: str = "unknown reason", returncode: int | None = None ) -> None: self.msg = msg self.returncode = returncode super().__init__(msg) -# Elaborate hack to work around https://github.com/python/mypy/issues/2087. -# Ideally would just be `exit.Exception = Exit` etc. +# We need a callable protocol to add attributes, for discussion see +# https://github.com/python/mypy/issues/2087. _F = TypeVar("_F", bound=Callable[..., object]) -_ET = TypeVar("_ET", bound=Type[BaseException]) +_ET = TypeVar("_ET", bound=type[BaseException]) class _WithException(Protocol[_F, _ET]): @@ -114,7 +103,8 @@ def decorate(func: _F) -> _WithException[_F, _ET]: @_with_exception(Exit) def exit( - reason: str = "", returncode: Optional[int] = None, *, msg: Optional[str] = None + reason: str = "", + returncode: int | None = None, ) -> NoReturn: """Exit testing process. @@ -123,29 +113,20 @@ def exit( only because `msg` is deprecated. :param returncode: - Return code to be used when exiting pytest. + Return code to be used when exiting pytest. None means the same as ``0`` (no error), same as :func:`sys.exit`. - :param msg: - Same as ``reason``, but deprecated. Will be removed in a future version, use ``reason`` instead. + :raises pytest.exit.Exception: + The exception that is raised. """ __tracebackhide__ = True - from _pytest.config import UsageError - - if reason and msg: - raise UsageError( - "cannot pass reason and msg to exit(), `msg` is deprecated, use `reason`." - ) - if not reason: - if msg is None: - raise UsageError("exit() requires a reason argument") - warnings.warn(KEYWORD_MSG_ARG.format(func="exit"), stacklevel=2) - reason = msg raise Exit(reason, returncode) @_with_exception(Skipped) def skip( - reason: str = "", *, allow_module_level: bool = False, msg: Optional[str] = None + reason: str = "", + *, + allow_module_level: bool = False, ) -> NoReturn: """Skip an executing test with the given message. @@ -164,8 +145,8 @@ def skip( Defaults to False. - :param msg: - Same as ``reason``, but deprecated. Will be removed in a future version, use ``reason`` instead. + :raises pytest.skip.Exception: + The exception that is raised. .. note:: It is better to use the :ref:`pytest.mark.skipif ref` marker when @@ -175,12 +156,11 @@ def skip( to skip a doctest statically. """ __tracebackhide__ = True - reason = _resolve_msg_to_reason("skip", reason, msg) raise Skipped(msg=reason, allow_module_level=allow_module_level) @_with_exception(Failed) -def fail(reason: str = "", pytrace: bool = True, msg: Optional[str] = None) -> NoReturn: +def fail(reason: str = "", pytrace: bool = True) -> NoReturn: """Explicitly fail an executing test with the given message. :param reason: @@ -190,50 +170,13 @@ def fail(reason: str = "", pytrace: bool = True, msg: Optional[str] = None) -> N If False, msg represents the full failure information and no python traceback will be reported. - :param msg: - Same as ``reason``, but deprecated. Will be removed in a future version, use ``reason`` instead. + :raises pytest.fail.Exception: + The exception that is raised. """ __tracebackhide__ = True - reason = _resolve_msg_to_reason("fail", reason, msg) raise Failed(msg=reason, pytrace=pytrace) -def _resolve_msg_to_reason( - func_name: str, reason: str, msg: Optional[str] = None -) -> str: - """ - Handles converting the deprecated msg parameter if provided into - reason, raising a deprecation warning. This function will be removed - when the optional msg argument is removed from here in future. - - :param str func_name: - The name of the offending function, this is formatted into the deprecation message. - - :param str reason: - The reason= passed into either pytest.fail() or pytest.skip() - - :param str msg: - The msg= passed into either pytest.fail() or pytest.skip(). This will - be converted into reason if it is provided to allow pytest.skip(msg=) or - pytest.fail(msg=) to continue working in the interim period. - - :returns: - The value to use as reason. - - """ - __tracebackhide__ = True - if msg is not None: - if reason: - from pytest import UsageError - - raise UsageError( - f"Passing both ``reason`` and ``msg`` to pytest.{func_name}(...) is not permitted." - ) - warnings.warn(KEYWORD_MSG_ARG.format(func=func_name), stacklevel=3) - reason = msg - return reason - - class XFailed(Failed): """Raised from an explicit call to pytest.xfail().""" @@ -244,6 +187,9 @@ def xfail(reason: str = "") -> NoReturn: This function should be called only during testing (setup, call or teardown). + No other code is executed after using ``xfail()`` (it is implemented + internally by raising an exception). + :param reason: The message to show the user as reason for the xfail. @@ -251,13 +197,20 @@ def xfail(reason: str = "") -> NoReturn: It is better to use the :ref:`pytest.mark.xfail ref` marker when possible to declare a test to be xfailed under certain conditions like known bugs or missing features. + + :raises pytest.xfail.Exception: + The exception that is raised. """ __tracebackhide__ = True raise XFailed(reason) def importorskip( - modname: str, minversion: Optional[str] = None, reason: Optional[str] = None + modname: str, + minversion: str | None = None, + reason: str | None = None, + *, + exc_type: type[ImportError] | None = None, ) -> Any: """Import and return the requested module ``modname``, or skip the current test if the module cannot be imported. @@ -270,30 +223,84 @@ def importorskip( :param reason: If given, this reason is shown as the message when the module cannot be imported. + :param exc_type: + The exception that should be captured in order to skip modules. + Must be :py:class:`ImportError` or a subclass. + + If the module can be imported but raises :class:`ImportError`, pytest will + issue a warning to the user, as often users expect the module not to be + found (which would raise :class:`ModuleNotFoundError` instead). + + This warning can be suppressed by passing ``exc_type=ImportError`` explicitly. + + See :ref:`import-or-skip-import-error` for details. + :returns: The imported module. This should be assigned to its canonical name. + :raises pytest.skip.Exception: + If the module cannot be imported. + Example:: docutils = pytest.importorskip("docutils") + + .. versionadded:: 8.2 + + The ``exc_type`` parameter. """ import warnings __tracebackhide__ = True compile(modname, "", "eval") # to catch syntaxerrors + # Until pytest 9.1, we will warn the user if we catch ImportError (instead of ModuleNotFoundError), + # as this might be hiding an installation/environment problem, which is not usually what is intended + # when using importorskip() (#11523). + # In 9.1, to keep the function signature compatible, we just change the code below to: + # 1. Use `exc_type = ModuleNotFoundError` if `exc_type` is not given. + # 2. Remove `warn_on_import` and the warning handling. + if exc_type is None: + exc_type = ImportError + warn_on_import_error = True + else: + warn_on_import_error = False + + skipped: Skipped | None = None + warning: Warning | None = None + with warnings.catch_warnings(): # Make sure to ignore ImportWarnings that might happen because # of existing directories with the same name we're trying to # import but without a __init__.py file. warnings.simplefilter("ignore") + try: __import__(modname) - except ImportError as exc: + except exc_type as exc: + # Do not raise or issue warnings inside the catch_warnings() block. if reason is None: reason = f"could not import {modname!r}: {exc}" - raise Skipped(reason, allow_module_level=True) from None + skipped = Skipped(reason, allow_module_level=True) + + if warn_on_import_error and not isinstance(exc, ModuleNotFoundError): + lines = [ + "", + f"Module '{modname}' was found, but when imported by pytest it raised:", + f" {exc!r}", + "In pytest 9.1 this warning will become an error by default.", + "You can fix the underlying problem, or alternatively overwrite this behavior and silence this " + "warning by passing exc_type=ImportError explicitly.", + "See https://docs.pytest.org/en/stable/deprecations.html#pytest-importorskip-default-behavior-regarding-importerror", + ] + warning = PytestDeprecationWarning("\n".join(lines)) + + if warning: + warnings.warn(warning, stacklevel=2) + if skipped: + raise skipped + mod = sys.modules[modname] if minversion is None: return mod @@ -304,8 +311,7 @@ def importorskip( if verattr is None or Version(verattr) < Version(minversion): raise Skipped( - "module %r has __version__ %r, required is: %r" - % (modname, verattr, minversion), + f"module {modname!r} has __version__ {verattr!r}, required is: {minversion!r}", allow_module_level=True, ) return mod diff --git a/src/_pytest/pastebin.py b/src/_pytest/pastebin.py index 22c7a622373..c7b39d96f02 100644 --- a/src/_pytest/pastebin.py +++ b/src/_pytest/pastebin.py @@ -1,15 +1,18 @@ +# mypy: allow-untyped-defs """Submit failure or test session information to a pastebin service.""" -import tempfile + +from __future__ import annotations + from io import StringIO +import tempfile from typing import IO -from typing import Union -import pytest from _pytest.config import Config from _pytest.config import create_terminal_writer from _pytest.config.argparsing import Parser from _pytest.stash import StashKey from _pytest.terminal import TerminalReporter +import pytest pastebinfile_key = StashKey[IO[bytes]]() @@ -17,7 +20,7 @@ def pytest_addoption(parser: Parser) -> None: group = parser.getgroup("terminal reporting") - group._addoption( + group.addoption( "--pastebin", metavar="mode", action="store", @@ -63,18 +66,19 @@ def pytest_unconfigure(config: Config) -> None: # Write summary. tr.write_sep("=", "Sending information to Paste Service") pastebinurl = create_new_paste(sessionlog) - tr.write_line("pastebin session-log: %s\n" % pastebinurl) + tr.write_line(f"pastebin session-log: {pastebinurl}\n") -def create_new_paste(contents: Union[str, bytes]) -> str: +def create_new_paste(contents: str | bytes) -> str: """Create a new paste using the bpaste.net service. :contents: Paste contents string. :returns: URL to the pasted contents, or an error message. """ import re - from urllib.request import urlopen + from urllib.error import HTTPError from urllib.parse import urlencode + from urllib.request import urlopen params = {"code": contents, "lexer": "text", "expiry": "1week"} url = "https://bpa.st" @@ -82,8 +86,11 @@ def create_new_paste(contents: Union[str, bytes]) -> str: response: str = ( urlopen(url, data=urlencode(params).encode("ascii")).read().decode("utf-8") ) - except OSError as exc_info: # urllib errors - return "bad response: %s" % exc_info + except HTTPError as e: + with e: # HTTPErrors are also http responses that must be closed! + return f"bad response: {e}" + except OSError as e: # eg urllib.error.URLError + return f"bad response: {e}" m = re.search(r'href="/raw/(\w+)"', response) if m: return f"{url}/show/{m.group(1)}" diff --git a/src/_pytest/pathlib.py b/src/_pytest/pathlib.py index 70383e4b504..b69e85404e7 100644 --- a/src/_pytest/pathlib.py +++ b/src/_pytest/pathlib.py @@ -1,20 +1,22 @@ +from __future__ import annotations + import atexit +from collections.abc import Callable +from collections.abc import Iterable +from collections.abc import Iterator import contextlib -import fnmatch -import importlib.util -import itertools -import os -import shutil -import sys -import types -import uuid -import warnings from enum import Enum from errno import EBADF from errno import ELOOP from errno import ENOENT from errno import ENOTDIR +import fnmatch from functools import partial +from importlib.machinery import ModuleSpec +from importlib.machinery import PathFinder +import importlib.util +import itertools +import os from os.path import expanduser from os.path import expandvars from os.path import isabs @@ -22,25 +24,26 @@ from pathlib import Path from pathlib import PurePath from posixpath import sep as posix_sep +import shutil +import sys +import types from types import ModuleType -from typing import Callable -from typing import Dict -from typing import Iterable -from typing import Iterator -from typing import List -from typing import Optional -from typing import Set -from typing import Tuple -from typing import Type +from typing import Any from typing import TypeVar -from typing import Union +import uuid +import warnings from _pytest.compat import assert_never from _pytest.outcomes import skip from _pytest.warning_types import PytestWarning -LOCK_TIMEOUT = 60 * 60 * 24 * 3 +if sys.version_info < (3, 11): + from importlib._bootstrap_external import _NamespaceLoader as NamespaceLoader +else: + from importlib.machinery import NamespaceLoader + +LOCK_TIMEOUT = 60 * 60 * 24 * 3 _AnyPurePath = TypeVar("_AnyPurePath", bound=PurePath) @@ -56,7 +59,7 @@ ) -def _ignore_error(exception): +def _ignore_error(exception: Exception) -> bool: return ( getattr(exception, "errno", None) in _IGNORED_ERRORS or getattr(exception, "winerror", None) in _IGNORED_WINERRORS @@ -68,12 +71,10 @@ def get_lock_path(path: _AnyPurePath) -> _AnyPurePath: def on_rm_rf_error( - func, + func: Callable[..., Any] | None, path: str, - excinfo: Union[ - BaseException, - Tuple[Type[BaseException], BaseException, Optional[types.TracebackType]], - ], + excinfo: BaseException + | tuple[type[BaseException], BaseException, types.TracebackType | None], *, start_path: Path, ) -> bool: @@ -101,9 +102,7 @@ def on_rm_rf_error( if func not in (os.open,): warnings.warn( PytestWarning( - "(rm_rf) unknown function {} when removing {}:\n{}: {}".format( - func, path, type(exc), exc - ) + f"(rm_rf) unknown function {func} when removing {path}:\n{type(exc)}: {exc}" ) ) return False @@ -171,23 +170,23 @@ def rm_rf(path: Path) -> None: shutil.rmtree(str(path), onerror=onerror) -def find_prefixed(root: Path, prefix: str) -> Iterator[Path]: - """Find all elements in root that begin with the prefix, case insensitive.""" +def find_prefixed(root: Path, prefix: str) -> Iterator[os.DirEntry[str]]: + """Find all elements in root that begin with the prefix, case-insensitive.""" l_prefix = prefix.lower() - for x in root.iterdir(): + for x in os.scandir(root): if x.name.lower().startswith(l_prefix): yield x -def extract_suffixes(iter: Iterable[PurePath], prefix: str) -> Iterator[str]: +def extract_suffixes(iter: Iterable[os.DirEntry[str]], prefix: str) -> Iterator[str]: """Return the parts of the paths following the prefix. :param iter: Iterator over path names. :param prefix: Expected prefix of the path names. """ p_len = len(prefix) - for p in iter: - yield p.name[p_len:] + for entry in iter: + yield entry.name[p_len:] def find_suffixes(root: Path, prefix: str) -> Iterator[str]: @@ -195,7 +194,7 @@ def find_suffixes(root: Path, prefix: str) -> Iterator[str]: return extract_suffixes(find_prefixed(root, prefix), prefix) -def parse_num(maybe_num) -> int: +def parse_num(maybe_num: str) -> int: """Parse number path suffixes, returns -1 on error.""" try: return int(maybe_num) @@ -203,9 +202,7 @@ def parse_num(maybe_num) -> int: return -1 -def _force_symlink( - root: Path, target: Union[str, PurePath], link_to: Union[str, Path] -) -> None: +def _force_symlink(root: Path, target: str | PurePath, link_to: str | Path) -> None: """Helper to create the current symlink. It's full of race conditions that are reasonably OK to ignore @@ -242,7 +239,7 @@ def make_numbered_dir(root: Path, prefix: str, mode: int = 0o700) -> Path: else: raise OSError( "could not create numbered dir with prefix " - "{prefix} in {root} after 10 tries".format(prefix=prefix, root=root) + f"{prefix} in {root} after 10 tries" ) @@ -263,7 +260,9 @@ def create_cleanup_lock(p: Path) -> Path: return lock_path -def register_cleanup_lock_removal(lock_path: Path, register=atexit.register): +def register_cleanup_lock_removal( + lock_path: Path, register: Any = atexit.register +) -> Any: """Register a cleanup function for removing a lock, by default on atexit.""" pid = os.getpid() @@ -346,15 +345,15 @@ def cleanup_candidates(root: Path, prefix: str, keep: int) -> Iterator[Path]: """List candidates for numbered directories to be removed - follows py.path.""" max_existing = max(map(parse_num, find_suffixes(root, prefix)), default=-1) max_delete = max_existing - keep - paths = find_prefixed(root, prefix) - paths, paths2 = itertools.tee(paths) - numbers = map(parse_num, extract_suffixes(paths2, prefix)) - for path, number in zip(paths, numbers): + entries = find_prefixed(root, prefix) + entries, entries2 = itertools.tee(entries) + numbers = map(parse_num, extract_suffixes(entries2, prefix)) + for entry, number in zip(entries, numbers): if number <= max_delete: - yield path + yield Path(entry) -def cleanup_dead_symlinks(root: Path): +def cleanup_dead_symlinks(root: Path) -> None: for left_dir in root.iterdir(): if left_dir.is_symlink(): if not left_dir.resolve().exists(): @@ -417,7 +416,7 @@ def resolve_from_str(input: str, rootpath: Path) -> Path: return rootpath.joinpath(input) -def fnmatch_ex(pattern: str, path: Union[str, "os.PathLike[str]"]) -> bool: +def fnmatch_ex(pattern: str, path: str | os.PathLike[str]) -> bool: """A port of FNMatcher from py.path.common which works with PurePath() instances. The difference between this algorithm and PurePath.match() is that the @@ -453,15 +452,19 @@ def fnmatch_ex(pattern: str, path: Union[str, "os.PathLike[str]"]) -> bool: return fnmatch.fnmatch(name, pattern) -def parts(s: str) -> Set[str]: +def parts(s: str) -> set[str]: parts = s.split(sep) return {sep.join(parts[: i + 1]) or sep for i in range(len(parts))} -def symlink_or_skip(src, dst, **kwargs): +def symlink_or_skip( + src: os.PathLike[str] | str, + dst: os.PathLike[str] | str, + **kwargs: Any, +) -> None: """Make a symlink, or skip the test in case symlinks are not supported.""" try: - os.symlink(str(src), str(dst), **kwargs) + os.symlink(src, dst, **kwargs) except OSError as e: skip(f"symlinks not supported: {e}") @@ -484,71 +487,90 @@ class ImportPathMismatchError(ImportError): def import_path( - p: Union[str, "os.PathLike[str]"], + path: str | os.PathLike[str], *, - mode: Union[str, ImportMode] = ImportMode.prepend, + mode: str | ImportMode = ImportMode.prepend, root: Path, + consider_namespace_packages: bool, ) -> ModuleType: - """Import and return a module from the given path, which can be a file (a module) or + """ + Import and return a module from the given path, which can be a file (a module) or a directory (a package). - The import mechanism used is controlled by the `mode` parameter: + :param path: + Path to the file to import. - * `mode == ImportMode.prepend`: the directory containing the module (or package, taking - `__init__.py` files into account) will be put at the *start* of `sys.path` before - being imported with `importlib.import_module`. + :param mode: + Controls the underlying import mechanism that will be used: - * `mode == ImportMode.append`: same as `prepend`, but the directory will be appended - to the end of `sys.path`, if not already in `sys.path`. + * ImportMode.prepend: the directory containing the module (or package, taking + `__init__.py` files into account) will be put at the *start* of `sys.path` before + being imported with `importlib.import_module`. - * `mode == ImportMode.importlib`: uses more fine control mechanisms provided by `importlib` - to import the module, which avoids having to muck with `sys.path` at all. It effectively - allows having same-named test modules in different places. + * ImportMode.append: same as `prepend`, but the directory will be appended + to the end of `sys.path`, if not already in `sys.path`. + + * ImportMode.importlib: uses more fine control mechanisms provided by `importlib` + to import the module, which avoids having to muck with `sys.path` at all. It effectively + allows having same-named test modules in different places. :param root: Used as an anchor when mode == ImportMode.importlib to obtain a unique name for the module being imported so it can safely be stored into ``sys.modules``. + :param consider_namespace_packages: + If True, consider namespace packages when resolving module names. + :raises ImportPathMismatchError: If after importing the given `path` and the module `__file__` are different. Only raised in `prepend` and `append` modes. """ + path = Path(path) mode = ImportMode(mode) - path = Path(p) - if not path.exists(): raise ImportError(path) if mode is ImportMode.importlib: - module_name = module_name_from_path(path, root) - - for meta_importer in sys.meta_path: - spec = meta_importer.find_spec(module_name, [str(path.parent)]) - if spec is not None: - break + # Try to import this module using the standard import mechanisms, but + # without touching sys.path. + try: + pkg_root, module_name = resolve_pkg_root_and_module_name( + path, consider_namespace_packages=consider_namespace_packages + ) + except CouldNotResolvePathError: + pass else: - spec = importlib.util.spec_from_file_location(module_name, str(path)) + # If the given module name is already in sys.modules, do not import it again. + with contextlib.suppress(KeyError): + return sys.modules[module_name] + + mod = _import_module_using_spec( + module_name, path, pkg_root, insert_modules=False + ) + if mod is not None: + return mod + + # Could not import the module with the current sys.path, so we fall back + # to importing the file as a single module, not being a part of a package. + module_name = module_name_from_path(path, root) + with contextlib.suppress(KeyError): + return sys.modules[module_name] - if spec is None: + mod = _import_module_using_spec( + module_name, path, path.parent, insert_modules=True + ) + if mod is None: raise ImportError(f"Can't find module {module_name} at location {path}") - mod = importlib.util.module_from_spec(spec) - sys.modules[module_name] = mod - spec.loader.exec_module(mod) # type: ignore[union-attr] - insert_missing_modules(sys.modules, module_name) return mod - pkg_path = resolve_package_path(path) - if pkg_path is not None: - pkg_root = pkg_path.parent - names = list(path.with_suffix("").relative_to(pkg_root).parts) - if names[-1] == "__init__": - names.pop() - module_name = ".".join(names) - else: - pkg_root = path.parent - module_name = path.stem + try: + pkg_root, module_name = resolve_pkg_root_and_module_name( + path, consider_namespace_packages=consider_namespace_packages + ) + except CouldNotResolvePathError: + pkg_root, module_name = path.parent, path.stem # Change sys.path permanently: restoring it at the end of this function would cause surprising # problems because of delayed imports: for example, a conftest.py file imported by this function @@ -590,6 +612,148 @@ def import_path( return mod +def _import_module_using_spec( + module_name: str, module_path: Path, module_location: Path, *, insert_modules: bool +) -> ModuleType | None: + """ + Tries to import a module by its canonical name, path, and its parent location. + + :param module_name: + The expected module name, will become the key of `sys.modules`. + + :param module_path: + The file path of the module, for example `/foo/bar/test_demo.py`. + If module is a package, pass the path to the `__init__.py` of the package. + If module is a namespace package, pass directory path. + + :param module_location: + The parent location of the module. + If module is a package, pass the directory containing the `__init__.py` file. + + :param insert_modules: + If True, will call `insert_missing_modules` to create empty intermediate modules + with made-up module names (when importing test files not reachable from `sys.path`). + + Example 1 of parent_module_*: + + module_name: "a.b.c.demo" + module_path: Path("a/b/c/demo.py") + module_location: Path("a/b/c/") + if "a.b.c" is package ("a/b/c/__init__.py" exists), then + parent_module_name: "a.b.c" + parent_module_path: Path("a/b/c/__init__.py") + parent_module_location: Path("a/b/c/") + else: + parent_module_name: "a.b.c" + parent_module_path: Path("a/b/c") + parent_module_location: Path("a/b/") + + Example 2 of parent_module_*: + + module_name: "a.b.c" + module_path: Path("a/b/c/__init__.py") + module_location: Path("a/b/c/") + if "a.b" is package ("a/b/__init__.py" exists), then + parent_module_name: "a.b" + parent_module_path: Path("a/b/__init__.py") + parent_module_location: Path("a/b/") + else: + parent_module_name: "a.b" + parent_module_path: Path("a/b/") + parent_module_location: Path("a/") + """ + # Attempt to import the parent module, seems is our responsibility: + # https://github.com/python/cpython/blob/73906d5c908c1e0b73c5436faeff7d93698fc074/Lib/importlib/_bootstrap.py#L1308-L1311 + parent_module_name, _, name = module_name.rpartition(".") + parent_module: ModuleType | None = None + if parent_module_name: + parent_module = sys.modules.get(parent_module_name) + # If the parent_module lacks the `__path__` attribute, AttributeError when finding a submodule's spec, + # requiring re-import according to the path. + need_reimport = not hasattr(parent_module, "__path__") + if parent_module is None or need_reimport: + # Get parent_location based on location, get parent_path based on path. + if module_path.name == "__init__.py": + # If the current module is in a package, + # need to leave the package first and then enter the parent module. + parent_module_path = module_path.parent.parent + else: + parent_module_path = module_path.parent + + if (parent_module_path / "__init__.py").is_file(): + # If the parent module is a package, loading by __init__.py file. + parent_module_path = parent_module_path / "__init__.py" + + parent_module = _import_module_using_spec( + parent_module_name, + parent_module_path, + parent_module_path.parent, + insert_modules=insert_modules, + ) + + # Checking with sys.meta_path first in case one of its hooks can import this module, + # such as our own assertion-rewrite hook. + for meta_importer in sys.meta_path: + module_name_of_meta = getattr(meta_importer.__class__, "__module__", "") + if module_name_of_meta == "_pytest.assertion.rewrite" and module_path.is_file(): + # Import modules in subdirectories by module_path + # to ensure assertion rewrites are not missed (#12659). + find_spec_path = [str(module_location), str(module_path)] + else: + find_spec_path = [str(module_location)] + + spec = meta_importer.find_spec(module_name, find_spec_path) + + if spec_matches_module_path(spec, module_path): + break + else: + loader = None + if module_path.is_dir(): + # The `spec_from_file_location` matches a loader based on the file extension by default. + # For a namespace package, need to manually specify a loader. + loader = NamespaceLoader(name, module_path, PathFinder()) # type: ignore[arg-type] + + spec = importlib.util.spec_from_file_location( + module_name, str(module_path), loader=loader + ) + + if spec_matches_module_path(spec, module_path): + assert spec is not None + # Find spec and import this module. + mod = importlib.util.module_from_spec(spec) + sys.modules[module_name] = mod + spec.loader.exec_module(mod) # type: ignore[union-attr] + + # Set this module as an attribute of the parent module (#12194). + if parent_module is not None: + setattr(parent_module, name, mod) + + if insert_modules: + insert_missing_modules(sys.modules, module_name) + return mod + + return None + + +def spec_matches_module_path(module_spec: ModuleSpec | None, module_path: Path) -> bool: + """Return true if the given ModuleSpec can be used to import the given module path.""" + if module_spec is None: + return False + + if module_spec.origin: + return Path(module_spec.origin) == module_path + + # Compare the path with the `module_spec.submodule_Search_Locations` in case + # the module is part of a namespace package. + # https://docs.python.org/3/library/importlib.html#importlib.machinery.ModuleSpec.submodule_search_locations + if module_spec.submodule_search_locations: # can be None. + for path in module_spec.submodule_search_locations: + if Path(path) == module_path: + return True + + return False + + # Implement a special _is_same function on Windows which returns True if the two filenames # compare equal, to circumvent os.path.samefile returning False for mounts in UNC (#7678). if sys.platform.startswith("win"): @@ -621,10 +785,20 @@ def module_name_from_path(path: Path, root: Path) -> str: # Use the parts for the relative path to the root path. path_parts = relative_path.parts + # Module name for packages do not contain the __init__ file, unless + # the `__init__.py` file is at the root. + if len(path_parts) >= 2 and path_parts[-1] == "__init__": + path_parts = path_parts[:-1] + + # Module names cannot contain ".", normalize them to "_". This prevents + # a directory having a "." in the name (".env.310" for example) causing extra intermediate modules. + # Also, important to replace "." at the start of paths, as those are considered relative imports. + path_parts = tuple(x.replace(".", "_") for x in path_parts) + return ".".join(path_parts) -def insert_missing_modules(modules: Dict[str, ModuleType], module_name: str) -> None: +def insert_missing_modules(modules: dict[str, ModuleType], module_name: str) -> None: """ Used by ``import_path`` to create intermediate modules when using mode=importlib. @@ -634,35 +808,44 @@ def insert_missing_modules(modules: Dict[str, ModuleType], module_name: str) -> """ module_parts = module_name.split(".") while module_name: - if module_name not in modules: - try: - # If sys.meta_path is empty, calling import_module will issue - # a warning and raise ModuleNotFoundError. To avoid the - # warning, we check sys.meta_path explicitly and raise the error - # ourselves to fall back to creating a dummy module. - if not sys.meta_path: - raise ModuleNotFoundError - importlib.import_module(module_name) - except ModuleNotFoundError: - module = ModuleType( - module_name, - doc="Empty module created by pytest's importmode=importlib.", - ) - modules[module_name] = module + parent_module_name, _, child_name = module_name.rpartition(".") + if parent_module_name: + parent_module = modules.get(parent_module_name) + if parent_module is None: + try: + # If sys.meta_path is empty, calling import_module will issue + # a warning and raise ModuleNotFoundError. To avoid the + # warning, we check sys.meta_path explicitly and raise the error + # ourselves to fall back to creating a dummy module. + if not sys.meta_path: + raise ModuleNotFoundError + parent_module = importlib.import_module(parent_module_name) + except ModuleNotFoundError: + parent_module = ModuleType( + module_name, + doc="Empty module created by pytest's importmode=importlib.", + ) + modules[parent_module_name] = parent_module + + # Add child attribute to the parent that can reference the child + # modules. + if not hasattr(parent_module, child_name): + setattr(parent_module, child_name, modules[module_name]) + module_parts.pop(-1) module_name = ".".join(module_parts) -def resolve_package_path(path: Path) -> Optional[Path]: +def resolve_package_path(path: Path) -> Path | None: """Return the Python package path by looking for the last directory upwards which still contains an __init__.py. - Returns None if it can not be determined. + Returns None if it cannot be determined. """ result = None for parent in itertools.chain((path,), path.parents): if parent.is_dir(): - if not parent.joinpath("__init__.py").is_file(): + if not (parent / "__init__.py").is_file(): break if not parent.name.isidentifier(): break @@ -670,30 +853,135 @@ def resolve_package_path(path: Path) -> Optional[Path]: return result -def scandir(path: Union[str, "os.PathLike[str]"]) -> List["os.DirEntry[str]"]: +def resolve_pkg_root_and_module_name( + path: Path, *, consider_namespace_packages: bool = False +) -> tuple[Path, str]: + """ + Return the path to the directory of the root package that contains the + given Python file, and its module name: + + src/ + app/ + __init__.py + core/ + __init__.py + models.py + + Passing the full path to `models.py` will yield Path("src") and "app.core.models". + + If consider_namespace_packages is True, then we additionally check upwards in the hierarchy + for namespace packages: + + https://packaging.python.org/en/latest/guides/packaging-namespace-packages + + Raises CouldNotResolvePathError if the given path does not belong to a package (missing any __init__.py files). + """ + pkg_root: Path | None = None + pkg_path = resolve_package_path(path) + if pkg_path is not None: + pkg_root = pkg_path.parent + if consider_namespace_packages: + start = pkg_root if pkg_root is not None else path.parent + for candidate in (start, *start.parents): + module_name = compute_module_name(candidate, path) + if module_name and is_importable(module_name, path): + # Point the pkg_root to the root of the namespace package. + pkg_root = candidate + break + + if pkg_root is not None: + module_name = compute_module_name(pkg_root, path) + if module_name: + return pkg_root, module_name + + raise CouldNotResolvePathError(f"Could not resolve for {path}") + + +def is_importable(module_name: str, module_path: Path) -> bool: + """ + Return if the given module path could be imported normally by Python, akin to the user + entering the REPL and importing the corresponding module name directly, and corresponds + to the module_path specified. + + :param module_name: + Full module name that we want to check if is importable. + For example, "app.models". + + :param module_path: + Full path to the python module/package we want to check if is importable. + For example, "/projects/src/app/models.py". + """ + try: + # Note this is different from what we do in ``_import_module_using_spec``, where we explicitly search through + # sys.meta_path to be able to pass the path of the module that we want to import (``meta_importer.find_spec``). + # Using importlib.util.find_spec() is different, it gives the same results as trying to import + # the module normally in the REPL. + spec = importlib.util.find_spec(module_name) + except (ImportError, ValueError, ImportWarning): + return False + else: + return spec_matches_module_path(spec, module_path) + + +def compute_module_name(root: Path, module_path: Path) -> str | None: + """Compute a module name based on a path and a root anchor.""" + try: + path_without_suffix = module_path.with_suffix("") + except ValueError: + # Empty paths (such as Path.cwd()) might break meta_path hooks (like our own assertion rewriter). + return None + + try: + relative = path_without_suffix.relative_to(root) + except ValueError: # pragma: no cover + return None + names = list(relative.parts) + if not names: + return None + if names[-1] == "__init__": + names.pop() + return ".".join(names) + + +class CouldNotResolvePathError(Exception): + """Custom exception raised by resolve_pkg_root_and_module_name.""" + + +def scandir( + path: str | os.PathLike[str], + sort_key: Callable[[os.DirEntry[str]], object] = lambda entry: entry.name, +) -> list[os.DirEntry[str]]: """Scan a directory recursively, in breadth-first order. - The returned entries are sorted. + The returned entries are sorted according to the given key. + The default is to sort by name. + If the directory does not exist, return an empty list. """ entries = [] - with os.scandir(path) as s: - # Skip entries with symlink loops and other brokenness, so the caller - # doesn't have to deal with it. + # Attempt to create a scandir iterator for the given path. + try: + scandir_iter = os.scandir(path) + except FileNotFoundError: + # If the directory does not exist, return an empty list. + return [] + # Use the scandir iterator in a context manager to ensure it is properly closed. + with scandir_iter as s: for entry in s: try: entry.is_file() except OSError as err: if _ignore_error(err): continue + # Reraise non-ignorable errors to avoid hiding issues. raise entries.append(entry) - entries.sort(key=lambda entry: entry.name) + entries.sort(key=sort_key) # type: ignore[arg-type] return entries def visit( - path: Union[str, "os.PathLike[str]"], recurse: Callable[["os.DirEntry[str]"], bool] -) -> Iterator["os.DirEntry[str]"]: + path: str | os.PathLike[str], recurse: Callable[[os.DirEntry[str]], bool] +) -> Iterator[os.DirEntry[str]]: """Walk a directory recursively, in breadth-first order. The `recurse` predicate determines whether a directory is recursed. @@ -707,16 +995,16 @@ def visit( yield from visit(entry.path, recurse) -def absolutepath(path: Union[Path, str]) -> Path: +def absolutepath(path: str | os.PathLike[str]) -> Path: """Convert a path to an absolute path using os.path.abspath. Prefer this over Path.resolve() (see #6523). Prefer this over Path.absolute() (not public, doesn't normalize). """ - return Path(os.path.abspath(str(path))) + return Path(os.path.abspath(path)) -def commonpath(path1: Path, path2: Path) -> Optional[Path]: +def commonpath(path1: Path, path2: Path) -> Path | None: """Return the common part shared with the other path, or None if there is no common part. @@ -757,19 +1045,11 @@ def bestrelpath(directory: Path, dest: Path) -> str: ) -# Originates from py. path.local.copy(), with siginficant trims and adjustments. -# TODO(py38): Replace with shutil.copytree(..., symlinks=True, dirs_exist_ok=True) -def copytree(source: Path, target: Path) -> None: - """Recursively copy a source directory to target.""" - assert source.is_dir() - for entry in visit(source, recurse=lambda entry: not entry.is_symlink()): - x = Path(entry) - relpath = x.relative_to(source) - newx = target / relpath - newx.parent.mkdir(exist_ok=True) - if x.is_symlink(): - newx.symlink_to(os.readlink(x)) - elif x.is_file(): - shutil.copyfile(x, newx) - elif x.is_dir(): - newx.mkdir(exist_ok=True) +def safe_exists(p: Path) -> bool: + """Like Path.exists(), but account for input arguments that might be too long (#11394).""" + try: + return p.exists() + except (ValueError, OSError): + # ValueError: stat: path too long for Windows + # OSError: [WinError 123] The filename, directory name, or volume label syntax is incorrect + return False diff --git a/src/_pytest/pytester.py b/src/_pytest/pytester.py index 3df52ebe88c..59d2b0befe9 100644 --- a/src/_pytest/pytester.py +++ b/src/_pytest/pytester.py @@ -1,37 +1,38 @@ +# mypy: allow-untyped-defs """(Disabled by default) support for testing pytest and pytest plugins. PYTEST_DONT_REWRITE """ + +from __future__ import annotations + import collections.abc +from collections.abc import Callable +from collections.abc import Generator +from collections.abc import Iterable +from collections.abc import Sequence import contextlib +from fnmatch import fnmatch import gc import importlib +from io import StringIO import locale import os +from pathlib import Path import platform import re import shutil import subprocess import sys import traceback -from fnmatch import fnmatch -from io import StringIO -from pathlib import Path from typing import Any -from typing import Callable -from typing import Dict -from typing import Generator +from typing import Final +from typing import final from typing import IO -from typing import Iterable -from typing import List -from typing import Optional +from typing import Literal from typing import overload -from typing import Sequence from typing import TextIO -from typing import Tuple -from typing import Type from typing import TYPE_CHECKING -from typing import Union from weakref import WeakKeyDictionary from iniconfig import IniConfig @@ -40,7 +41,6 @@ from _pytest import timing from _pytest._code import Source from _pytest.capture import _get_multicapture -from _pytest.compat import final from _pytest.compat import NOTSET from _pytest.compat import NotSetType from _pytest.config import _PluggyPlugin @@ -61,18 +61,14 @@ from _pytest.outcomes import importorskip from _pytest.outcomes import skip from _pytest.pathlib import bestrelpath -from _pytest.pathlib import copytree from _pytest.pathlib import make_numbered_dir from _pytest.reports import CollectReport from _pytest.reports import TestReport from _pytest.tmpdir import TempPathFactory -from _pytest.warning_types import PytestWarning +from _pytest.warning_types import PytestFDWarning if TYPE_CHECKING: - from typing_extensions import Final - from typing_extensions import Literal - import pexpect @@ -123,14 +119,19 @@ def pytest_configure(config: Config) -> None: class LsofFdLeakChecker: - def get_open_files(self) -> List[Tuple[str, str]]: + def get_open_files(self) -> list[tuple[str, str]]: + if sys.version_info >= (3, 11): + # New in Python 3.11, ignores utf-8 mode + encoding = locale.getencoding() + else: + encoding = locale.getpreferredencoding(False) out = subprocess.run( ("lsof", "-Ffn0", "-p", str(os.getpid())), stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, check=True, text=True, - encoding=locale.getpreferredencoding(False), + encoding=encoding, ).stdout def isopen(line: str) -> bool: @@ -163,36 +164,38 @@ def matching_platform(self) -> bool: else: return True - @hookimpl(hookwrapper=True, tryfirst=True) - def pytest_runtest_protocol(self, item: Item) -> Generator[None, None, None]: + @hookimpl(wrapper=True, tryfirst=True) + def pytest_runtest_protocol(self, item: Item) -> Generator[None, object, object]: lines1 = self.get_open_files() - yield - if hasattr(sys, "pypy_version_info"): - gc.collect() - lines2 = self.get_open_files() - - new_fds = {t[0] for t in lines2} - {t[0] for t in lines1} - leaked_files = [t for t in lines2 if t[0] in new_fds] - if leaked_files: - error = [ - "***** %s FD leakage detected" % len(leaked_files), - *(str(f) for f in leaked_files), - "*** Before:", - *(str(f) for f in lines1), - "*** After:", - *(str(f) for f in lines2), - "***** %s FD leakage detected" % len(leaked_files), - "*** function %s:%s: %s " % item.location, - "See issue #2366", - ] - item.warn(PytestWarning("\n".join(error))) + try: + return (yield) + finally: + if hasattr(sys, "pypy_version_info"): + gc.collect() + lines2 = self.get_open_files() + + new_fds = {t[0] for t in lines2} - {t[0] for t in lines1} + leaked_files = [t for t in lines2 if t[0] in new_fds] + if leaked_files: + error = [ + f"***** {len(leaked_files)} FD leakage detected", + *(str(f) for f in leaked_files), + "*** Before:", + *(str(f) for f in lines1), + "*** After:", + *(str(f) for f in lines2), + f"***** {len(leaked_files)} FD leakage detected", + "*** function {}:{}: {} ".format(*item.location), + "See issue #2366", + ] + item.warn(PytestFDWarning("\n".join(error))) # used at least by pytest-xdist plugin @fixture -def _pytest(request: FixtureRequest) -> "PytestArg": +def _pytest(request: FixtureRequest) -> PytestArg: """Return a helper which offers a gethookrecorder(hook) method which returns a HookRecorder instance which helps to make assertions about called hooks.""" @@ -203,13 +206,13 @@ class PytestArg: def __init__(self, request: FixtureRequest) -> None: self._request = request - def gethookrecorder(self, hook) -> "HookRecorder": + def gethookrecorder(self, hook) -> HookRecorder: hookrecorder = HookRecorder(hook._pm) self._request.addfinalizer(hookrecorder.finish_recording) return hookrecorder -def get_public_names(values: Iterable[str]) -> List[str]: +def get_public_names(values: Iterable[str]) -> list[str]: """Only return names from iterator values without a leading underscore.""" return [x for x in values if x[0] != "_"] @@ -239,8 +242,7 @@ def __repr__(self) -> str: if TYPE_CHECKING: # The class has undetermined attributes, this tells mypy about it. - def __getattr__(self, key: str): - ... + def __getattr__(self, key: str): ... @final @@ -259,8 +261,8 @@ def __init__( check_ispytest(_ispytest) self._pluginmanager = pluginmanager - self.calls: List[RecordedHookCall] = [] - self.ret: Optional[Union[int, ExitCode]] = None + self.calls: list[RecordedHookCall] = [] + self.ret: int | ExitCode | None = None def before(hook_name: str, hook_impls, kwargs) -> None: self.calls.append(RecordedHookCall(hook_name, kwargs)) @@ -273,17 +275,18 @@ def after(outcome, hook_name: str, hook_impls, kwargs) -> None: def finish_recording(self) -> None: self._undo_wrapping() - def getcalls(self, names: Union[str, Iterable[str]]) -> List[RecordedHookCall]: + def getcalls(self, names: str | Iterable[str]) -> list[RecordedHookCall]: """Get all recorded calls to hooks with the given names (or name).""" if isinstance(names, str): names = names.split() return [call for call in self.calls if call._name in names] - def assert_contains(self, entries: Sequence[Tuple[str, str]]) -> None: + def assert_contains(self, entries: Sequence[tuple[str, str]]) -> None: __tracebackhide__ = True i = 0 entries = list(entries) - backlocals = sys._getframe(1).f_locals + # Since Python 3.13, f_locals is not a dict, but eval requires a dict. + backlocals = dict(sys._getframe(1).f_locals) while entries: name, check = entries.pop(0) for ind, call in enumerate(self.calls[i:]): @@ -307,7 +310,7 @@ def popcall(self, name: str) -> RecordedHookCall: del self.calls[i] return call lines = [f"could not find call {name!r}, in:"] - lines.extend([" %s" % x for x in self.calls]) + lines.extend([f" {x}" for x in self.calls]) fail("\n".join(lines)) def getcall(self, name: str) -> RecordedHookCall: @@ -320,45 +323,42 @@ def getcall(self, name: str) -> RecordedHookCall: @overload def getreports( self, - names: "Literal['pytest_collectreport']", - ) -> Sequence[CollectReport]: - ... + names: Literal["pytest_collectreport"], + ) -> Sequence[CollectReport]: ... @overload def getreports( self, - names: "Literal['pytest_runtest_logreport']", - ) -> Sequence[TestReport]: - ... + names: Literal["pytest_runtest_logreport"], + ) -> Sequence[TestReport]: ... @overload def getreports( self, - names: Union[str, Iterable[str]] = ( + names: str | Iterable[str] = ( "pytest_collectreport", "pytest_runtest_logreport", ), - ) -> Sequence[Union[CollectReport, TestReport]]: - ... + ) -> Sequence[CollectReport | TestReport]: ... def getreports( self, - names: Union[str, Iterable[str]] = ( + names: str | Iterable[str] = ( "pytest_collectreport", "pytest_runtest_logreport", ), - ) -> Sequence[Union[CollectReport, TestReport]]: + ) -> Sequence[CollectReport | TestReport]: return [x.report for x in self.getcalls(names)] def matchreport( self, inamepart: str = "", - names: Union[str, Iterable[str]] = ( + names: str | Iterable[str] = ( "pytest_runtest_logreport", "pytest_collectreport", ), - when: Optional[str] = None, - ) -> Union[CollectReport, TestReport]: + when: str | None = None, + ) -> CollectReport | TestReport: """Return a testreport whose dotted import path matches.""" values = [] for rep in self.getreports(names=names): @@ -371,48 +371,43 @@ def matchreport( values.append(rep) if not values: raise ValueError( - "could not find test report matching %r: " - "no test reports at all!" % (inamepart,) + f"could not find test report matching {inamepart!r}: " + "no test reports at all!" ) if len(values) > 1: raise ValueError( - "found 2 or more testreports matching {!r}: {}".format( - inamepart, values - ) + f"found 2 or more testreports matching {inamepart!r}: {values}" ) return values[0] @overload def getfailures( self, - names: "Literal['pytest_collectreport']", - ) -> Sequence[CollectReport]: - ... + names: Literal["pytest_collectreport"], + ) -> Sequence[CollectReport]: ... @overload def getfailures( self, - names: "Literal['pytest_runtest_logreport']", - ) -> Sequence[TestReport]: - ... + names: Literal["pytest_runtest_logreport"], + ) -> Sequence[TestReport]: ... @overload def getfailures( self, - names: Union[str, Iterable[str]] = ( + names: str | Iterable[str] = ( "pytest_collectreport", "pytest_runtest_logreport", ), - ) -> Sequence[Union[CollectReport, TestReport]]: - ... + ) -> Sequence[CollectReport | TestReport]: ... def getfailures( self, - names: Union[str, Iterable[str]] = ( + names: str | Iterable[str] = ( "pytest_collectreport", "pytest_runtest_logreport", ), - ) -> Sequence[Union[CollectReport, TestReport]]: + ) -> Sequence[CollectReport | TestReport]: return [rep for rep in self.getreports(names) if rep.failed] def getfailedcollections(self) -> Sequence[CollectReport]: @@ -420,10 +415,10 @@ def getfailedcollections(self) -> Sequence[CollectReport]: def listoutcomes( self, - ) -> Tuple[ + ) -> tuple[ Sequence[TestReport], - Sequence[Union[CollectReport, TestReport]], - Sequence[Union[CollectReport, TestReport]], + Sequence[CollectReport | TestReport], + Sequence[CollectReport | TestReport], ]: passed = [] skipped = [] @@ -442,7 +437,7 @@ def listoutcomes( failed.append(rep) return passed, skipped, failed - def countoutcomes(self) -> List[int]: + def countoutcomes(self) -> list[int]: return [len(x) for x in self.listoutcomes()] def assertoutcome(self, passed: int = 0, skipped: int = 0, failed: int = 0) -> None: @@ -462,14 +457,14 @@ def clear(self) -> None: @fixture -def linecomp() -> "LineComp": +def linecomp() -> LineComp: """A :class: `LineComp` instance for checking that an input linearly contains a sequence of strings.""" return LineComp() @fixture(name="LineMatcher") -def LineMatcher_fixture(request: FixtureRequest) -> Type["LineMatcher"]: +def LineMatcher_fixture(request: FixtureRequest) -> type[LineMatcher]: """A reference to the :class: `LineMatcher`. This is instantiable with a list of lines (without their trailing newlines). @@ -481,7 +476,7 @@ def LineMatcher_fixture(request: FixtureRequest) -> Type["LineMatcher"]: @fixture def pytester( request: FixtureRequest, tmp_path_factory: TempPathFactory, monkeypatch: MonkeyPatch -) -> "Pytester": +) -> Pytester: """ Facilities to write tests/configuration files, execute pytest in isolation, and match against expected output, perfect for black-box testing of pytest plugins. @@ -496,7 +491,7 @@ def pytester( @fixture -def _sys_snapshot() -> Generator[None, None, None]: +def _sys_snapshot() -> Generator[None]: snappaths = SysPathsSnapshot() snapmods = SysModulesSnapshot() yield @@ -505,7 +500,7 @@ def _sys_snapshot() -> Generator[None, None, None]: @fixture -def _config_for_test() -> Generator[Config, None, None]: +def _config_for_test() -> Generator[Config]: from _pytest.config import get_config config = get_config() @@ -525,13 +520,13 @@ class RunResult: def __init__( self, - ret: Union[int, ExitCode], - outlines: List[str], - errlines: List[str], + ret: int | ExitCode, + outlines: list[str], + errlines: list[str], duration: float, ) -> None: try: - self.ret: Union[int, ExitCode] = ExitCode(ret) + self.ret: int | ExitCode = ExitCode(ret) """The return value.""" except ValueError: self.ret = ret @@ -552,11 +547,13 @@ def __init__( def __repr__(self) -> str: return ( - "" - % (self.ret, len(self.stdout.lines), len(self.stderr.lines), self.duration) + f"" ) - def parseoutcomes(self) -> Dict[str, int]: + def parseoutcomes(self) -> dict[str, int]: """Return a dictionary of outcome noun -> count from parsing the terminal output that the test process produced. @@ -569,7 +566,7 @@ def parseoutcomes(self) -> Dict[str, int]: return self.parse_summary_nouns(self.outlines) @classmethod - def parse_summary_nouns(cls, lines) -> Dict[str, int]: + def parse_summary_nouns(cls, lines) -> dict[str, int]: """Extract the nouns from a pytest terminal summary line. It always returns the plural noun for consistency:: @@ -600,8 +597,8 @@ def assert_outcomes( errors: int = 0, xpassed: int = 0, xfailed: int = 0, - warnings: Optional[int] = None, - deselected: Optional[int] = None, + warnings: int | None = None, + deselected: int | None = None, ) -> None: """ Assert that the specified outcomes appear with the respective @@ -626,16 +623,8 @@ def assert_outcomes( ) -class CwdSnapshot: - def __init__(self) -> None: - self.__saved = os.getcwd() - - def restore(self) -> None: - os.chdir(self.__saved) - - class SysModulesSnapshot: - def __init__(self, preserve: Optional[Callable[[str], bool]] = None) -> None: + def __init__(self, preserve: Callable[[str], bool] | None = None) -> None: self.__preserve = preserve self.__saved = dict(sys.modules) @@ -668,7 +657,7 @@ class Pytester: __test__ = False - CLOSE_STDIN: "Final" = NOTSET + CLOSE_STDIN: Final = NOTSET class TimeoutExpired(Exception): pass @@ -683,9 +672,9 @@ def __init__( ) -> None: check_ispytest(_ispytest) self._request = request - self._mod_collections: WeakKeyDictionary[ - Collector, List[Union[Item, Collector]] - ] = WeakKeyDictionary() + self._mod_collections: WeakKeyDictionary[Collector, list[Item | Collector]] = ( + WeakKeyDictionary() + ) if request.function: name: str = request.function.__name__ else: @@ -696,16 +685,15 @@ def __init__( #: :py:meth:`runpytest`. Initially this is an empty list but plugins can #: be added to the list. The type of items to add to the list depends on #: the method using them so refer to them for details. - self.plugins: List[Union[str, _PluggyPlugin]] = [] - self._cwd_snapshot = CwdSnapshot() + self.plugins: list[str | _PluggyPlugin] = [] self._sys_path_snapshot = SysPathsSnapshot() self._sys_modules_snapshot = self.__take_sys_modules_snapshot() - self.chdir() self._request.addfinalizer(self._finalize) self._method = self._request.config.getoption("--runpytest") self._test_tmproot = tmp_path_factory.mktemp(f"tmp-{name}", numbered=True) self._monkeypatch = mp = monkeypatch + self.chdir() mp.setenv("PYTEST_DEBUG_TEMPROOT", str(self._test_tmproot)) # Ensure no unexpected caching via tox. mp.delenv("TOX_ENV_DIR", raising=False) @@ -736,7 +724,6 @@ def _finalize(self) -> None: """ self._sys_modules_snapshot.restore() self._sys_path_snapshot.restore() - self._cwd_snapshot.restore() def __take_sys_modules_snapshot(self) -> SysModulesSnapshot: # Some zope modules used by twisted-related tests keep internal state @@ -752,7 +739,7 @@ def preserve_module(name): def make_hook_recorder(self, pluginmanager: PytestPluginManager) -> HookRecorder: """Create a new :class:`HookRecorder` for a :class:`PytestPluginManager`.""" - pluginmanager.reprec = reprec = HookRecorder(pluginmanager, _ispytest=True) + pluginmanager.reprec = reprec = HookRecorder(pluginmanager, _ispytest=True) # type: ignore[attr-defined] self._request.addfinalizer(reprec.finish_recording) return reprec @@ -761,23 +748,26 @@ def chdir(self) -> None: This is done automatically upon instantiation. """ - os.chdir(self.path) + self._monkeypatch.chdir(self.path) def _makefile( self, ext: str, - lines: Sequence[Union[Any, bytes]], - files: Dict[str, str], + lines: Sequence[Any | bytes], + files: dict[str, str], encoding: str = "utf-8", ) -> Path: items = list(files.items()) + if ext is None: + raise TypeError("ext must not be None") + if ext and not ext.startswith("."): raise ValueError( f"pytester.makefile expects a file extension, try .{ext} instead of {ext}" ) - def to_text(s: Union[Any, bytes]) -> str: + def to_text(s: Any | bytes) -> str: return s.decode(encoding) if isinstance(s, bytes) else str(s) if lines: @@ -830,7 +820,7 @@ def makefile(self, ext: str, *args: str, **kwargs: str) -> Path: return self._makefile(ext, args, kwargs) def makeconftest(self, source: str) -> Path: - """Write a contest.py file. + """Write a conftest.py file. :param source: The contents. :returns: The conftest.py file. @@ -900,9 +890,7 @@ def test_something(pytester): """ return self._makefile(".txt", args, kwargs) - def syspathinsert( - self, path: Optional[Union[str, "os.PathLike[str]"]] = None - ) -> None: + def syspathinsert(self, path: str | os.PathLike[str] | None = None) -> None: """Prepend a directory to sys.path, defaults to :attr:`path`. This is undone automatically when this object dies at the end of each @@ -916,19 +904,20 @@ def syspathinsert( self._monkeypatch.syspath_prepend(str(path)) - def mkdir(self, name: Union[str, "os.PathLike[str]"]) -> Path: + def mkdir(self, name: str | os.PathLike[str]) -> Path: """Create a new (sub)directory. :param name: The name of the directory, relative to the pytester path. :returns: The created directory. + :rtype: pathlib.Path """ p = self.path / name p.mkdir() return p - def mkpydir(self, name: Union[str, "os.PathLike[str]"]) -> Path: + def mkpydir(self, name: str | os.PathLike[str]) -> Path: """Create a new python package. This creates a (sub)directory with an empty ``__init__.py`` file so it @@ -939,13 +928,14 @@ def mkpydir(self, name: Union[str, "os.PathLike[str]"]) -> Path: p.joinpath("__init__.py").touch() return p - def copy_example(self, name: Optional[str] = None) -> Path: + def copy_example(self, name: str | None = None) -> Path: """Copy file from project's directory into the testdir. :param name: The name of the file to copy. :return: Path to the copied directory (inside ``self.path``). + :rtype: pathlib.Path """ example_dir_ = self._request.config.getini("pytester_example_dir") if example_dir_ is None: @@ -973,7 +963,7 @@ def copy_example(self, name: Optional[str] = None) -> Path: example_path = example_dir.joinpath(name) if example_path.is_dir() and not example_path.joinpath("__init__.py").is_file(): - copytree(example_path, self.path) + shutil.copytree(example_path, self.path, symlinks=True, dirs_exist_ok=True) return self.path elif example_path.is_file(): result = self.path.joinpath(example_path.name) @@ -984,9 +974,7 @@ def copy_example(self, name: Optional[str] = None) -> Path: f'example "{example_path}" is not found as a file or directory' ) - def getnode( - self, config: Config, arg: Union[str, "os.PathLike[str]"] - ) -> Union[Collector, Item]: + def getnode(self, config: Config, arg: str | os.PathLike[str]) -> Collector | Item: """Get the collection node of a file. :param config: @@ -1005,9 +993,7 @@ def getnode( config.hook.pytest_sessionfinish(session=session, exitstatus=ExitCode.OK) return res - def getpathnode( - self, path: Union[str, "os.PathLike[str]"] - ) -> Union[Collector, Item]: + def getpathnode(self, path: str | os.PathLike[str]) -> Collector | Item: """Return the collection node of a file. This is like :py:meth:`getnode` but uses :py:meth:`parseconfigure` to @@ -1027,7 +1013,7 @@ def getpathnode( config.hook.pytest_sessionfinish(session=session, exitstatus=ExitCode.OK) return res - def genitems(self, colitems: Sequence[Union[Item, Collector]]) -> List[Item]: + def genitems(self, colitems: Sequence[Item | Collector]) -> list[Item]: """Generate all test items from a collection node. This recurses into the collection node and returns a list of all the @@ -1039,7 +1025,7 @@ def genitems(self, colitems: Sequence[Union[Item, Collector]]) -> List[Item]: The collected items. """ session = colitems[0].session - result: List[Item] = [] + result: list[Item] = [] for colitem in colitems: result.extend(session.genitems(colitem)) return result @@ -1050,7 +1036,7 @@ def runitem(self, source: str) -> Any: The calling test instance (class containing the test method) must provide a ``.getrunner()`` method which should return a runner which can run the test protocol for a single item, e.g. - :py:func:`_pytest.runner.runtestprotocol`. + ``_pytest.runner.runtestprotocol``. """ # used from runner functional tests item = self.getitem(source) @@ -1070,11 +1056,11 @@ def inline_runsource(self, source: str, *cmdlineargs) -> HookRecorder: :param cmdlineargs: Any extra command line arguments to use. """ p = self.makepyfile(source) - values = list(cmdlineargs) + [p] + values = [*list(cmdlineargs), p] return self.inline_run(*values) - def inline_genitems(self, *args) -> Tuple[List[Item], HookRecorder]: - """Run ``pytest.main(['--collectonly'])`` in-process. + def inline_genitems(self, *args) -> tuple[list[Item], HookRecorder]: + """Run ``pytest.main(['--collect-only'])`` in-process. Runs the :py:func:`pytest.main` function to run all of pytest inside the test process itself like :py:meth:`inline_run`, but returns a @@ -1086,7 +1072,7 @@ def inline_genitems(self, *args) -> Tuple[List[Item], HookRecorder]: def inline_run( self, - *args: Union[str, "os.PathLike[str]"], + *args: str | os.PathLike[str], plugins=(), no_reraise_ctrlc: bool = False, ) -> HookRecorder: @@ -1106,6 +1092,8 @@ def inline_run( Typically we reraise keyboard interrupts from the child run. If True, the KeyboardInterrupt exception is captured. """ + from _pytest.unraisableexception import gc_collect_iterations_key + # (maybe a cpython bug?) the importlib cache sometimes isn't updated # properly between file creation and inline_run (especially if imports # are interspersed with file creation) @@ -1129,11 +1117,16 @@ def inline_run( rec = [] - class Collect: - def pytest_configure(x, config: Config) -> None: + class PytesterHelperPlugin: + @staticmethod + def pytest_configure(config: Config) -> None: rec.append(self.make_hook_recorder(config.pluginmanager)) - plugins.append(Collect()) + # The unraisable plugin GC collect slows down inline + # pytester runs too much. + config.stash[gc_collect_iterations_key] = 0 + + plugins.append(PytesterHelperPlugin()) ret = main([str(x) for x in args], plugins=plugins) if len(rec) == 1: reprec = rec.pop() @@ -1156,7 +1149,7 @@ class reprec: # type: ignore finalizer() def runpytest_inprocess( - self, *args: Union[str, "os.PathLike[str]"], **kwargs: Any + self, *args: str | os.PathLike[str], **kwargs: Any ) -> RunResult: """Return result of running pytest in-process, providing a similar interface to what self.runpytest() provides.""" @@ -1164,7 +1157,7 @@ def runpytest_inprocess( if syspathinsert: self.syspathinsert() - now = timing.time() + instant = timing.Instant() capture = _get_multicapture("sys") capture.start_capturing() try: @@ -1194,14 +1187,12 @@ class reprec: # type: ignore assert reprec.ret is not None res = RunResult( - reprec.ret, out.splitlines(), err.splitlines(), timing.time() - now + reprec.ret, out.splitlines(), err.splitlines(), instant.elapsed().seconds ) res.reprec = reprec # type: ignore return res - def runpytest( - self, *args: Union[str, "os.PathLike[str]"], **kwargs: Any - ) -> RunResult: + def runpytest(self, *args: str | os.PathLike[str], **kwargs: Any) -> RunResult: """Run pytest inline or in a subprocess, depending on the command line option "--runpytest" and return a :py:class:`~pytest.RunResult`.""" new_args = self._ensure_basetemp(args) @@ -1212,17 +1203,19 @@ def runpytest( raise RuntimeError(f"Unrecognized runpytest option: {self._method}") def _ensure_basetemp( - self, args: Sequence[Union[str, "os.PathLike[str]"]] - ) -> List[Union[str, "os.PathLike[str]"]]: + self, args: Sequence[str | os.PathLike[str]] + ) -> list[str | os.PathLike[str]]: new_args = list(args) for x in new_args: if str(x).startswith("--basetemp"): break else: - new_args.append("--basetemp=%s" % self.path.parent.joinpath("basetemp")) + new_args.append( + "--basetemp={}".format(self.path.parent.joinpath("basetemp")) + ) return new_args - def parseconfig(self, *args: Union[str, "os.PathLike[str]"]) -> Config: + def parseconfig(self, *args: str | os.PathLike[str]) -> Config: """Return a new pytest :class:`pytest.Config` instance from given commandline args. @@ -1246,7 +1239,7 @@ def parseconfig(self, *args: Union[str, "os.PathLike[str]"]) -> Config: self._request.addfinalizer(config._ensure_unconfigure) return config - def parseconfigure(self, *args: Union[str, "os.PathLike[str]"]) -> Config: + def parseconfigure(self, *args: str | os.PathLike[str]) -> Config: """Return a new pytest configured Config instance. Returns a new :py:class:`pytest.Config` instance like @@ -1258,7 +1251,7 @@ def parseconfigure(self, *args: Union[str, "os.PathLike[str]"]) -> Config: return config def getitem( - self, source: Union[str, "os.PathLike[str]"], funcname: str = "test_func" + self, source: str | os.PathLike[str], funcname: str = "test_func" ) -> Item: """Return the test item for a test function. @@ -1277,11 +1270,9 @@ def getitem( for item in items: if item.name == funcname: return item - assert 0, "{!r} item not found in module:\n{}\nitems: {}".format( - funcname, source, items - ) + assert 0, f"{funcname!r} item not found in module:\n{source}\nitems: {items}" - def getitems(self, source: Union[str, "os.PathLike[str]"]) -> List[Item]: + def getitems(self, source: str | os.PathLike[str]) -> list[Item]: """Return all test items collected from the module. Writes the source to a Python file and runs pytest's collection on @@ -1292,7 +1283,7 @@ def getitems(self, source: Union[str, "os.PathLike[str]"]) -> List[Item]: def getmodulecol( self, - source: Union[str, "os.PathLike[str]"], + source: str | os.PathLike[str], configargs=(), *, withinit: bool = False, @@ -1324,9 +1315,7 @@ def getmodulecol( self.config = config = self.parseconfigure(path, *configargs) return self.getnode(config, path) - def collect_by_name( - self, modcol: Collector, name: str - ) -> Optional[Union[Item, Collector]]: + def collect_by_name(self, modcol: Collector, name: str) -> Item | Collector | None: """Return the collection node for name from the module collection. Searches a module collection node for a collection node matching the @@ -1344,10 +1333,10 @@ def collect_by_name( def popen( self, - cmdargs: Sequence[Union[str, "os.PathLike[str]"]], - stdout: Union[int, TextIO] = subprocess.PIPE, - stderr: Union[int, TextIO] = subprocess.PIPE, - stdin: Union[NotSetType, bytes, IO[Any], int] = CLOSE_STDIN, + cmdargs: Sequence[str | os.PathLike[str]], + stdout: int | TextIO = subprocess.PIPE, + stderr: int | TextIO = subprocess.PIPE, + stdin: NotSetType | bytes | IO[Any] | int = CLOSE_STDIN, **kw, ): """Invoke :py:class:`subprocess.Popen`. @@ -1382,9 +1371,9 @@ def popen( def run( self, - *cmdargs: Union[str, "os.PathLike[str]"], - timeout: Optional[float] = None, - stdin: Union[NotSetType, bytes, IO[Any], int] = CLOSE_STDIN, + *cmdargs: str | os.PathLike[str], + timeout: float | None = None, + stdin: NotSetType | bytes | IO[Any] | int = CLOSE_STDIN, ) -> RunResult: """Run a command with arguments. @@ -1401,7 +1390,7 @@ def run( :param stdin: Optional standard input. - - If it is :py:attr:`CLOSE_STDIN` (Default), then this method calls + - If it is ``CLOSE_STDIN`` (Default), then this method calls :py:class:`subprocess.Popen` with ``stdin=subprocess.PIPE``, and the standard input is closed immediately after the new command is started. @@ -1412,8 +1401,10 @@ def run( - Otherwise, it is passed through to :py:class:`subprocess.Popen`. For further information in this case, consult the document of the ``stdin`` parameter in :py:class:`subprocess.Popen`. + :type stdin: _pytest.compat.NotSetType | bytes | IO[Any] | int :returns: The result. + """ __tracebackhide__ = True @@ -1424,7 +1415,7 @@ def run( print(" in:", Path.cwd()) with p1.open("w", encoding="utf8") as f1, p2.open("w", encoding="utf8") as f2: - now = timing.time() + instant = timing.Instant() popen = self.popen( cmdargs, stdin=stdin, @@ -1438,10 +1429,7 @@ def run( def handle_timeout() -> None: __tracebackhide__ = True - timeout_message = ( - "{seconds} second timeout expired running:" - " {command}".format(seconds=timeout, command=cmdargs) - ) + timeout_message = f"{timeout} second timeout expired running: {cmdargs}" popen.kill() popen.wait() @@ -1464,7 +1452,7 @@ def handle_timeout() -> None: with contextlib.suppress(ValueError): ret = ExitCode(ret) - return RunResult(ret, out, err, timing.time() - now) + return RunResult(ret, out, err, instant.elapsed().seconds) def _dump_lines(self, lines, fp): try: @@ -1473,10 +1461,10 @@ def _dump_lines(self, lines, fp): except UnicodeEncodeError: print(f"couldn't print to {fp} because of encoding") - def _getpytestargs(self) -> Tuple[str, ...]: + def _getpytestargs(self) -> tuple[str, ...]: return sys.executable, "-mpytest" - def runpython(self, script: "os.PathLike[str]") -> RunResult: + def runpython(self, script: os.PathLike[str]) -> RunResult: """Run a python script using sys.executable as interpreter.""" return self.run(sys.executable, script) @@ -1485,7 +1473,7 @@ def runpython_c(self, command: str) -> RunResult: return self.run(sys.executable, "-c", command) def runpytest_subprocess( - self, *args: Union[str, "os.PathLike[str]"], timeout: Optional[float] = None + self, *args: str | os.PathLike[str], timeout: float | None = None ) -> RunResult: """Run pytest as a subprocess with given arguments. @@ -1505,16 +1493,14 @@ def runpytest_subprocess( """ __tracebackhide__ = True p = make_numbered_dir(root=self.path, prefix="runpytest-", mode=0o700) - args = ("--basetemp=%s" % p,) + args + args = (f"--basetemp={p}", *args) plugins = [x for x in self.plugins if isinstance(x, str)] if plugins: - args = ("-p", plugins[0]) + args + args = ("-p", plugins[0], *args) args = self._getpytestargs() + args return self.run(*args, timeout=timeout) - def spawn_pytest( - self, string: str, expect_timeout: float = 10.0 - ) -> "pexpect.spawn": + def spawn_pytest(self, string: str, expect_timeout: float = 10.0) -> pexpect.spawn: """Run pytest using pexpect. This makes sure to use the right pytest and sets up the temporary @@ -1528,7 +1514,7 @@ def spawn_pytest( cmd = f"{invoke} --basetemp={basetemp} {string}" return self.spawn(cmd, expect_timeout=expect_timeout) - def spawn(self, cmd: str, expect_timeout: float = 10.0) -> "pexpect.spawn": + def spawn(self, cmd: str, expect_timeout: float = 10.0) -> pexpect.spawn: """Run a command using pexpect. The pexpect child is returned. @@ -1573,9 +1559,9 @@ class LineMatcher: ``text.splitlines()``. """ - def __init__(self, lines: List[str]) -> None: + def __init__(self, lines: list[str]) -> None: self.lines = lines - self._log_output: List[str] = [] + self._log_output: list[str] = [] def __str__(self) -> str: """Return the entire original text. @@ -1585,7 +1571,7 @@ def __str__(self) -> str: """ return "\n".join(self.lines) - def _getlines(self, lines2: Union[str, Sequence[str], Source]) -> Sequence[str]: + def _getlines(self, lines2: str | Sequence[str] | Source) -> Sequence[str]: if isinstance(lines2, str): lines2 = Source(lines2) if isinstance(lines2, Source): @@ -1613,7 +1599,7 @@ def _match_lines_random( self._log("matched: ", repr(line)) break else: - msg = "line %r not found in output" % line + msg = f"line {line!r} not found in output" self._log(msg) self._fail(msg) @@ -1625,7 +1611,7 @@ def get_lines_after(self, fnline: str) -> Sequence[str]: for i, line in enumerate(self.lines): if fnline == line or fnmatch(line, fnline): return self.lines[i + 1 :] - raise ValueError("line %r not found in output" % fnline) + raise ValueError(f"line {fnline!r} not found in output") def _log(self, *args) -> None: self._log_output.append(" ".join(str(x) for x in args)) @@ -1710,7 +1696,7 @@ def _match_lines( started = True break elif match_func(nextline, line): - self._log("%s:" % match_nickname, repr(line)) + self._log(f"{match_nickname}:", repr(line)) self._log( "{:>{width}}".format("with:", width=wnick), repr(nextline) ) diff --git a/src/_pytest/pytester_assertions.py b/src/_pytest/pytester_assertions.py index 657e4db5fc3..915cc8a10ff 100644 --- a/src/_pytest/pytester_assertions.py +++ b/src/_pytest/pytester_assertions.py @@ -1,23 +1,22 @@ """Helper plugin for pytester; should not be loaded on its own.""" + # This plugin contains assertions used by pytester. pytester cannot # contain them itself, since it is imported by the `pytest` module, # hence cannot be subject to assertion rewriting, which requires a # module to not be already imported. -from typing import Dict -from typing import Optional -from typing import Sequence -from typing import Tuple -from typing import Union +from __future__ import annotations + +from collections.abc import Sequence from _pytest.reports import CollectReport from _pytest.reports import TestReport def assertoutcome( - outcomes: Tuple[ + outcomes: tuple[ Sequence[TestReport], - Sequence[Union[CollectReport, TestReport]], - Sequence[Union[CollectReport, TestReport]], + Sequence[CollectReport | TestReport], + Sequence[CollectReport | TestReport], ], passed: int = 0, skipped: int = 0, @@ -36,15 +35,15 @@ def assertoutcome( def assert_outcomes( - outcomes: Dict[str, int], + outcomes: dict[str, int], passed: int = 0, skipped: int = 0, failed: int = 0, errors: int = 0, xpassed: int = 0, xfailed: int = 0, - warnings: Optional[int] = None, - deselected: Optional[int] = None, + warnings: int | None = None, + deselected: int | None = None, ) -> None: """Assert that the specified outcomes appear with the respective numbers (0 means it didn't occur) in the text output from a test run.""" diff --git a/src/_pytest/python.py b/src/_pytest/python.py index ad847c8afe2..8e4fb041532 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -1,32 +1,33 @@ +# mypy: allow-untyped-defs """Python test discovery, setup and run of test functions.""" + +from __future__ import annotations + +import abc +from collections import Counter +from collections import defaultdict +from collections.abc import Callable +from collections.abc import Generator +from collections.abc import Iterable +from collections.abc import Iterator +from collections.abc import Mapping +from collections.abc import Sequence import dataclasses import enum import fnmatch +from functools import partial import inspect import itertools import os -import sys -import types -import warnings -from collections import Counter -from collections import defaultdict -from functools import partial from pathlib import Path +import re +import types from typing import Any -from typing import Callable -from typing import Dict -from typing import Generator -from typing import Iterable -from typing import Iterator -from typing import List -from typing import Mapping -from typing import Optional -from typing import Pattern -from typing import Sequence -from typing import Set -from typing import Tuple +from typing import final +from typing import Literal +from typing import NoReturn from typing import TYPE_CHECKING -from typing import Union +import warnings import _pytest from _pytest import fixtures @@ -36,77 +37,50 @@ from _pytest._code.code import ExceptionInfo from _pytest._code.code import TerminalRepr from _pytest._code.code import Traceback -from _pytest._io import TerminalWriter from _pytest._io.saferepr import saferepr from _pytest.compat import ascii_escaped -from _pytest.compat import assert_never -from _pytest.compat import final from _pytest.compat import get_default_arg_names from _pytest.compat import get_real_func from _pytest.compat import getimfunc -from _pytest.compat import getlocation from _pytest.compat import is_async_function -from _pytest.compat import is_generator from _pytest.compat import LEGACY_PATH from _pytest.compat import NOTSET from _pytest.compat import safe_getattr from _pytest.compat import safe_isclass -from _pytest.compat import STRING_TYPES from _pytest.config import Config -from _pytest.config import ExitCode from _pytest.config import hookimpl from _pytest.config.argparsing import Parser from _pytest.deprecated import check_ispytest -from _pytest.deprecated import INSTANCE_COLLECTOR -from _pytest.deprecated import NOSE_SUPPORT_METHOD +from _pytest.fixtures import FixtureDef +from _pytest.fixtures import FixtureRequest from _pytest.fixtures import FuncFixtureInfo +from _pytest.fixtures import get_scope_node from _pytest.main import Session -from _pytest.mark import MARK_GEN from _pytest.mark import ParameterSet +from _pytest.mark.structures import _HiddenParam from _pytest.mark.structures import get_unpacked_marks +from _pytest.mark.structures import HIDDEN_PARAM from _pytest.mark.structures import Mark from _pytest.mark.structures import MarkDecorator from _pytest.mark.structures import normalize_mark_list from _pytest.outcomes import fail from _pytest.outcomes import skip -from _pytest.pathlib import bestrelpath from _pytest.pathlib import fnmatch_ex from _pytest.pathlib import import_path from _pytest.pathlib import ImportPathMismatchError -from _pytest.pathlib import parts -from _pytest.pathlib import visit +from _pytest.pathlib import scandir +from _pytest.scope import _ScopeName from _pytest.scope import Scope +from _pytest.stash import StashKey from _pytest.warning_types import PytestCollectionWarning from _pytest.warning_types import PytestReturnNotNoneWarning -from _pytest.warning_types import PytestUnhandledCoroutineWarning - -if TYPE_CHECKING: - from typing_extensions import Literal - from _pytest.scope import _ScopeName - -_PYTEST_DIR = Path(_pytest.__file__).parent +if TYPE_CHECKING: + from typing_extensions import Self def pytest_addoption(parser: Parser) -> None: - group = parser.getgroup("general") - group.addoption( - "--fixtures", - "--funcargs", - action="store_true", - dest="showfixtures", - default=False, - help="Show available fixtures, sorted by plugin appearance " - "(fixtures with leading '_' are only shown with '-v')", - ) - group.addoption( - "--fixtures-per-test", - action="store_true", - dest="show_fixtures_per_test", - default=False, - help="Show fixtures per test", - ) parser.addini( "python_files", type="args", @@ -135,17 +109,7 @@ def pytest_addoption(parser: Parser) -> None: ) -def pytest_cmdline_main(config: Config) -> Optional[Union[int, ExitCode]]: - if config.option.showfixtures: - showfixtures(config) - return 0 - if config.option.show_fixtures_per_test: - show_fixtures_per_test(config) - return 0 - return None - - -def pytest_generate_tests(metafunc: "Metafunc") -> None: +def pytest_generate_tests(metafunc: Metafunc) -> None: for marker in metafunc.definition.iter_markers(name="parametrize"): metafunc.parametrize(*marker.args, **marker.kwargs, _param_mark=marker) @@ -170,45 +134,59 @@ def pytest_configure(config: Config) -> None: ) -def async_warn_and_skip(nodeid: str) -> None: - msg = "async def functions are not natively supported and have been skipped.\n" - msg += ( +def async_fail(nodeid: str) -> None: + msg = ( + "async def functions are not natively supported.\n" "You need to install a suitable plugin for your async framework, for example:\n" + " - anyio\n" + " - pytest-asyncio\n" + " - pytest-tornasync\n" + " - pytest-trio\n" + " - pytest-twisted" ) - msg += " - anyio\n" - msg += " - pytest-asyncio\n" - msg += " - pytest-tornasync\n" - msg += " - pytest-trio\n" - msg += " - pytest-twisted" - warnings.warn(PytestUnhandledCoroutineWarning(msg.format(nodeid))) - skip(reason="async def function and no async plugin installed (see warnings)") + fail(msg, pytrace=False) @hookimpl(trylast=True) -def pytest_pyfunc_call(pyfuncitem: "Function") -> Optional[object]: +def pytest_pyfunc_call(pyfuncitem: Function) -> object | None: testfunction = pyfuncitem.obj if is_async_function(testfunction): - async_warn_and_skip(pyfuncitem.nodeid) + async_fail(pyfuncitem.nodeid) funcargs = pyfuncitem.funcargs testargs = {arg: funcargs[arg] for arg in pyfuncitem._fixtureinfo.argnames} result = testfunction(**testargs) if hasattr(result, "__await__") or hasattr(result, "__aiter__"): - async_warn_and_skip(pyfuncitem.nodeid) + async_fail(pyfuncitem.nodeid) elif result is not None: warnings.warn( PytestReturnNotNoneWarning( - f"Expected None, but {pyfuncitem.nodeid} returned {result!r}, which will be an error in a " - "future version of pytest. Did you mean to use `assert` instead of `return`?" + f"Test functions should return None, but {pyfuncitem.nodeid} returned {type(result)!r}.\n" + "Did you mean to use `assert` instead of `return`?\n" + "See https://docs.pytest.org/en/stable/how-to/assert.html#return-not-none for more information." ) ) return True -def pytest_collect_file(file_path: Path, parent: nodes.Collector) -> Optional["Module"]: +def pytest_collect_directory( + path: Path, parent: nodes.Collector +) -> nodes.Collector | None: + pkginit = path / "__init__.py" + try: + has_pkginit = pkginit.is_file() + except PermissionError: + # See https://github.com/pytest-dev/pytest/issues/12120#issuecomment-2106349096. + return None + if has_pkginit: + return Package.from_parent(parent, path=path) + return None + + +def pytest_collect_file(file_path: Path, parent: nodes.Collector) -> Module | None: if file_path.suffix == ".py": if not parent.session.isinitpath(file_path): if not path_matches_patterns( - file_path, parent.config.getini("python_files") + ["__init__.py"] + file_path, parent.config.getini("python_files") ): return None ihook = parent.session.gethookproxy(file_path) @@ -224,24 +202,19 @@ def path_matches_patterns(path: Path, patterns: Iterable[str]) -> bool: return any(fnmatch_ex(pattern, path) for pattern in patterns) -def pytest_pycollect_makemodule(module_path: Path, parent) -> "Module": - if module_path.name == "__init__.py": - pkg: Package = Package.from_parent(parent, path=module_path) - return pkg - mod: Module = Module.from_parent(parent, path=module_path) - return mod +def pytest_pycollect_makemodule(module_path: Path, parent) -> Module: + return Module.from_parent(parent, path=module_path) @hookimpl(trylast=True) def pytest_pycollect_makeitem( - collector: Union["Module", "Class"], name: str, obj: object -) -> Union[None, nodes.Item, nodes.Collector, List[Union[nodes.Item, nodes.Collector]]]: + collector: Module | Class, name: str, obj: object +) -> None | nodes.Item | nodes.Collector | list[nodes.Item | nodes.Collector]: assert isinstance(collector, (Class, Module)), type(collector) # Nothing was collected elsewhere, let's do it here. if safe_isclass(obj): if collector.istestclass(obj, name): - klass: Class = Class.from_parent(collector, name=name, obj=obj) - return klass + return Class.from_parent(collector, name=name, obj=obj) elif collector.istestfunction(obj, name): # mock seems to store unbound methods (issue473), normalize it. obj = getattr(obj, "__func__", obj) @@ -252,23 +225,20 @@ def pytest_pycollect_makeitem( filename, lineno = getfslineno(obj) warnings.warn_explicit( message=PytestCollectionWarning( - "cannot collect %r because it is not a function." % name + f"cannot collect {name!r} because it is not a function." ), category=None, filename=str(filename), lineno=lineno + 1, ) elif getattr(obj, "__test__", True): - if is_generator(obj): - res: Function = Function.from_parent(collector, name=name) - reason = "yield tests were removed in pytest 4.0 - {name} will be ignored".format( - name=name + if inspect.isgeneratorfunction(obj): + fail( + f"'yield' keyword is allowed in fixtures, but not in tests ({name})", + pytrace=False, ) - res.add_marker(MARK_GEN.xfail(run=False, reason=reason)) - res.warn(PytestCollectionWarning(reason)) - return res - else: - return list(collector._genfunctions(name, obj)) + return list(collector._genfunctions(name, obj)) + return None return None @@ -297,10 +267,10 @@ def instance(self): """Python instance object the function is bound to. Returns None if not a test method, e.g. for a standalone test function, - a staticmethod, a class or a module. + a class or a module. """ - node = self.getparent(Function) - return getattr(node.obj, "__self__", None) if node is not None else None + # Overridden by Function. + return None @property def obj(self): @@ -330,10 +300,8 @@ def _getobj(self): def getmodpath(self, stopatmodule: bool = True, includemodule: bool = False) -> str: """Return Python path relative to the containing module.""" - chain = self.listchain() - chain.reverse() parts = [] - for node in chain: + for node in self.iter_parents(): name = node.name if isinstance(node, Module): name = os.path.splitext(name)[0] @@ -345,22 +313,10 @@ def getmodpath(self, stopatmodule: bool = True, includemodule: bool = False) -> parts.reverse() return ".".join(parts) - def reportinfo(self) -> Tuple[Union["os.PathLike[str]", str], Optional[int], str]: + def reportinfo(self) -> tuple[os.PathLike[str] | str, int | None, str]: # XXX caching? - obj = self.obj - compat_co_firstlineno = getattr(obj, "compat_co_firstlineno", None) - if isinstance(compat_co_firstlineno, int): - # nose compatibility - file_path = sys.modules[obj.__module__].__file__ - assert file_path is not None - if file_path.endswith(".pyc"): - file_path = file_path[:-1] - path: Union["os.PathLike[str]", str] = file_path - lineno = compat_co_firstlineno - else: - path, lineno = getfslineno(obj) + path, lineno = getfslineno(self.obj) modpath = self.getmodpath() - assert isinstance(lineno, int) return path, lineno, modpath @@ -369,7 +325,7 @@ def reportinfo(self) -> Tuple[Union["os.PathLike[str]", str], Optional[int], str # hook is not called for them. # fmt: off class _EmptyClass: pass # noqa: E701 -IGNORED_ATTRIBUTES = frozenset.union( # noqa: E305 +IGNORED_ATTRIBUTES = frozenset.union( frozenset(), # Module. dir(types.ModuleType("empty_module")), @@ -384,7 +340,7 @@ class _EmptyClass: pass # noqa: E701 # fmt: on -class PyCollector(PyobjMixin, nodes.Collector): +class PyCollector(PyobjMixin, nodes.Collector, abc.ABC): def funcnamefilter(self, name: str) -> bool: return self._matches_prefix_or_glob_option("python_functions", name) @@ -410,7 +366,11 @@ def istestfunction(self, obj: object, name: str) -> bool: return False def istestclass(self, obj: object, name: str) -> bool: - return self.classnamefilter(name) or self.isnosetest(obj) + if not (self.classnamefilter(name) or self.isnosetest(obj)): + return False + if inspect.isabstract(obj): + return False + return True def _matches_prefix_or_glob_option(self, option_name: str, name: str) -> bool: """Check if the given name matches the prefix or glob-pattern defined @@ -427,7 +387,7 @@ def _matches_prefix_or_glob_option(self, option_name: str, name: str) -> bool: return True return False - def collect(self) -> Iterable[Union[nodes.Item, nodes.Collector]]: + def collect(self) -> Iterable[nodes.Item | nodes.Collector]: if not getattr(self.obj, "__test__", True): return [] @@ -439,11 +399,12 @@ def collect(self) -> Iterable[Union[nodes.Item, nodes.Collector]]: # In each class, nodes should be definition ordered. # __dict__ is definition ordered. - seen: Set[str] = set() - dict_values: List[List[Union[nodes.Item, nodes.Collector]]] = [] + seen: set[str] = set() + dict_values: list[list[nodes.Item | nodes.Collector]] = [] + collect_imported_tests = self.session.config.getini("collect_imported_tests") ihook = self.ihook for dic in dicts: - values: List[Union[nodes.Item, nodes.Collector]] = [] + values: list[nodes.Item | nodes.Collector] = [] # Note: seems like the dict can change during iteration - # be careful not to remove the list() without consideration. for name, obj in list(dic.items()): @@ -452,6 +413,13 @@ def collect(self) -> Iterable[Union[nodes.Item, nodes.Collector]]: if name in seen: continue seen.add(name) + + if not collect_imported_tests and isinstance(self, Module): + # Do not collect functions and classes from other modules. + if inspect.isfunction(obj) or inspect.isclass(obj): + if obj.__module__ != self._getobj().__name__: + continue + res = ihook.pytest_pycollect_makeitem( collector=self, name=name, obj=obj ) @@ -470,12 +438,12 @@ def collect(self) -> Iterable[Union[nodes.Item, nodes.Collector]]: result.extend(values) return result - def _genfunctions(self, name: str, funcobj) -> Iterator["Function"]: + def _genfunctions(self, name: str, funcobj) -> Iterator[Function]: modulecol = self.getparent(Module) assert modulecol is not None module = modulecol.obj clscol = self.getparent(Class) - cls = clscol and clscol.obj or None + cls = (clscol and clscol.obj) or None definition = FunctionDefinition.from_parent(self, name=name, callobj=funcobj) fixtureinfo = definition._fixtureinfo @@ -500,17 +468,16 @@ def _genfunctions(self, name: str, funcobj) -> Iterator["Function"]: if not metafunc._calls: yield Function.from_parent(self, name=name, fixtureinfo=fixtureinfo) else: - # Add funcargs() as fixturedefs to fixtureinfo.arg2fixturedefs. - fm = self.session._fixturemanager - fixtures.add_funcarg_pseudo_fixture_def(self, metafunc, fm) - - # Add_funcarg_pseudo_fixture_def may have shadowed some fixtures - # with direct parametrization, so make sure we update what the - # function really needs. + metafunc._recompute_direct_params_indices() + # Direct parametrizations taking place in module/class-specific + # `metafunc.parametrize` calls may have shadowed some fixtures, so make sure + # we update what the function really needs a.k.a its fixture closure. Note that + # direct parametrizations using `@pytest.mark.parametrize` have already been considered + # into making the closure using `ignore_args` arg to `getfixtureclosure`. fixtureinfo.prune_dependency_tree() for callspec in metafunc._calls: - subname = f"{name}[{callspec.id}]" + subname = f"{name}[{callspec.id}]" if callspec._idlist else name yield Function.from_parent( self, name=subname, @@ -521,63 +488,110 @@ def _genfunctions(self, name: str, funcobj) -> Iterator["Function"]: ) +def importtestmodule( + path: Path, + config: Config, +): + # We assume we are only called once per module. + importmode = config.getoption("--import-mode") + try: + mod = import_path( + path, + mode=importmode, + root=config.rootpath, + consider_namespace_packages=config.getini("consider_namespace_packages"), + ) + except SyntaxError as e: + raise nodes.Collector.CollectError( + ExceptionInfo.from_current().getrepr(style="short") + ) from e + except ImportPathMismatchError as e: + raise nodes.Collector.CollectError( + "import file mismatch:\n" + "imported module {!r} has this __file__ attribute:\n" + " {}\n" + "which is not the same as the test file we want to collect:\n" + " {}\n" + "HINT: remove __pycache__ / .pyc files and/or use a " + "unique basename for your test file modules".format(*e.args) + ) from e + except ImportError as e: + exc_info = ExceptionInfo.from_current() + if config.get_verbosity() < 2: + exc_info.traceback = exc_info.traceback.filter(filter_traceback) + exc_repr = ( + exc_info.getrepr(style="short") + if exc_info.traceback + else exc_info.exconly() + ) + formatted_tb = str(exc_repr) + raise nodes.Collector.CollectError( + f"ImportError while importing test module '{path}'.\n" + "Hint: make sure your test modules/packages have valid Python names.\n" + "Traceback:\n" + f"{formatted_tb}" + ) from e + except skip.Exception as e: + if e.allow_module_level: + raise + raise nodes.Collector.CollectError( + "Using pytest.skip outside of a test will skip the entire module. " + "If that's your intention, pass `allow_module_level=True`. " + "If you want to skip a specific test or an entire class, " + "use the @pytest.mark.skip or @pytest.mark.skipif decorators." + ) from e + config.pluginmanager.consider_module(mod) + return mod + + class Module(nodes.File, PyCollector): - """Collector for test classes and functions.""" + """Collector for test classes and functions in a Python module.""" def _getobj(self): - return self._importtestmodule() + return importtestmodule(self.path, self.config) - def collect(self) -> Iterable[Union[nodes.Item, nodes.Collector]]: - self._inject_setup_module_fixture() - self._inject_setup_function_fixture() + def collect(self) -> Iterable[nodes.Item | nodes.Collector]: + self._register_setup_module_fixture() + self._register_setup_function_fixture() self.session._fixturemanager.parsefactories(self) return super().collect() - def _inject_setup_module_fixture(self) -> None: - """Inject a hidden autouse, module scoped fixture into the collected module object + def _register_setup_module_fixture(self) -> None: + """Register an autouse, module-scoped fixture for the collected module object that invokes setUpModule/tearDownModule if either or both are available. Using a fixture to invoke this methods ensures we play nicely and unsurprisingly with other fixtures (#517). """ - has_nose = self.config.pluginmanager.has_plugin("nose") setup_module = _get_first_non_fixture_func( self.obj, ("setUpModule", "setup_module") ) - if setup_module is None and has_nose: - # The name "setup" is too common - only treat as fixture if callable. - setup_module = _get_first_non_fixture_func(self.obj, ("setup",)) - if not callable(setup_module): - setup_module = None teardown_module = _get_first_non_fixture_func( self.obj, ("tearDownModule", "teardown_module") ) - if teardown_module is None and has_nose: - teardown_module = _get_first_non_fixture_func(self.obj, ("teardown",)) - # Same as "setup" above - only treat as fixture if callable. - if not callable(teardown_module): - teardown_module = None if setup_module is None and teardown_module is None: return - @fixtures.fixture( - autouse=True, - scope="module", - # Use a unique name to speed up lookup. - name=f"_xunit_setup_module_fixture_{self.obj.__name__}", - ) - def xunit_setup_module_fixture(request) -> Generator[None, None, None]: + def xunit_setup_module_fixture(request) -> Generator[None]: + module = request.module if setup_module is not None: - _call_with_optional_argument(setup_module, request.module) + _call_with_optional_argument(setup_module, module) yield if teardown_module is not None: - _call_with_optional_argument(teardown_module, request.module) + _call_with_optional_argument(teardown_module, module) - self.obj.__pytest_setup_module = xunit_setup_module_fixture + self.session._fixturemanager._register_fixture( + # Use a unique name to speed up lookup. + name=f"_xunit_setup_module_fixture_{self.obj.__name__}", + func=xunit_setup_module_fixture, + nodeid=self.nodeid, + scope="module", + autouse=True, + ) - def _inject_setup_function_fixture(self) -> None: - """Inject a hidden autouse, function scoped fixture into the collected module object + def _register_setup_function_fixture(self) -> None: + """Register an autouse, function-scoped fixture for the collected module object that invokes setup_function/teardown_function if either or both are available. Using a fixture to invoke this methods ensures we play nicely and unsurprisingly with @@ -590,90 +604,58 @@ def _inject_setup_function_fixture(self) -> None: if setup_function is None and teardown_function is None: return - @fixtures.fixture( - autouse=True, - scope="function", - # Use a unique name to speed up lookup. - name=f"_xunit_setup_function_fixture_{self.obj.__name__}", - ) - def xunit_setup_function_fixture(request) -> Generator[None, None, None]: + def xunit_setup_function_fixture(request) -> Generator[None]: if request.instance is not None: # in this case we are bound to an instance, so we need to let # setup_method handle this yield return + function = request.function if setup_function is not None: - _call_with_optional_argument(setup_function, request.function) + _call_with_optional_argument(setup_function, function) yield if teardown_function is not None: - _call_with_optional_argument(teardown_function, request.function) + _call_with_optional_argument(teardown_function, function) - self.obj.__pytest_setup_function = xunit_setup_function_fixture + self.session._fixturemanager._register_fixture( + # Use a unique name to speed up lookup. + name=f"_xunit_setup_function_fixture_{self.obj.__name__}", + func=xunit_setup_function_fixture, + nodeid=self.nodeid, + scope="function", + autouse=True, + ) + + +class Package(nodes.Directory): + """Collector for files and directories in a Python packages -- directories + with an `__init__.py` file. + + .. note:: + + Directories without an `__init__.py` file are instead collected by + :class:`~pytest.Dir` by default. Both are :class:`~pytest.Directory` + collectors. + + .. versionchanged:: 8.0 + + Now inherits from :class:`~pytest.Directory`. + """ - def _importtestmodule(self): - # We assume we are only called once per module. - importmode = self.config.getoption("--import-mode") - try: - mod = import_path(self.path, mode=importmode, root=self.config.rootpath) - except SyntaxError as e: - raise self.CollectError( - ExceptionInfo.from_current().getrepr(style="short") - ) from e - except ImportPathMismatchError as e: - raise self.CollectError( - "import file mismatch:\n" - "imported module %r has this __file__ attribute:\n" - " %s\n" - "which is not the same as the test file we want to collect:\n" - " %s\n" - "HINT: remove __pycache__ / .pyc files and/or use a " - "unique basename for your test file modules" % e.args - ) from e - except ImportError as e: - exc_info = ExceptionInfo.from_current() - if self.config.getoption("verbose") < 2: - exc_info.traceback = exc_info.traceback.filter(filter_traceback) - exc_repr = ( - exc_info.getrepr(style="short") - if exc_info.traceback - else exc_info.exconly() - ) - formatted_tb = str(exc_repr) - raise self.CollectError( - "ImportError while importing test module '{path}'.\n" - "Hint: make sure your test modules/packages have valid Python names.\n" - "Traceback:\n" - "{traceback}".format(path=self.path, traceback=formatted_tb) - ) from e - except skip.Exception as e: - if e.allow_module_level: - raise - raise self.CollectError( - "Using pytest.skip outside of a test will skip the entire module. " - "If that's your intention, pass `allow_module_level=True`. " - "If you want to skip a specific test or an entire class, " - "use the @pytest.mark.skip or @pytest.mark.skipif decorators." - ) from e - self.config.pluginmanager.consider_module(mod) - return mod - - -class Package(Module): def __init__( self, - fspath: Optional[LEGACY_PATH], + fspath: LEGACY_PATH | None, parent: nodes.Collector, # NOTE: following args are unused: config=None, session=None, nodeid=None, - path: Optional[Path] = None, + path: Path | None = None, ) -> None: # NOTE: Could be just the following, but kept as-is for compat. - # nodes.FSCollector.__init__(self, fspath, parent=parent) + # super().__init__(self, fspath, parent=parent) session = parent.session - nodes.FSCollector.__init__( - self, + super().__init__( fspath=fspath, path=path, parent=parent, @@ -681,87 +663,51 @@ def __init__( session=session, nodeid=nodeid, ) - self.name = self.path.parent.name def setup(self) -> None: + init_mod = importtestmodule(self.path / "__init__.py", self.config) + # Not using fixtures to call setup_module here because autouse fixtures # from packages are not called automatically (#4085). setup_module = _get_first_non_fixture_func( - self.obj, ("setUpModule", "setup_module") + init_mod, ("setUpModule", "setup_module") ) if setup_module is not None: - _call_with_optional_argument(setup_module, self.obj) + _call_with_optional_argument(setup_module, init_mod) teardown_module = _get_first_non_fixture_func( - self.obj, ("tearDownModule", "teardown_module") + init_mod, ("tearDownModule", "teardown_module") ) if teardown_module is not None: - func = partial(_call_with_optional_argument, teardown_module, self.obj) + func = partial(_call_with_optional_argument, teardown_module, init_mod) self.addfinalizer(func) - def _recurse(self, direntry: "os.DirEntry[str]") -> bool: - if direntry.name == "__pycache__": - return False - fspath = Path(direntry.path) - ihook = self.session.gethookproxy(fspath.parent) - if ihook.pytest_ignore_collect(collection_path=fspath, config=self.config): - return False - return True + def collect(self) -> Iterable[nodes.Item | nodes.Collector]: + # Always collect __init__.py first. + def sort_key(entry: os.DirEntry[str]) -> object: + return (entry.name != "__init__.py", entry.name) - def _collectfile( - self, fspath: Path, handle_dupes: bool = True - ) -> Sequence[nodes.Collector]: - assert ( - fspath.is_file() - ), "{!r} is not a file (isdir={!r}, exists={!r}, islink={!r})".format( - fspath, fspath.is_dir(), fspath.exists(), fspath.is_symlink() - ) - ihook = self.session.gethookproxy(fspath) - if not self.session.isinitpath(fspath): - if ihook.pytest_ignore_collect(collection_path=fspath, config=self.config): - return () - - if handle_dupes: - keepduplicates = self.config.getoption("keepduplicates") - if not keepduplicates: - duplicate_paths = self.config.pluginmanager._duplicatepaths - if fspath in duplicate_paths: - return () - else: - duplicate_paths.add(fspath) - - return ihook.pytest_collect_file(file_path=fspath, parent=self) # type: ignore[no-any-return] - - def collect(self) -> Iterable[Union[nodes.Item, nodes.Collector]]: - this_path = self.path.parent - - # Always collect the __init__ first. - if path_matches_patterns(self.path, self.config.getini("python_files")): - yield Module.from_parent(self, path=self.path) - - pkg_prefixes: Set[Path] = set() - for direntry in visit(str(this_path), recurse=self._recurse): - path = Path(direntry.path) - - # We will visit our own __init__.py file, in which case we skip it. - if direntry.is_file(): - if direntry.name == "__init__.py" and path.parent == this_path: - continue - - parts_ = parts(direntry.path) - if any( - str(pkg_prefix) in parts_ and pkg_prefix / "__init__.py" != path - for pkg_prefix in pkg_prefixes - ): - continue - - if direntry.is_file(): - yield from self._collectfile(path) - elif not direntry.is_dir(): - # Broken symlink or invalid/missing file. - continue - elif path.joinpath("__init__.py").is_file(): - pkg_prefixes.add(path) + config = self.config + col: nodes.Collector | None + cols: Sequence[nodes.Collector] + ihook = self.ihook + for direntry in scandir(self.path, sort_key): + if direntry.is_dir(): + path = Path(direntry.path) + if not self.session.isinitpath(path, with_parents=True): + if ihook.pytest_ignore_collect(collection_path=path, config=config): + continue + col = ihook.pytest_collect_directory(path=path, parent=self) + if col is not None: + yield col + + elif direntry.is_file(): + path = Path(direntry.path) + if not self.session.isinitpath(path): + if ihook.pytest_ignore_collect(collection_path=path, config=config): + continue + cols = ihook.pytest_collect_file(file_path=path, parent=self) + yield from cols def _call_with_optional_argument(func, arg) -> None: @@ -776,38 +722,37 @@ def _call_with_optional_argument(func, arg) -> None: func() -def _get_first_non_fixture_func(obj: object, names: Iterable[str]) -> Optional[object]: +def _get_first_non_fixture_func(obj: object, names: Iterable[str]) -> object | None: """Return the attribute from the given object to be used as a setup/teardown xunit-style function, but only if not marked as a fixture to avoid calling it twice. """ for name in names: - meth: Optional[object] = getattr(obj, name, None) + meth: object | None = getattr(obj, name, None) if meth is not None and fixtures.getfixturemarker(meth) is None: return meth return None class Class(PyCollector): - """Collector for test methods.""" + """Collector for test methods (and nested classes) in a Python class.""" @classmethod - def from_parent(cls, parent, *, name, obj=None, **kw): + def from_parent(cls, parent, *, name, obj=None, **kw) -> Self: # type: ignore[override] """The public constructor.""" return super().from_parent(name=name, parent=parent, **kw) def newinstance(self): return self.obj() - def collect(self) -> Iterable[Union[nodes.Item, nodes.Collector]]: + def collect(self) -> Iterable[nodes.Item | nodes.Collector]: if not safe_getattr(self.obj, "__test__", True): return [] if hasinit(self.obj): assert self.parent is not None self.warn( PytestCollectionWarning( - "cannot collect test class %r because it has a " - "__init__ constructor (from: %s)" - % (self.obj.__name__, self.parent.nodeid) + f"cannot collect test class {self.obj.__name__!r} because it has a " + f"__init__ constructor (from: {self.parent.nodeid})" ) ) return [] @@ -815,22 +760,21 @@ def collect(self) -> Iterable[Union[nodes.Item, nodes.Collector]]: assert self.parent is not None self.warn( PytestCollectionWarning( - "cannot collect test class %r because it has a " - "__new__ constructor (from: %s)" - % (self.obj.__name__, self.parent.nodeid) + f"cannot collect test class {self.obj.__name__!r} because it has a " + f"__new__ constructor (from: {self.parent.nodeid})" ) ) return [] - self._inject_setup_class_fixture() - self._inject_setup_method_fixture() + self._register_setup_class_fixture() + self._register_setup_method_fixture() self.session._fixturemanager.parsefactories(self.newinstance(), self.nodeid) return super().collect() - def _inject_setup_class_fixture(self) -> None: - """Inject a hidden autouse, class scoped fixture into the collected class object + def _register_setup_class_fixture(self) -> None: + """Register an autouse, class scoped fixture into the collected class object that invokes setup_class/teardown_class if either or both are available. Using a fixture to invoke this methods ensures we play nicely and unsurprisingly with @@ -841,93 +785,58 @@ def _inject_setup_class_fixture(self) -> None: if setup_class is None and teardown_class is None: return - @fixtures.fixture( - autouse=True, - scope="class", - # Use a unique name to speed up lookup. - name=f"_xunit_setup_class_fixture_{self.obj.__qualname__}", - ) - def xunit_setup_class_fixture(cls) -> Generator[None, None, None]: + def xunit_setup_class_fixture(request) -> Generator[None]: + cls = request.cls if setup_class is not None: func = getimfunc(setup_class) - _call_with_optional_argument(func, self.obj) + _call_with_optional_argument(func, cls) yield if teardown_class is not None: func = getimfunc(teardown_class) - _call_with_optional_argument(func, self.obj) + _call_with_optional_argument(func, cls) - self.obj.__pytest_setup_class = xunit_setup_class_fixture + self.session._fixturemanager._register_fixture( + # Use a unique name to speed up lookup. + name=f"_xunit_setup_class_fixture_{self.obj.__qualname__}", + func=xunit_setup_class_fixture, + nodeid=self.nodeid, + scope="class", + autouse=True, + ) - def _inject_setup_method_fixture(self) -> None: - """Inject a hidden autouse, function scoped fixture into the collected class object + def _register_setup_method_fixture(self) -> None: + """Register an autouse, function scoped fixture into the collected class object that invokes setup_method/teardown_method if either or both are available. Using a fixture to invoke these methods ensures we play nicely and unsurprisingly with other fixtures (#517). """ - has_nose = self.config.pluginmanager.has_plugin("nose") setup_name = "setup_method" setup_method = _get_first_non_fixture_func(self.obj, (setup_name,)) - emit_nose_setup_warning = False - if setup_method is None and has_nose: - setup_name = "setup" - emit_nose_setup_warning = True - setup_method = _get_first_non_fixture_func(self.obj, (setup_name,)) teardown_name = "teardown_method" teardown_method = _get_first_non_fixture_func(self.obj, (teardown_name,)) - emit_nose_teardown_warning = False - if teardown_method is None and has_nose: - teardown_name = "teardown" - emit_nose_teardown_warning = True - teardown_method = _get_first_non_fixture_func(self.obj, (teardown_name,)) if setup_method is None and teardown_method is None: return - @fixtures.fixture( - autouse=True, - scope="function", - # Use a unique name to speed up lookup. - name=f"_xunit_setup_method_fixture_{self.obj.__qualname__}", - ) - def xunit_setup_method_fixture(self, request) -> Generator[None, None, None]: + def xunit_setup_method_fixture(request) -> Generator[None]: + instance = request.instance method = request.function if setup_method is not None: - func = getattr(self, setup_name) + func = getattr(instance, setup_name) _call_with_optional_argument(func, method) - if emit_nose_setup_warning: - warnings.warn( - NOSE_SUPPORT_METHOD.format( - nodeid=request.node.nodeid, method="setup" - ), - stacklevel=2, - ) yield if teardown_method is not None: - func = getattr(self, teardown_name) + func = getattr(instance, teardown_name) _call_with_optional_argument(func, method) - if emit_nose_teardown_warning: - warnings.warn( - NOSE_SUPPORT_METHOD.format( - nodeid=request.node.nodeid, method="teardown" - ), - stacklevel=2, - ) - - self.obj.__pytest_setup_method = xunit_setup_method_fixture - -class InstanceDummy: - """Instance used to be a node type between Class and Function. It has been - removed in pytest 7.0. Some plugins exist which reference `pytest.Instance` - only to ignore it; this dummy class keeps them working. This will be removed - in pytest 8.""" - - -def __getattr__(name: str) -> object: - if name == "Instance": - warnings.warn(INSTANCE_COLLECTOR, 2) - return InstanceDummy - raise AttributeError(f"module {__name__} has no attribute {name}") + self.session._fixturemanager._register_fixture( + # Use a unique name to speed up lookup. + name=f"_xunit_setup_method_fixture_{self.obj.__qualname__}", + func=xunit_setup_method_fixture, + nodeid=self.nodeid, + scope="function", + autouse=True, + ) def hasinit(obj: object) -> bool: @@ -951,12 +860,12 @@ class IdMaker: __slots__ = ( "argnames", - "parametersets", + "config", + "func_name", "idfn", "ids", - "config", "nodeid", - "func_name", + "parametersets", ) # The argnames of the parametrization. @@ -965,21 +874,21 @@ class IdMaker: parametersets: Sequence[ParameterSet] # Optionally, a user-provided callable to make IDs for parameters in a # ParameterSet. - idfn: Optional[Callable[[Any], Optional[object]]] + idfn: Callable[[Any], object | None] | None # Optionally, explicit IDs for ParameterSets by index. - ids: Optional[Sequence[Optional[object]]] + ids: Sequence[object | None] | None # Optionally, the pytest config. # Used for controlling ASCII escaping, and for calling the # :hook:`pytest_make_parametrize_id` hook. - config: Optional[Config] + config: Config | None # Optionally, the ID of the node being parametrized. # Used only for clearer error messages. - nodeid: Optional[str] + nodeid: str | None # Optionally, the ID of the function being parametrized. # Used only for clearer error messages. - func_name: Optional[str] + func_name: str | None - def make_unique_parameterset_ids(self) -> List[str]: + def make_unique_parameterset_ids(self) -> list[str | _HiddenParam]: """Make a unique identifier for each ParameterSet, that may be used to identify the parametrization in a node ID. @@ -996,23 +905,41 @@ def make_unique_parameterset_ids(self) -> List[str]: # Record the number of occurrences of each ID. id_counts = Counter(resolved_ids) # Map the ID to its next suffix. - id_suffixes: Dict[str, int] = defaultdict(int) + id_suffixes: dict[str, int] = defaultdict(int) # Suffix non-unique IDs to make them unique. for index, id in enumerate(resolved_ids): if id_counts[id] > 1: - resolved_ids[index] = f"{id}{id_suffixes[id]}" + if id is HIDDEN_PARAM: + self._complain_multiple_hidden_parameter_sets() + suffix = "" + if id and id[-1].isdigit(): + suffix = "_" + new_id = f"{id}{suffix}{id_suffixes[id]}" + while new_id in set(resolved_ids): + id_suffixes[id] += 1 + new_id = f"{id}{suffix}{id_suffixes[id]}" + resolved_ids[index] = new_id id_suffixes[id] += 1 + assert len(resolved_ids) == len(set(resolved_ids)), ( + f"Internal error: {resolved_ids=}" + ) return resolved_ids - def _resolve_ids(self) -> Iterable[str]: + def _resolve_ids(self) -> Iterable[str | _HiddenParam]: """Resolve IDs for all ParameterSets (may contain duplicates).""" for idx, parameterset in enumerate(self.parametersets): if parameterset.id is not None: # ID provided directly - pytest.param(..., id="...") - yield parameterset.id + if parameterset.id is HIDDEN_PARAM: + yield HIDDEN_PARAM + else: + yield _ascii_escaped_by_config(parameterset.id, self.config) elif self.ids and idx < len(self.ids) and self.ids[idx] is not None: # ID provided in the IDs list - parametrize(..., ids=[...]). - yield self._idval_from_value_required(self.ids[idx], idx) + if self.ids[idx] is HIDDEN_PARAM: + yield HIDDEN_PARAM + else: + yield self._idval_from_value_required(self.ids[idx], idx) else: # ID not provided - generate it. yield "-".join( @@ -1033,9 +960,7 @@ def _idval(self, val: object, argname: str, idx: int) -> str: return idval return self._idval_from_argname(argname, idx) - def _idval_from_function( - self, val: object, argname: str, idx: int - ) -> Optional[str]: + def _idval_from_function(self, val: object, argname: str, idx: int) -> str | None: """Try to make an ID for a parameter in a ParameterSet using the user-provided id callable, if given.""" if self.idfn is None: @@ -1051,24 +976,24 @@ def _idval_from_function( return None return self._idval_from_value(id) - def _idval_from_hook(self, val: object, argname: str) -> Optional[str]: + def _idval_from_hook(self, val: object, argname: str) -> str | None: """Try to make an ID for a parameter in a ParameterSet by calling the :hook:`pytest_make_parametrize_id` hook.""" if self.config: - id: Optional[str] = self.config.hook.pytest_make_parametrize_id( + id: str | None = self.config.hook.pytest_make_parametrize_id( config=self.config, val=val, argname=argname ) return id return None - def _idval_from_value(self, val: object) -> Optional[str]: + def _idval_from_value(self, val: object) -> str | None: """Try to make an ID for a parameter in a ParameterSet from its value, if the value type is supported.""" - if isinstance(val, STRING_TYPES): + if isinstance(val, (str, bytes)): return _ascii_escaped_by_config(val, self.config) elif val is None or isinstance(val, (float, int, bool, complex)): return str(val) - elif isinstance(val, Pattern): + elif isinstance(val, re.Pattern): return ascii_escaped(val.pattern) elif val is NOTSET: # Fallback to default. Note that NOTSET is an enum.Enum. @@ -1088,12 +1013,7 @@ def _idval_from_value_required(self, val: object, idx: int) -> str: return id # Fail. - if self.func_name is not None: - prefix = f"In {self.func_name}: " - elif self.nodeid is not None: - prefix = f"In {self.nodeid}: " - else: - prefix = "" + prefix = self._make_error_prefix() msg = ( f"{prefix}ids contains unsupported value {saferepr(val)} (type: {type(val)!r}) at index {idx}. " "Supported types are: str, bytes, int, float, complex, bool, enum, regex or anything with a __name__." @@ -1106,6 +1026,21 @@ def _idval_from_argname(argname: str, idx: int) -> str: and the index of the ParameterSet.""" return str(argname) + str(idx) + def _complain_multiple_hidden_parameter_sets(self) -> NoReturn: + fail( + f"{self._make_error_prefix()}multiple instances of HIDDEN_PARAM " + "cannot be used in the same parametrize call, " + "because the tests names need to be unique." + ) + + def _make_error_prefix(self) -> str: + if self.func_name is not None: + return f"In {self.func_name}: " + elif self.nodeid is not None: + return f"In {self.nodeid}: " + else: + return "" + @final @dataclasses.dataclass(frozen=True) @@ -1117,54 +1052,46 @@ class CallSpec2: and stored in item.callspec. """ - # arg name -> arg value which will be passed to the parametrized test - # function (direct parameterization). - funcargs: Dict[str, object] = dataclasses.field(default_factory=dict) - # arg name -> arg value which will be passed to a fixture of the same name - # (indirect parametrization). - params: Dict[str, object] = dataclasses.field(default_factory=dict) + # arg name -> arg value which will be passed to a fixture or pseudo-fixture + # of the same name. (indirect or direct parametrization respectively) + params: dict[str, object] = dataclasses.field(default_factory=dict) # arg name -> arg index. - indices: Dict[str, int] = dataclasses.field(default_factory=dict) + indices: dict[str, int] = dataclasses.field(default_factory=dict) + # arg name -> parameter scope. # Used for sorting parametrized resources. - _arg2scope: Dict[str, Scope] = dataclasses.field(default_factory=dict) + _arg2scope: Mapping[str, Scope] = dataclasses.field(default_factory=dict) # Parts which will be added to the item's name in `[..]` separated by "-". - _idlist: List[str] = dataclasses.field(default_factory=list) + _idlist: Sequence[str] = dataclasses.field(default_factory=tuple) # Marks which will be applied to the item. - marks: List[Mark] = dataclasses.field(default_factory=list) + marks: list[Mark] = dataclasses.field(default_factory=list) def setmulti( self, *, - valtypes: Mapping[str, "Literal['params', 'funcargs']"], argnames: Iterable[str], valset: Iterable[object], - id: str, - marks: Iterable[Union[Mark, MarkDecorator]], + id: str | _HiddenParam, + marks: Iterable[Mark | MarkDecorator], scope: Scope, param_index: int, - ) -> "CallSpec2": - funcargs = self.funcargs.copy() + nodeid: str, + ) -> CallSpec2: params = self.params.copy() indices = self.indices.copy() - arg2scope = self._arg2scope.copy() + arg2scope = dict(self._arg2scope) for arg, val in zip(argnames, valset): - if arg in params or arg in funcargs: - raise ValueError(f"duplicate {arg!r}") - valtype_for_arg = valtypes[arg] - if valtype_for_arg == "params": - params[arg] = val - elif valtype_for_arg == "funcargs": - funcargs[arg] = val - else: - assert_never(valtype_for_arg) + if arg in params: + raise nodes.Collector.CollectError( + f"{nodeid}: duplicate parametrization of {arg!r}" + ) + params[arg] = val indices[arg] = param_index arg2scope[arg] = scope return CallSpec2( - funcargs=funcargs, params=params, indices=indices, _arg2scope=arg2scope, - _idlist=[*self._idlist, id], + _idlist=self._idlist if id is HIDDEN_PARAM else [*self._idlist, id], marks=[*self.marks, *normalize_mark_list(marks)], ) @@ -1179,6 +1106,14 @@ def id(self) -> str: return "-".join(self._idlist) +def get_direct_param_fixture_func(request: FixtureRequest) -> Any: + return request.param + + +# Used for storing pseudo fixturedefs for direct parametrization. +name2pseudofixturedef_key = StashKey[dict[str, FixtureDef[Any]]]() + + @final class Metafunc: """Objects passed to the :hook:`pytest_generate_tests` hook. @@ -1190,7 +1125,7 @@ class Metafunc: def __init__( self, - definition: "FunctionDefinition", + definition: FunctionDefinition, fixtureinfo: fixtures.FuncFixtureInfo, config: Config, cls=None, @@ -1221,27 +1156,28 @@ def __init__( self._arg2fixturedefs = fixtureinfo.name2fixturedefs # Result of parametrize(). - self._calls: List[CallSpec2] = [] + self._calls: list[CallSpec2] = [] + + self._params_directness: dict[str, Literal["indirect", "direct"]] = {} def parametrize( self, - argnames: Union[str, Sequence[str]], - argvalues: Iterable[Union[ParameterSet, Sequence[object], object]], - indirect: Union[bool, Sequence[str]] = False, - ids: Optional[ - Union[Iterable[Optional[object]], Callable[[Any], Optional[object]]] - ] = None, - scope: "Optional[_ScopeName]" = None, + argnames: str | Sequence[str], + argvalues: Iterable[ParameterSet | Sequence[object] | object], + indirect: bool | Sequence[str] = False, + ids: Iterable[object | None] | Callable[[Any], object | None] | None = None, + scope: _ScopeName | None = None, *, - _param_mark: Optional[Mark] = None, + _param_mark: Mark | None = None, ) -> None: """Add new invocations to the underlying test function using the list of argvalues for the given argnames. Parametrization is performed during the collection phase. If you need to setup expensive resources - see about setting indirect to do it rather than at test setup time. + see about setting ``indirect`` to do it at test setup time instead. - Can be called multiple times, in which case each call parametrizes all - previous parametrizations, e.g. + Can be called multiple times per test function (but only on different + argument names), in which case each call parametrizes all previous + parametrizations, e.g. :: @@ -1280,6 +1216,11 @@ def parametrize( They are mapped to the corresponding index in ``argvalues``. ``None`` means to use the auto-generated id. + .. versionadded:: 8.4 + :ref:`hidden-param` means to hide the parameter set + from the test name. Can only be used at most 1 time, as + test names need to be unique. + If it is a callable it will be called for each entry in ``argvalues``, and the return value is used as part of the auto-generated id for the whole set (where parts are joined with @@ -1296,6 +1237,8 @@ def parametrize( It will also override any fixture-function defined scope, allowing to set a dynamic scope using test context or configuration. """ + nodeid = self.definition.nodeid + argnames, parametersets = ParameterSet._for_parametrize( argnames, argvalues, @@ -1307,7 +1250,7 @@ def parametrize( if "request" in argnames: fail( - "'request' is a reserved name and cannot be used in @pytest.mark.parametrize", + f"{nodeid}: 'request' is a reserved name and cannot be used in @pytest.mark.parametrize", pytrace=False, ) @@ -1320,8 +1263,6 @@ def parametrize( self._validate_if_using_arg_names(argnames, indirect) - arg_values_types = self._resolve_arg_value_types(argnames, indirect) - # Use any already (possibly) generated ids with parametrize Marks. if _param_mark and _param_mark._param_ids_from: generated_ids = _param_mark._param_ids_from._param_ids_generated @@ -1336,6 +1277,59 @@ def parametrize( if _param_mark and _param_mark._param_ids_from and generated_ids is None: object.__setattr__(_param_mark._param_ids_from, "_param_ids_generated", ids) + # Calculate directness. + arg_directness = self._resolve_args_directness(argnames, indirect) + self._params_directness.update(arg_directness) + + # Add direct parametrizations as fixturedefs to arg2fixturedefs by + # registering artificial "pseudo" FixtureDef's such that later at test + # setup time we can rely on FixtureDefs to exist for all argnames. + node = None + # For scopes higher than function, a "pseudo" FixtureDef might have + # already been created for the scope. We thus store and cache the + # FixtureDef on the node related to the scope. + if scope_ is Scope.Function: + name2pseudofixturedef = None + else: + collector = self.definition.parent + assert collector is not None + node = get_scope_node(collector, scope_) + if node is None: + # If used class scope and there is no class, use module-level + # collector (for now). + if scope_ is Scope.Class: + assert isinstance(collector, Module) + node = collector + # If used package scope and there is no package, use session + # (for now). + elif scope_ is Scope.Package: + node = collector.session + else: + assert False, f"Unhandled missing scope: {scope}" + default: dict[str, FixtureDef[Any]] = {} + name2pseudofixturedef = node.stash.setdefault( + name2pseudofixturedef_key, default + ) + for argname in argnames: + if arg_directness[argname] == "indirect": + continue + if name2pseudofixturedef is not None and argname in name2pseudofixturedef: + fixturedef = name2pseudofixturedef[argname] + else: + fixturedef = FixtureDef( + config=self.config, + baseid="", + argname=argname, + func=get_direct_param_fixture_func, + scope=scope_, + params=None, + ids=None, + _ispytest=True, + ) + if name2pseudofixturedef is not None: + name2pseudofixturedef[argname] = fixturedef + self._arg2fixturedefs[argname] = [fixturedef] + # Create the new calls: if we are parametrize() multiple times (by applying the decorator # more than once) then we accumulate those calls generating the cartesian product # of all calls. @@ -1345,13 +1339,13 @@ def parametrize( zip(ids, parametersets) ): newcallspec = callspec.setmulti( - valtypes=arg_values_types, argnames=argnames, valset=param_set.values, id=param_id, marks=param_set.marks, scope=scope_, param_index=param_index, + nodeid=nodeid, ) newcalls.append(newcallspec) self._calls = newcalls @@ -1359,12 +1353,10 @@ def parametrize( def _resolve_parameter_set_ids( self, argnames: Sequence[str], - ids: Optional[ - Union[Iterable[Optional[object]], Callable[[Any], Optional[object]]] - ], + ids: Iterable[object | None] | Callable[[Any], object | None] | None, parametersets: Sequence[ParameterSet], nodeid: str, - ) -> List[str]: + ) -> list[str | _HiddenParam]: """Resolve the actual ids for the given parameter sets. :param argnames: @@ -1402,10 +1394,10 @@ def _resolve_parameter_set_ids( def _validate_ids( self, - ids: Iterable[Optional[object]], + ids: Iterable[object | None], parametersets: Sequence[ParameterSet], func_name: str, - ) -> List[Optional[object]]: + ) -> list[object | None]: try: num_ids = len(ids) # type: ignore[arg-type] except TypeError: @@ -1422,50 +1414,49 @@ def _validate_ids( return list(itertools.islice(ids, num_ids)) - def _resolve_arg_value_types( + def _resolve_args_directness( self, argnames: Sequence[str], - indirect: Union[bool, Sequence[str]], - ) -> Dict[str, "Literal['params', 'funcargs']"]: - """Resolve if each parametrized argument must be considered a - parameter to a fixture or a "funcarg" to the function, based on the - ``indirect`` parameter of the parametrized() call. + indirect: bool | Sequence[str], + ) -> dict[str, Literal["indirect", "direct"]]: + """Resolve if each parametrized argument must be considered an indirect + parameter to a fixture of the same name, or a direct parameter to the + parametrized function, based on the ``indirect`` parameter of the + parametrized() call. - :param List[str] argnames: List of argument names passed to ``parametrize()``. - :param indirect: Same as the ``indirect`` parameter of ``parametrize()``. - :rtype: Dict[str, str] - A dict mapping each arg name to either: - * "params" if the argname should be the parameter of a fixture of the same name. - * "funcargs" if the argname should be a parameter to the parametrized test function. + :param argnames: + List of argument names passed to ``parametrize()``. + :param indirect: + Same as the ``indirect`` parameter of ``parametrize()``. + :returns + A dict mapping each arg name to either "indirect" or "direct". """ + arg_directness: dict[str, Literal["indirect", "direct"]] if isinstance(indirect, bool): - valtypes: Dict[str, Literal["params", "funcargs"]] = dict.fromkeys( - argnames, "params" if indirect else "funcargs" + arg_directness = dict.fromkeys( + argnames, "indirect" if indirect else "direct" ) elif isinstance(indirect, Sequence): - valtypes = dict.fromkeys(argnames, "funcargs") + arg_directness = dict.fromkeys(argnames, "direct") for arg in indirect: if arg not in argnames: fail( - "In {}: indirect fixture '{}' doesn't exist".format( - self.function.__name__, arg - ), + f"In {self.function.__name__}: indirect fixture '{arg}' doesn't exist", pytrace=False, ) - valtypes[arg] = "params" + arg_directness[arg] = "indirect" else: fail( - "In {func}: expected Sequence or boolean for indirect, got {type}".format( - type=type(indirect).__name__, func=self.function.__name__ - ), + f"In {self.function.__name__}: expected Sequence or boolean" + f" for indirect, got {type(indirect).__name__}", pytrace=False, ) - return valtypes + return arg_directness def _validate_if_using_arg_names( self, argnames: Sequence[str], - indirect: Union[bool, Sequence[str]], + indirect: bool | Sequence[str], ) -> None: """Check if all argnames are being used, by default values, or directly/indirectly. @@ -1479,9 +1470,7 @@ def _validate_if_using_arg_names( if arg not in self.fixturenames: if arg in default_arg_names: fail( - "In {}: function already takes an argument '{}' with a default value".format( - func_name, arg - ), + f"In {func_name}: function already takes an argument '{arg}' with a default value", pytrace=False, ) else: @@ -1494,11 +1483,17 @@ def _validate_if_using_arg_names( pytrace=False, ) + def _recompute_direct_params_indices(self) -> None: + for argname, param_type in self._params_directness.items(): + if param_type == "direct": + for i, callspec in enumerate(self._calls): + callspec.indices[argname] = i + def _find_parametrized_scope( argnames: Sequence[str], arg2fixturedefs: Mapping[str, Sequence[fixtures.FixtureDef[object]]], - indirect: Union[bool, Sequence[str]], + indirect: bool | Sequence[str], ) -> Scope: """Find the most appropriate scope for a parametrized call based on its arguments. @@ -1517,7 +1512,7 @@ def _find_parametrized_scope( if all_arguments_are_fixtures: fixturedefs = arg2fixturedefs or {} used_scopes = [ - fixturedef[0]._scope + fixturedef[-1]._scope for name, fixturedef in fixturedefs.items() if name in argnames ] @@ -1527,7 +1522,7 @@ def _find_parametrized_scope( return Scope.Function -def _ascii_escaped_by_config(val: Union[str, bytes], config: Optional[Config]) -> str: +def _ascii_escaped_by_config(val: str | bytes, config: Config | None) -> str: if config is None: escape_option = False else: @@ -1540,140 +1535,8 @@ def _ascii_escaped_by_config(val: Union[str, bytes], config: Optional[Config]) - return val if escape_option else ascii_escaped(val) # type: ignore -def _pretty_fixture_path(func) -> str: - cwd = Path.cwd() - loc = Path(getlocation(func, str(cwd))) - prefix = Path("...", "_pytest") - try: - return str(prefix / loc.relative_to(_PYTEST_DIR)) - except ValueError: - return bestrelpath(cwd, loc) - - -def show_fixtures_per_test(config): - from _pytest.main import wrap_session - - return wrap_session(config, _show_fixtures_per_test) - - -def _show_fixtures_per_test(config: Config, session: Session) -> None: - import _pytest.config - - session.perform_collect() - curdir = Path.cwd() - tw = _pytest.config.create_terminal_writer(config) - verbose = config.getvalue("verbose") - - def get_best_relpath(func) -> str: - loc = getlocation(func, str(curdir)) - return bestrelpath(curdir, Path(loc)) - - def write_fixture(fixture_def: fixtures.FixtureDef[object]) -> None: - argname = fixture_def.argname - if verbose <= 0 and argname.startswith("_"): - return - prettypath = _pretty_fixture_path(fixture_def.func) - tw.write(f"{argname}", green=True) - tw.write(f" -- {prettypath}", yellow=True) - tw.write("\n") - fixture_doc = inspect.getdoc(fixture_def.func) - if fixture_doc: - write_docstring( - tw, fixture_doc.split("\n\n")[0] if verbose <= 0 else fixture_doc - ) - else: - tw.line(" no docstring available", red=True) - - def write_item(item: nodes.Item) -> None: - # Not all items have _fixtureinfo attribute. - info: Optional[FuncFixtureInfo] = getattr(item, "_fixtureinfo", None) - if info is None or not info.name2fixturedefs: - # This test item does not use any fixtures. - return - tw.line() - tw.sep("-", f"fixtures used by {item.name}") - # TODO: Fix this type ignore. - tw.sep("-", f"({get_best_relpath(item.function)})") # type: ignore[attr-defined] - # dict key not used in loop but needed for sorting. - for _, fixturedefs in sorted(info.name2fixturedefs.items()): - assert fixturedefs is not None - if not fixturedefs: - continue - # Last item is expected to be the one used by the test item. - write_fixture(fixturedefs[-1]) - - for session_item in session.items: - write_item(session_item) - - -def showfixtures(config: Config) -> Union[int, ExitCode]: - from _pytest.main import wrap_session - - return wrap_session(config, _showfixtures_main) - - -def _showfixtures_main(config: Config, session: Session) -> None: - import _pytest.config - - session.perform_collect() - curdir = Path.cwd() - tw = _pytest.config.create_terminal_writer(config) - verbose = config.getvalue("verbose") - - fm = session._fixturemanager - - available = [] - seen: Set[Tuple[str, str]] = set() - - for argname, fixturedefs in fm._arg2fixturedefs.items(): - assert fixturedefs is not None - if not fixturedefs: - continue - for fixturedef in fixturedefs: - loc = getlocation(fixturedef.func, str(curdir)) - if (fixturedef.argname, loc) in seen: - continue - seen.add((fixturedef.argname, loc)) - available.append( - ( - len(fixturedef.baseid), - fixturedef.func.__module__, - _pretty_fixture_path(fixturedef.func), - fixturedef.argname, - fixturedef, - ) - ) - - available.sort() - currentmodule = None - for baseid, module, prettypath, argname, fixturedef in available: - if currentmodule != module: - if not module.startswith("_pytest."): - tw.line() - tw.sep("-", f"fixtures defined from {module}") - currentmodule = module - if verbose <= 0 and argname.startswith("_"): - continue - tw.write(f"{argname}", green=True) - if fixturedef.scope != "function": - tw.write(" [%s scope]" % fixturedef.scope, cyan=True) - tw.write(f" -- {prettypath}", yellow=True) - tw.write("\n") - doc = inspect.getdoc(fixturedef.func) - if doc: - write_docstring(tw, doc.split("\n\n")[0] if verbose <= 0 else doc) - else: - tw.line(" no docstring available", red=True) - tw.line() - - -def write_docstring(tw: TerminalWriter, doc: str, indent: str = " ") -> None: - for line in doc.split("\n"): - tw.line(indent + line) - - class Function(PyobjMixin, nodes.Item): - """An Item responsible for setting up and executing a Python test function. + """Item responsible for setting up and executing a Python test function. :param name: The full function name, including any decorations like those @@ -1683,7 +1546,7 @@ class Function(PyobjMixin, nodes.Item): :param config: The pytest Config object. :param callspec: - If given, this is function has been parametrized and the callspec contains + If given, this function has been parametrized and the callspec contains meta information about the parametrization. :param callobj: If given, the object which will be called when the Function is invoked, @@ -1708,18 +1571,19 @@ def __init__( self, name: str, parent, - config: Optional[Config] = None, - callspec: Optional[CallSpec2] = None, + config: Config | None = None, + callspec: CallSpec2 | None = None, callobj=NOTSET, - keywords: Optional[Mapping[str, Any]] = None, - session: Optional[Session] = None, - fixtureinfo: Optional[FuncFixtureInfo] = None, - originalname: Optional[str] = None, + keywords: Mapping[str, Any] | None = None, + session: Session | None = None, + fixtureinfo: FuncFixtureInfo | None = None, + originalname: str | None = None, ) -> None: super().__init__(name, parent, config=config, session=session) if callobj is not NOTSET: - self.obj = callobj + self._obj = callobj + self._instance = getattr(callobj, "__self__", None) #: Original function name, without any decorations (for example #: parametrization adds a ``"[...]"`` suffix to function names), used to access @@ -1748,33 +1612,52 @@ def __init__( self.keywords.update(keywords) if fixtureinfo is None: - fixtureinfo = self.session._fixturemanager.getfixtureinfo( - self, self.obj, self.cls, funcargs=True - ) + fm = self.session._fixturemanager + fixtureinfo = fm.getfixtureinfo(self, self.obj, self.cls) self._fixtureinfo: FuncFixtureInfo = fixtureinfo self.fixturenames = fixtureinfo.names_closure self._initrequest() + # todo: determine sound type limitations @classmethod - def from_parent(cls, parent, **kw): # todo: determine sound type limitations + def from_parent(cls, parent, **kw) -> Self: """The public constructor.""" return super().from_parent(parent=parent, **kw) def _initrequest(self) -> None: - self.funcargs: Dict[str, object] = {} - self._request = fixtures.FixtureRequest(self, _ispytest=True) + self.funcargs: dict[str, object] = {} + self._request = fixtures.TopRequest(self, _ispytest=True) @property def function(self): """Underlying python 'function' object.""" return getimfunc(self.obj) - def _getobj(self): - assert self.parent is not None + @property + def instance(self): + try: + return self._instance + except AttributeError: + if isinstance(self.parent, Class): + # Each Function gets a fresh class instance. + self._instance = self._getinstance() + else: + self._instance = None + return self._instance + + def _getinstance(self): if isinstance(self.parent, Class): # Each Function gets a fresh class instance. - parent_obj = self.parent.newinstance() + return self.parent.newinstance() else: + return None + + def _getobj(self): + instance = self.instance + if instance is not None: + parent_obj = instance + else: + assert self.parent is not None parent_obj = self.parent.obj # type: ignore[attr-defined] return getattr(parent_obj, self.originalname) @@ -1809,10 +1692,11 @@ def _traceback_filter(self, excinfo: ExceptionInfo[BaseException]) -> Traceback: if self.config.getoption("tbstyle", "auto") == "auto": if len(ntraceback) > 2: ntraceback = Traceback( - entry - if i == 0 or i == len(ntraceback) - 1 - else entry.with_repr_style("short") - for i, entry in enumerate(ntraceback) + ( + ntraceback[0], + *(t.with_repr_style("short") for t in ntraceback[1:-1]), + ntraceback[-1], + ) ) return ntraceback @@ -1822,7 +1706,7 @@ def _traceback_filter(self, excinfo: ExceptionInfo[BaseException]) -> Traceback: def repr_failure( # type: ignore[override] self, excinfo: ExceptionInfo[BaseException], - ) -> Union[str, TerminalRepr]: + ) -> str | TerminalRepr: style = self.config.getoption("tbstyle", "auto") if style == "auto": style = "long" @@ -1830,10 +1714,8 @@ def repr_failure( # type: ignore[override] class FunctionDefinition(Function): - """ - This class is a step gap solution until we evolve to have actual function definition nodes - and manage to get rid of ``metafunc``. - """ + """This class is a stop gap solution until we evolve to have actual function + definition nodes and manage to get rid of ``metafunc``.""" def runtest(self) -> None: raise RuntimeError("function definitions are not supposed to be run as tests") diff --git a/src/_pytest/python_api.py b/src/_pytest/python_api.py index 4213bd098c2..74346c34976 100644 --- a/src/_pytest/python_api.py +++ b/src/_pytest/python_api.py @@ -1,53 +1,31 @@ -import math -import pprint +# mypy: allow-untyped-defs +from __future__ import annotations + from collections.abc import Collection +from collections.abc import Mapping +from collections.abc import Sequence from collections.abc import Sized from decimal import Decimal +import math from numbers import Complex -from types import TracebackType +import pprint +import sys from typing import Any -from typing import Callable -from typing import cast -from typing import ContextManager -from typing import List -from typing import Mapping -from typing import Optional -from typing import Pattern -from typing import Sequence -from typing import Tuple -from typing import Type from typing import TYPE_CHECKING -from typing import TypeVar -from typing import Union + if TYPE_CHECKING: from numpy import ndarray -import _pytest._code -from _pytest.compat import final -from _pytest.compat import STRING_TYPES -from _pytest.compat import overload -from _pytest.outcomes import fail - - -def _non_numeric_type_error(value, at: Optional[str]) -> TypeError: - at_str = f" at {at}" if at else "" - return TypeError( - "cannot make approximate comparisons to non-numeric values: {!r} {}".format( - value, at_str - ) - ) - - def _compare_approx( full_object: object, - message_data: Sequence[Tuple[str, str, str]], + message_data: Sequence[tuple[str, str, str]], number_of_elements: int, different_ids: Sequence[object], max_abs_diff: float, max_rel_diff: float, -) -> List[str]: +) -> list[str]: message_list = list(message_data) message_list.insert(0, ("Index", "Obtained", "Expected")) max_sizes = [0, 0, 0] @@ -88,7 +66,7 @@ def __init__(self, expected, rel=None, abs=None, nan_ok: bool = False) -> None: def __repr__(self) -> str: raise NotImplementedError - def _repr_compare(self, other_side: Any) -> List[str]: + def _repr_compare(self, other_side: Any) -> list[str]: return [ "comparison failed", f"Obtained: {other_side}", @@ -112,7 +90,7 @@ def __bool__(self): def __ne__(self, actual) -> bool: return not (actual == self) - def _approx_scalar(self, x) -> "ApproxScalar": + def _approx_scalar(self, x) -> ApproxScalar: if isinstance(x, Decimal): return ApproxDecimal(x, rel=self.rel, abs=self.abs, nan_ok=self.nan_ok) return ApproxScalar(x, rel=self.rel, abs=self.abs, nan_ok=self.nan_ok) @@ -138,6 +116,8 @@ def _recursive_sequence_map(f, x): if isinstance(x, (list, tuple)): seq_type = type(x) return seq_type(_recursive_sequence_map(f, xi) for xi in x) + elif _is_sequence_like(x): + return [_recursive_sequence_map(f, xi) for xi in x] else: return f(x) @@ -151,12 +131,12 @@ def __repr__(self) -> str: ) return f"approx({list_scalars!r})" - def _repr_compare(self, other_side: "ndarray") -> List[str]: + def _repr_compare(self, other_side: ndarray | list[Any]) -> list[str]: import itertools import math def get_value_from_nested_list( - nested_list: List[Any], nd_index: Tuple[Any, ...] + nested_list: list[Any], nd_index: tuple[Any, ...] ) -> Any: """ Helper function to get the value out of a nested list, given an n-dimensional index. @@ -172,10 +152,14 @@ def get_value_from_nested_list( self._approx_scalar, self.expected.tolist() ) - if np_array_shape != other_side.shape: + # convert other_side to numpy array to ensure shape attribute is available + other_side_as_array = _as_numpy_array(other_side) + assert other_side_as_array is not None + + if np_array_shape != other_side_as_array.shape: return [ "Impossible to compare arrays with different shapes.", - f"Shapes: {np_array_shape} and {other_side.shape}", + f"Shapes: {np_array_shape} and {other_side_as_array.shape}", ] number_of_elements = self.expected.size @@ -184,7 +168,7 @@ def get_value_from_nested_list( different_ids = [] for index in itertools.product(*(range(i) for i in np_array_shape)): approx_value = get_value_from_nested_list(approx_side_as_seq, index) - other_value = get_value_from_nested_list(other_side, index) + other_value = get_value_from_nested_list(other_side_as_array, index) if approx_value != other_value: abs_diff = abs(approx_value.expected - other_value) max_abs_diff = max(max_abs_diff, abs_diff) @@ -197,7 +181,7 @@ def get_value_from_nested_list( message_data = [ ( str(index), - str(get_value_from_nested_list(other_side, index)), + str(get_value_from_nested_list(other_side_as_array, index)), str(get_value_from_nested_list(approx_side_as_seq, index)), ) for index in different_ids @@ -247,11 +231,9 @@ class ApproxMapping(ApproxBase): with numeric values (the keys can be anything).""" def __repr__(self) -> str: - return "approx({!r})".format( - {k: self._approx_scalar(v) for k, v in self.expected.items()} - ) + return f"approx({ ({k: self._approx_scalar(v) for k, v in self.expected.items()})!r})" - def _repr_compare(self, other_side: Mapping[object, float]) -> List[str]: + def _repr_compare(self, other_side: Mapping[object, float]) -> list[str]: import math approx_side_as_map = { @@ -266,19 +248,23 @@ def _repr_compare(self, other_side: Mapping[object, float]) -> List[str]: approx_side_as_map.items(), other_side.values() ): if approx_value != other_value: - max_abs_diff = max( - max_abs_diff, abs(approx_value.expected - other_value) - ) - if approx_value.expected == 0.0: - max_rel_diff = math.inf - else: - max_rel_diff = max( - max_rel_diff, - abs( - (approx_value.expected - other_value) - / approx_value.expected - ), - ) + if approx_value.expected is not None and other_value is not None: + try: + max_abs_diff = max( + max_abs_diff, abs(approx_value.expected - other_value) + ) + if approx_value.expected == 0.0: + max_rel_diff = math.inf + else: + max_rel_diff = max( + max_rel_diff, + abs( + (approx_value.expected - other_value) + / approx_value.expected + ), + ) + except ZeroDivisionError: + pass different_ids.append(approx_key) message_data = [ @@ -323,11 +309,9 @@ def __repr__(self) -> str: seq_type = type(self.expected) if seq_type not in (tuple, list): seq_type = list - return "approx({!r})".format( - seq_type(self._approx_scalar(x) for x in self.expected) - ) + return f"approx({seq_type(self._approx_scalar(x) for x in self.expected)!r})" - def _repr_compare(self, other_side: Sequence[float]) -> List[str]: + def _repr_compare(self, other_side: Sequence[float]) -> list[str]: import math if len(self.expected) != len(other_side): @@ -346,14 +330,18 @@ def _repr_compare(self, other_side: Sequence[float]) -> List[str]: zip(approx_side_as_map, other_side) ): if approx_value != other_value: - abs_diff = abs(approx_value.expected - other_value) - max_abs_diff = max(max_abs_diff, abs_diff) - if other_value == 0.0: - max_rel_diff = math.inf + try: + abs_diff = abs(approx_value.expected - other_value) + max_abs_diff = max(max_abs_diff, abs_diff) + # Ignore non-numbers for the diff calculations (#13012). + except TypeError: + pass else: - max_rel_diff = max(max_rel_diff, abs_diff / abs(other_value)) + if other_value == 0.0: + max_rel_diff = math.inf + else: + max_rel_diff = max(max_rel_diff, abs_diff / abs(other_value)) different_ids.append(i) - message_data = [ (str(i), str(other_side[i]), str(approx_side_as_map[i])) for i in different_ids @@ -392,8 +380,8 @@ class ApproxScalar(ApproxBase): # Using Real should be better than this Union, but not possible yet: # https://github.com/python/typeshed/pull/3108 - DEFAULT_ABSOLUTE_TOLERANCE: Union[float, Decimal] = 1e-12 - DEFAULT_RELATIVE_TOLERANCE: Union[float, Decimal] = 1e-6 + DEFAULT_ABSOLUTE_TOLERANCE: float | Decimal = 1e-12 + DEFAULT_RELATIVE_TOLERANCE: float | Decimal = 1e-6 def __repr__(self) -> str: """Return a string communicating both the expected value and the @@ -404,15 +392,21 @@ def __repr__(self) -> str: # Don't show a tolerance for values that aren't compared using # tolerances, i.e. non-numerics and infinities. Need to call abs to # handle complex numbers, e.g. (inf + 1j). - if (not isinstance(self.expected, (Complex, Decimal))) or math.isinf( - abs(self.expected) # type: ignore[arg-type] + if ( + isinstance(self.expected, bool) + or (not isinstance(self.expected, (Complex, Decimal))) + or math.isinf(abs(self.expected) or isinstance(self.expected, bool)) ): return str(self.expected) # If a sensible tolerance can't be calculated, self.tolerance will # raise a ValueError. In this case, display '???'. try: - vetted_tolerance = f"{self.tolerance:.1e}" + if 1e-3 <= self.tolerance < 1e3: + vetted_tolerance = f"{self.tolerance:n}" + else: + vetted_tolerance = f"{self.tolerance:.1e}" + if ( isinstance(self.expected, Complex) and self.expected.imag @@ -427,20 +421,32 @@ def __repr__(self) -> str: def __eq__(self, actual) -> bool: """Return whether the given value is equal to the expected value within the pre-specified tolerance.""" + + def is_bool(val: Any) -> bool: + # Check if `val` is a native bool or numpy bool. + if isinstance(val, bool): + return True + if np := sys.modules.get("numpy"): + return isinstance(val, np.bool_) + return False + asarray = _as_numpy_array(actual) if asarray is not None: # Call ``__eq__()`` manually to prevent infinite-recursion with # numpy<1.13. See #3748. return all(self.__eq__(a) for a in asarray.flat) - # Short-circuit exact equality. - if actual == self.expected: + # Short-circuit exact equality, except for bool and np.bool_ + if is_bool(self.expected) and not is_bool(actual): + return False + elif actual == self.expected: return True # If either type is non-numeric, fall back to strict equality. # NB: we need Complex, rather than just Number, to ensure that __abs__, - # __sub__, and __float__ are defined. - if not ( + # __sub__, and __float__ are defined. Also, consider bool to be + # non-numeric, even though it has the required arithmetic. + if is_bool(self.expected) or not ( isinstance(self.expected, (Complex, Decimal)) and isinstance(actual, (Complex, Decimal)) ): @@ -449,8 +455,8 @@ def __eq__(self, actual) -> bool: # Allow the user to control whether NaNs are considered equal to each # other or not. The abs() calls are for compatibility with complex # numbers. - if math.isnan(abs(self.expected)): # type: ignore[arg-type] - return self.nan_ok and math.isnan(abs(actual)) # type: ignore[arg-type] + if math.isnan(abs(self.expected)): + return self.nan_ok and math.isnan(abs(actual)) # Infinity shouldn't be approximately equal to anything but itself, but # if there's a relative tolerance, it will be infinite and infinity @@ -458,7 +464,7 @@ def __eq__(self, actual) -> bool: # case would have been short circuited above, so here we can just # return false if the expected value is infinite. The abs() call is # for compatibility with complex numbers. - if math.isinf(abs(self.expected)): # type: ignore[arg-type] + if math.isinf(abs(self.expected)): return False # Return true if the two numbers are within the tolerance. @@ -522,6 +528,25 @@ class ApproxDecimal(ApproxScalar): DEFAULT_ABSOLUTE_TOLERANCE = Decimal("1e-12") DEFAULT_RELATIVE_TOLERANCE = Decimal("1e-6") + def __repr__(self) -> str: + if isinstance(self.rel, float): + rel = Decimal.from_float(self.rel) + else: + rel = self.rel + + if isinstance(self.abs, float): + abs_ = Decimal.from_float(self.abs) + else: + abs_ = self.abs + + tol_str = "???" + if rel is not None and Decimal("1e-3") <= rel <= Decimal("1e3"): + tol_str = f"{rel:.1e}" + elif abs_ is not None: + tol_str = f"{abs_:.1e}" + + return f"{self.expected} ± {tol_str}" + def approx(expected, rel=None, abs=None, nan_ok: bool = False) -> ApproxBase: """Assert that two numbers (or two ordered sequences of numbers) are equal to each other @@ -623,8 +648,10 @@ def approx(expected, rel=None, abs=None, nan_ok: bool = False) -> ApproxBase: >>> 1 + 1e-8 == approx(1, rel=1e-6, abs=1e-12) True - You can also use ``approx`` to compare nonnumeric types, or dicts and - sequences containing nonnumeric types, in which case it falls back to + **Non-numeric types** + + You can also use ``approx`` to compare non-numeric types, or dicts and + sequences containing non-numeric types, in which case it falls back to strict equality. This can be useful for comparing dicts and sequences that can contain optional values:: @@ -681,6 +708,15 @@ def approx(expected, rel=None, abs=None, nan_ok: bool = False) -> ApproxBase: from the `re_assert package `_. + + .. note:: + + Unlike built-in equality, this function considers + booleans unequal to numeric zero or one. For example:: + + >>> 1 == approx(True) + False + .. warning:: .. versionchanged:: 3.2 @@ -699,13 +735,12 @@ def approx(expected, rel=None, abs=None, nan_ok: bool = False) -> ApproxBase: .. versionchanged:: 3.7.1 ``approx`` raises ``TypeError`` when it encounters a dict value or - sequence element of nonnumeric type. + sequence element of non-numeric type. .. versionchanged:: 6.1.0 - ``approx`` falls back to strict equality for nonnumeric types instead + ``approx`` falls back to strict equality for non-numeric types instead of raising ``TypeError``. """ - # Delegate the comparison to a class that knows how to deal with the type # of the expected value (e.g. int, float, list, dict, numpy.array, etc). # @@ -724,25 +759,16 @@ def approx(expected, rel=None, abs=None, nan_ok: bool = False) -> ApproxBase: __tracebackhide__ = True if isinstance(expected, Decimal): - cls: Type[ApproxBase] = ApproxDecimal + cls: type[ApproxBase] = ApproxDecimal elif isinstance(expected, Mapping): cls = ApproxMapping elif _is_numpy_array(expected): expected = _as_numpy_array(expected) cls = ApproxNumpy - elif ( - hasattr(expected, "__getitem__") - and isinstance(expected, Sized) - # Type ignored because the error is wrong -- not unreachable. - and not isinstance(expected, STRING_TYPES) # type: ignore[unreachable] - ): + elif _is_sequence_like(expected): cls = ApproxSequenceLike - elif ( - isinstance(expected, Collection) - # Type ignored because the error is wrong -- not unreachable. - and not isinstance(expected, STRING_TYPES) # type: ignore[unreachable] - ): - msg = f"pytest.approx() only supports ordered sequences, but got: {repr(expected)}" + elif isinstance(expected, Collection) and not isinstance(expected, (str, bytes)): + msg = f"pytest.approx() only supports ordered sequences, but got: {expected!r}" raise TypeError(msg) else: cls = ApproxScalar @@ -750,6 +776,14 @@ def approx(expected, rel=None, abs=None, nan_ok: bool = False) -> ApproxBase: return cls(expected, rel, abs, nan_ok) +def _is_sequence_like(expected: object) -> bool: + return ( + hasattr(expected, "__getitem__") + and isinstance(expected, Sized) + and not isinstance(expected, (str, bytes)) + ) + + def _is_numpy_array(obj: object) -> bool: """ Return true if the given object is implicitly convertible to ndarray, @@ -758,13 +792,11 @@ def _is_numpy_array(obj: object) -> bool: return _as_numpy_array(obj) is not None -def _as_numpy_array(obj: object) -> Optional["ndarray"]: +def _as_numpy_array(obj: object) -> ndarray | None: """ Return an ndarray if the given object is implicitly convertible to ndarray, and numpy is already imported, otherwise None. """ - import sys - np: Any = sys.modules.get("numpy") if np is not None: # avoid infinite recursion on numpy scalars, which have __array__ @@ -775,221 +807,3 @@ def _as_numpy_array(obj: object) -> Optional["ndarray"]: elif hasattr(obj, "__array__") or hasattr("obj", "__array_interface__"): return np.asarray(obj) return None - - -# builtin pytest.raises helper - -E = TypeVar("E", bound=BaseException) - - -@overload -def raises( - expected_exception: Union[Type[E], Tuple[Type[E], ...]], - *, - match: Optional[Union[str, Pattern[str]]] = ..., -) -> "RaisesContext[E]": - ... - - -@overload -def raises( # noqa: F811 - expected_exception: Union[Type[E], Tuple[Type[E], ...]], - func: Callable[..., Any], - *args: Any, - **kwargs: Any, -) -> _pytest._code.ExceptionInfo[E]: - ... - - -def raises( # noqa: F811 - expected_exception: Union[Type[E], Tuple[Type[E], ...]], *args: Any, **kwargs: Any -) -> Union["RaisesContext[E]", _pytest._code.ExceptionInfo[E]]: - r"""Assert that a code block/function call raises an exception. - - :param typing.Type[E] | typing.Tuple[typing.Type[E], ...] expected_exception: - The expected exception type, or a tuple if one of multiple possible - exception types are expected. - :kwparam str | typing.Pattern[str] | None match: - If specified, a string containing a regular expression, - or a regular expression object, that is tested against the string - representation of the exception using :func:`re.search`. - - To match a literal string that may contain :ref:`special characters - `, the pattern can first be escaped with :func:`re.escape`. - - (This is only used when :py:func:`pytest.raises` is used as a context manager, - and passed through to the function otherwise. - When using :py:func:`pytest.raises` as a function, you can use: - ``pytest.raises(Exc, func, match="passed on").match("my pattern")``.) - - .. currentmodule:: _pytest._code - - Use ``pytest.raises`` as a context manager, which will capture the exception of the given - type:: - - >>> import pytest - >>> with pytest.raises(ZeroDivisionError): - ... 1/0 - - If the code block does not raise the expected exception (``ZeroDivisionError`` in the example - above), or no exception at all, the check will fail instead. - - You can also use the keyword argument ``match`` to assert that the - exception matches a text or regex:: - - >>> with pytest.raises(ValueError, match='must be 0 or None'): - ... raise ValueError("value must be 0 or None") - - >>> with pytest.raises(ValueError, match=r'must be \d+$'): - ... raise ValueError("value must be 42") - - The context manager produces an :class:`ExceptionInfo` object which can be used to inspect the - details of the captured exception:: - - >>> with pytest.raises(ValueError) as exc_info: - ... raise ValueError("value must be 42") - >>> assert exc_info.type is ValueError - >>> assert exc_info.value.args[0] == "value must be 42" - - .. note:: - - When using ``pytest.raises`` as a context manager, it's worthwhile to - note that normal context manager rules apply and that the exception - raised *must* be the final line in the scope of the context manager. - Lines of code after that, within the scope of the context manager will - not be executed. For example:: - - >>> value = 15 - >>> with pytest.raises(ValueError) as exc_info: - ... if value > 10: - ... raise ValueError("value must be <= 10") - ... assert exc_info.type is ValueError # this will not execute - - Instead, the following approach must be taken (note the difference in - scope):: - - >>> with pytest.raises(ValueError) as exc_info: - ... if value > 10: - ... raise ValueError("value must be <= 10") - ... - >>> assert exc_info.type is ValueError - - **Using with** ``pytest.mark.parametrize`` - - When using :ref:`pytest.mark.parametrize ref` - it is possible to parametrize tests such that - some runs raise an exception and others do not. - - See :ref:`parametrizing_conditional_raising` for an example. - - **Legacy form** - - It is possible to specify a callable by passing a to-be-called lambda:: - - >>> raises(ZeroDivisionError, lambda: 1/0) - - - or you can specify an arbitrary callable with arguments:: - - >>> def f(x): return 1/x - ... - >>> raises(ZeroDivisionError, f, 0) - - >>> raises(ZeroDivisionError, f, x=0) - - - The form above is fully supported but discouraged for new code because the - context manager form is regarded as more readable and less error-prone. - - .. note:: - Similar to caught exception objects in Python, explicitly clearing - local references to returned ``ExceptionInfo`` objects can - help the Python interpreter speed up its garbage collection. - - Clearing those references breaks a reference cycle - (``ExceptionInfo`` --> caught exception --> frame stack raising - the exception --> current frame stack --> local variables --> - ``ExceptionInfo``) which makes Python keep all objects referenced - from that cycle (including all local variables in the current - frame) alive until the next cyclic garbage collection run. - More detailed information can be found in the official Python - documentation for :ref:`the try statement `. - """ - __tracebackhide__ = True - - if not expected_exception: - raise ValueError( - f"Expected an exception type or a tuple of exception types, but got `{expected_exception!r}`. " - f"Raising exceptions is already understood as failing the test, so you don't need " - f"any special code to say 'this should never raise an exception'." - ) - if isinstance(expected_exception, type): - expected_exceptions: Tuple[Type[E], ...] = (expected_exception,) - else: - expected_exceptions = expected_exception - for exc in expected_exceptions: - if not isinstance(exc, type) or not issubclass(exc, BaseException): - msg = "expected exception must be a BaseException type, not {}" # type: ignore[unreachable] - not_a = exc.__name__ if isinstance(exc, type) else type(exc).__name__ - raise TypeError(msg.format(not_a)) - - message = f"DID NOT RAISE {expected_exception}" - - if not args: - match: Optional[Union[str, Pattern[str]]] = kwargs.pop("match", None) - if kwargs: - msg = "Unexpected keyword arguments passed to pytest.raises: " - msg += ", ".join(sorted(kwargs)) - msg += "\nUse context-manager form instead?" - raise TypeError(msg) - return RaisesContext(expected_exception, message, match) - else: - func = args[0] - if not callable(func): - raise TypeError(f"{func!r} object (type: {type(func)}) must be callable") - try: - func(*args[1:], **kwargs) - except expected_exception as e: - return _pytest._code.ExceptionInfo.from_exception(e) - fail(message) - - -# This doesn't work with mypy for now. Use fail.Exception instead. -raises.Exception = fail.Exception # type: ignore - - -@final -class RaisesContext(ContextManager[_pytest._code.ExceptionInfo[E]]): - def __init__( - self, - expected_exception: Union[Type[E], Tuple[Type[E], ...]], - message: str, - match_expr: Optional[Union[str, Pattern[str]]] = None, - ) -> None: - self.expected_exception = expected_exception - self.message = message - self.match_expr = match_expr - self.excinfo: Optional[_pytest._code.ExceptionInfo[E]] = None - - def __enter__(self) -> _pytest._code.ExceptionInfo[E]: - self.excinfo = _pytest._code.ExceptionInfo.for_later() - return self.excinfo - - def __exit__( - self, - exc_type: Optional[Type[BaseException]], - exc_val: Optional[BaseException], - exc_tb: Optional[TracebackType], - ) -> bool: - __tracebackhide__ = True - if exc_type is None: - fail(self.message) - assert self.excinfo is not None - if not issubclass(exc_type, self.expected_exception): - return False - # Cast to narrow the exception type now that it's verified. - exc_info = cast(Tuple[Type[E], E, TracebackType], (exc_type, exc_val, exc_tb)) - self.excinfo.fill_unfilled(exc_info) - if self.match_expr is not None: - self.excinfo.match(self.match_expr) - return True diff --git a/src/_pytest/python_path.py b/src/_pytest/python_path.py deleted file mode 100644 index cceabbca12a..00000000000 --- a/src/_pytest/python_path.py +++ /dev/null @@ -1,24 +0,0 @@ -import sys - -import pytest -from pytest import Config -from pytest import Parser - - -def pytest_addoption(parser: Parser) -> None: - parser.addini("pythonpath", type="paths", help="Add paths to sys.path", default=[]) - - -@pytest.hookimpl(tryfirst=True) -def pytest_load_initial_conftests(early_config: Config) -> None: - # `pythonpath = a b` will set `sys.path` to `[a, b, x, y, z, ...]` - for path in reversed(early_config.getini("pythonpath")): - sys.path.insert(0, str(path)) - - -@pytest.hookimpl(trylast=True) -def pytest_unconfigure(config: Config) -> None: - for path in config.getini("pythonpath"): - path_str = str(path) - if path_str in sys.path: - sys.path.remove(path_str) diff --git a/src/_pytest/raises.py b/src/_pytest/raises.py new file mode 100644 index 00000000000..78fae6ddcde --- /dev/null +++ b/src/_pytest/raises.py @@ -0,0 +1,1519 @@ +from __future__ import annotations + +from abc import ABC +from abc import abstractmethod +import re +from re import Pattern +import sys +from textwrap import indent +from typing import Any +from typing import cast +from typing import final +from typing import Generic +from typing import get_args +from typing import get_origin +from typing import Literal +from typing import overload +from typing import TYPE_CHECKING +import warnings + +from _pytest._code import ExceptionInfo +from _pytest._code.code import stringify_exception +from _pytest.outcomes import fail +from _pytest.warning_types import PytestWarning + + +if TYPE_CHECKING: + from collections.abc import Callable + from collections.abc import Sequence + + # for some reason Sphinx does not play well with 'from types import TracebackType' + import types + + from typing_extensions import ParamSpec + from typing_extensions import TypeGuard + from typing_extensions import TypeVar + + P = ParamSpec("P") + + # this conditional definition is because we want to allow a TypeVar default + BaseExcT_co_default = TypeVar( + "BaseExcT_co_default", + bound=BaseException, + default=BaseException, + covariant=True, + ) + + # Use short name because it shows up in docs. + E = TypeVar("E", bound=BaseException, default=BaseException) +else: + from typing import TypeVar + + BaseExcT_co_default = TypeVar( + "BaseExcT_co_default", bound=BaseException, covariant=True + ) + +# RaisesGroup doesn't work with a default. +BaseExcT_co = TypeVar("BaseExcT_co", bound=BaseException, covariant=True) +BaseExcT_1 = TypeVar("BaseExcT_1", bound=BaseException) +BaseExcT_2 = TypeVar("BaseExcT_2", bound=BaseException) +ExcT_1 = TypeVar("ExcT_1", bound=Exception) +ExcT_2 = TypeVar("ExcT_2", bound=Exception) + +if sys.version_info < (3, 11): + from exceptiongroup import BaseExceptionGroup + from exceptiongroup import ExceptionGroup + + +# String patterns default to including the unicode flag. +_REGEX_NO_FLAGS = re.compile(r"").flags + + +# pytest.raises helper +@overload +def raises( + expected_exception: type[E] | tuple[type[E], ...], + *, + match: str | re.Pattern[str] | None = ..., + check: Callable[[E], bool] = ..., +) -> RaisesExc[E]: ... + + +@overload +def raises( + *, + match: str | re.Pattern[str], + # If exception_type is not provided, check() must do any typechecks itself. + check: Callable[[BaseException], bool] = ..., +) -> RaisesExc[BaseException]: ... + + +@overload +def raises(*, check: Callable[[BaseException], bool]) -> RaisesExc[BaseException]: ... + + +@overload +def raises( + expected_exception: type[E] | tuple[type[E], ...], + func: Callable[..., Any], + *args: Any, + **kwargs: Any, +) -> ExceptionInfo[E]: ... + + +def raises( + expected_exception: type[E] | tuple[type[E], ...] | None = None, + *args: Any, + **kwargs: Any, +) -> RaisesExc[BaseException] | ExceptionInfo[E]: + r"""Assert that a code block/function call raises an exception type, or one of its subclasses. + + :param expected_exception: + The expected exception type, or a tuple if one of multiple possible + exception types are expected. Note that subclasses of the passed exceptions + will also match. + + This is not a required parameter, you may opt to only use ``match`` and/or + ``check`` for verifying the raised exception. + + :kwparam str | re.Pattern[str] | None match: + If specified, a string containing a regular expression, + or a regular expression object, that is tested against the string + representation of the exception and its :pep:`678` `__notes__` + using :func:`re.search`. + + To match a literal string that may contain :ref:`special characters + `, the pattern can first be escaped with :func:`re.escape`. + + (This is only used when ``pytest.raises`` is used as a context manager, + and passed through to the function otherwise. + When using ``pytest.raises`` as a function, you can use: + ``pytest.raises(Exc, func, match="passed on").match("my pattern")``.) + + :kwparam Callable[[BaseException], bool] check: + + .. versionadded:: 8.4 + + If specified, a callable that will be called with the exception as a parameter + after checking the type and the match regex if specified. + If it returns ``True`` it will be considered a match, if not it will + be considered a failed match. + + + Use ``pytest.raises`` as a context manager, which will capture the exception of the given + type, or any of its subclasses:: + + >>> import pytest + >>> with pytest.raises(ZeroDivisionError): + ... 1/0 + + If the code block does not raise the expected exception (:class:`ZeroDivisionError` in the example + above), or no exception at all, the check will fail instead. + + You can also use the keyword argument ``match`` to assert that the + exception matches a text or regex:: + + >>> with pytest.raises(ValueError, match='must be 0 or None'): + ... raise ValueError("value must be 0 or None") + + >>> with pytest.raises(ValueError, match=r'must be \d+$'): + ... raise ValueError("value must be 42") + + The ``match`` argument searches the formatted exception string, which includes any + `PEP-678 `__ ``__notes__``: + + >>> with pytest.raises(ValueError, match=r"had a note added"): # doctest: +SKIP + ... e = ValueError("value must be 42") + ... e.add_note("had a note added") + ... raise e + + The ``check`` argument, if provided, must return True when passed the raised exception + for the match to be successful, otherwise an :exc:`AssertionError` is raised. + + >>> import errno + >>> with pytest.raises(OSError, check=lambda e: e.errno == errno.EACCES): + ... raise OSError(errno.EACCES, "no permission to view") + + The context manager produces an :class:`ExceptionInfo` object which can be used to inspect the + details of the captured exception:: + + >>> with pytest.raises(ValueError) as exc_info: + ... raise ValueError("value must be 42") + >>> assert exc_info.type is ValueError + >>> assert exc_info.value.args[0] == "value must be 42" + + .. warning:: + + Given that ``pytest.raises`` matches subclasses, be wary of using it to match :class:`Exception` like this:: + + # Careful, this will catch ANY exception raised. + with pytest.raises(Exception): + some_function() + + Because :class:`Exception` is the base class of almost all exceptions, it is easy for this to hide + real bugs, where the user wrote this expecting a specific exception, but some other exception is being + raised due to a bug introduced during a refactoring. + + Avoid using ``pytest.raises`` to catch :class:`Exception` unless certain that you really want to catch + **any** exception raised. + + .. note:: + + When using ``pytest.raises`` as a context manager, it's worthwhile to + note that normal context manager rules apply and that the exception + raised *must* be the final line in the scope of the context manager. + Lines of code after that, within the scope of the context manager will + not be executed. For example:: + + >>> value = 15 + >>> with pytest.raises(ValueError) as exc_info: + ... if value > 10: + ... raise ValueError("value must be <= 10") + ... assert exc_info.type is ValueError # This will not execute. + + Instead, the following approach must be taken (note the difference in + scope):: + + >>> with pytest.raises(ValueError) as exc_info: + ... if value > 10: + ... raise ValueError("value must be <= 10") + ... + >>> assert exc_info.type is ValueError + + **Expecting exception groups** + + When expecting exceptions wrapped in :exc:`BaseExceptionGroup` or + :exc:`ExceptionGroup`, you should instead use :class:`pytest.RaisesGroup`. + + **Using with** ``pytest.mark.parametrize`` + + When using :ref:`pytest.mark.parametrize ref` + it is possible to parametrize tests such that + some runs raise an exception and others do not. + + See :ref:`parametrizing_conditional_raising` for an example. + + .. seealso:: + + :ref:`assertraises` for more examples and detailed discussion. + + **Legacy form** + + It is possible to specify a callable by passing a to-be-called lambda:: + + >>> raises(ZeroDivisionError, lambda: 1/0) + + + or you can specify an arbitrary callable with arguments:: + + >>> def f(x): return 1/x + ... + >>> raises(ZeroDivisionError, f, 0) + + >>> raises(ZeroDivisionError, f, x=0) + + + The form above is fully supported but discouraged for new code because the + context manager form is regarded as more readable and less error-prone. + + .. note:: + Similar to caught exception objects in Python, explicitly clearing + local references to returned ``ExceptionInfo`` objects can + help the Python interpreter speed up its garbage collection. + + Clearing those references breaks a reference cycle + (``ExceptionInfo`` --> caught exception --> frame stack raising + the exception --> current frame stack --> local variables --> + ``ExceptionInfo``) which makes Python keep all objects referenced + from that cycle (including all local variables in the current + frame) alive until the next cyclic garbage collection run. + More detailed information can be found in the official Python + documentation for :ref:`the try statement `. + """ + __tracebackhide__ = True + + if not args: + if set(kwargs) - {"match", "check", "expected_exception"}: + msg = "Unexpected keyword arguments passed to pytest.raises: " + msg += ", ".join(sorted(kwargs)) + msg += "\nUse context-manager form instead?" + raise TypeError(msg) + + if expected_exception is None: + return RaisesExc(**kwargs) + return RaisesExc(expected_exception, **kwargs) + + if not expected_exception: + raise ValueError( + f"Expected an exception type or a tuple of exception types, but got `{expected_exception!r}`. " + f"Raising exceptions is already understood as failing the test, so you don't need " + f"any special code to say 'this should never raise an exception'." + ) + func = args[0] + if not callable(func): + raise TypeError(f"{func!r} object (type: {type(func)}) must be callable") + with RaisesExc(expected_exception) as excinfo: + func(*args[1:], **kwargs) + try: + return excinfo + finally: + del excinfo + + +# note: RaisesExc/RaisesGroup uses fail() internally, so this alias +# indicates (to [internal] plugins?) that `pytest.raises` will +# raise `_pytest.outcomes.Failed`, where +# `outcomes.Failed is outcomes.fail.Exception is raises.Exception` +# note: this is *not* the same as `_pytest.main.Failed` +# note: mypy does not recognize this attribute, and it's not possible +# to use a protocol/decorator like the others in outcomes due to +# https://github.com/python/mypy/issues/18715 +raises.Exception = fail.Exception # type: ignore[attr-defined] + + +def _match_pattern(match: Pattern[str]) -> str | Pattern[str]: + """Helper function to remove redundant `re.compile` calls when printing regex""" + return match.pattern if match.flags == _REGEX_NO_FLAGS else match + + +def repr_callable(fun: Callable[[BaseExcT_1], bool]) -> str: + """Get the repr of a ``check`` parameter. + + Split out so it can be monkeypatched (e.g. by hypothesis) + """ + return repr(fun) + + +def backquote(s: str) -> str: + return "`" + s + "`" + + +def _exception_type_name( + e: type[BaseException] | tuple[type[BaseException], ...], +) -> str: + if isinstance(e, type): + return e.__name__ + if len(e) == 1: + return e[0].__name__ + return "(" + ", ".join(ee.__name__ for ee in e) + ")" + + +def _check_raw_type( + expected_type: type[BaseException] | tuple[type[BaseException], ...] | None, + exception: BaseException, +) -> str | None: + if expected_type is None or expected_type == (): + return None + + if not isinstance( + exception, + expected_type, + ): + actual_type_str = backquote(_exception_type_name(type(exception)) + "()") + expected_type_str = backquote(_exception_type_name(expected_type)) + if ( + isinstance(exception, BaseExceptionGroup) + and isinstance(expected_type, type) + and not issubclass(expected_type, BaseExceptionGroup) + ): + return f"Unexpected nested {actual_type_str}, expected {expected_type_str}" + return f"{actual_type_str} is not an instance of {expected_type_str}" + return None + + +def is_fully_escaped(s: str) -> bool: + # we know we won't compile with re.VERBOSE, so whitespace doesn't need to be escaped + metacharacters = "{}()+.*?^$[]" + return not any( + c in metacharacters and (i == 0 or s[i - 1] != "\\") for (i, c) in enumerate(s) + ) + + +def unescape(s: str) -> str: + return re.sub(r"\\([{}()+-.*?^$\[\]\s\\])", r"\1", s) + + +# These classes conceptually differ from ExceptionInfo in that ExceptionInfo is tied, and +# constructed from, a particular exception - whereas these are constructed with expected +# exceptions, and later allow matching towards particular exceptions. +# But there's overlap in `ExceptionInfo.match` and `AbstractRaises._check_match`, as with +# `AbstractRaises.matches` and `ExceptionInfo.errisinstance`+`ExceptionInfo.group_contains`. +# The interaction between these classes should perhaps be improved. +class AbstractRaises(ABC, Generic[BaseExcT_co]): + """ABC with common functionality shared between RaisesExc and RaisesGroup""" + + def __init__( + self, + *, + match: str | Pattern[str] | None, + check: Callable[[BaseExcT_co], bool] | None, + ) -> None: + if isinstance(match, str): + # juggle error in order to avoid context to fail (necessary?) + re_error = None + try: + self.match: Pattern[str] | None = re.compile(match) + except re.error as e: + re_error = e + if re_error is not None: + fail(f"Invalid regex pattern provided to 'match': {re_error}") + if match == "": + warnings.warn( + PytestWarning( + "matching against an empty string will *always* pass. If you want " + "to check for an empty message you need to pass '^$'. If you don't " + "want to match you should pass `None` or leave out the parameter." + ), + stacklevel=2, + ) + else: + self.match = match + + # check if this is a fully escaped regex and has ^$ to match fully + # in which case we can do a proper diff on error + self.rawmatch: str | None = None + if isinstance(match, str) or ( + isinstance(match, Pattern) and match.flags == _REGEX_NO_FLAGS + ): + if isinstance(match, Pattern): + match = match.pattern + if ( + match + and match[0] == "^" + and match[-1] == "$" + and is_fully_escaped(match[1:-1]) + ): + self.rawmatch = unescape(match[1:-1]) + + self.check = check + self._fail_reason: str | None = None + + # used to suppress repeated printing of `repr(self.check)` + self._nested: bool = False + + # set in self._parse_exc + self.is_baseexception = False + + def _parse_exc( + self, exc: type[BaseExcT_1] | types.GenericAlias, expected: str + ) -> type[BaseExcT_1]: + if isinstance(exc, type) and issubclass(exc, BaseException): + if not issubclass(exc, Exception): + self.is_baseexception = True + return exc + # because RaisesGroup does not support variable number of exceptions there's + # still a use for RaisesExc(ExceptionGroup[Exception]). + origin_exc: type[BaseException] | None = get_origin(exc) + if origin_exc and issubclass(origin_exc, BaseExceptionGroup): + exc_type = get_args(exc)[0] + if ( + issubclass(origin_exc, ExceptionGroup) and exc_type in (Exception, Any) + ) or ( + issubclass(origin_exc, BaseExceptionGroup) + and exc_type in (BaseException, Any) + ): + if not isinstance(exc, Exception): + self.is_baseexception = True + return cast(type[BaseExcT_1], origin_exc) + else: + raise ValueError( + f"Only `ExceptionGroup[Exception]` or `BaseExceptionGroup[BaseException]` " + f"are accepted as generic types but got `{exc}`. " + f"As `raises` will catch all instances of the specified group regardless of the " + f"generic argument specific nested exceptions has to be checked " + f"with `RaisesGroup`." + ) + # unclear if the Type/ValueError distinction is even helpful here + msg = f"expected exception must be {expected}, not " + if isinstance(exc, type): + raise ValueError(msg + f"{exc.__name__!r}") + if isinstance(exc, BaseException): + raise TypeError(msg + f"an exception instance ({type(exc).__name__})") + raise TypeError(msg + repr(type(exc).__name__)) + + @property + def fail_reason(self) -> str | None: + """Set after a call to :meth:`matches` to give a human-readable reason for why the match failed. + When used as a context manager the string will be printed as the reason for the + test failing.""" + return self._fail_reason + + def _check_check( + self: AbstractRaises[BaseExcT_1], + exception: BaseExcT_1, + ) -> bool: + if self.check is None: + return True + + if self.check(exception): + return True + + check_repr = "" if self._nested else " " + repr_callable(self.check) + self._fail_reason = f"check{check_repr} did not return True" + return False + + # TODO: harmonize with ExceptionInfo.match + def _check_match(self, e: BaseException) -> bool: + if self.match is None or re.search( + self.match, + stringified_exception := stringify_exception( + e, include_subexception_msg=False + ), + ): + return True + + # if we're matching a group, make sure we're explicit to reduce confusion + # if they're trying to match an exception contained within the group + maybe_specify_type = ( + f" the `{_exception_type_name(type(e))}()`" + if isinstance(e, BaseExceptionGroup) + else "" + ) + if isinstance(self.rawmatch, str): + # TODO: it instructs to use `-v` to print leading text, but that doesn't work + # I also don't know if this is the proper entry point, or tool to use at all + from _pytest.assertion.util import _diff_text + from _pytest.assertion.util import dummy_highlighter + + diff = _diff_text(self.rawmatch, stringified_exception, dummy_highlighter) + self._fail_reason = ("\n" if diff[0][0] == "-" else "") + "\n".join(diff) + return False + + # I don't love "Regex"+"Input" vs something like "expected regex"+"exception message" + # when they're similar it's not always obvious which is which + self._fail_reason = ( + f"Regex pattern did not match{maybe_specify_type}.\n" + f" Regex: {_match_pattern(self.match)!r}\n" + f" Input: {stringified_exception!r}" + ) + if _match_pattern(self.match) == stringified_exception: + self._fail_reason += "\n Did you mean to `re.escape()` the regex?" + return False + + @abstractmethod + def matches( + self: AbstractRaises[BaseExcT_1], exception: BaseException + ) -> TypeGuard[BaseExcT_1]: + """Check if an exception matches the requirements of this AbstractRaises. + If it fails, :meth:`AbstractRaises.fail_reason` should be set. + """ + + +@final +class RaisesExc(AbstractRaises[BaseExcT_co_default]): + """ + .. versionadded:: 8.4 + + + This is the class constructed when calling :func:`pytest.raises`, but may be used + directly as a helper class with :class:`RaisesGroup` when you want to specify + requirements on sub-exceptions. + + You don't need this if you only want to specify the type, since :class:`RaisesGroup` + accepts ``type[BaseException]``. + + :param type[BaseException] | tuple[type[BaseException]] | None expected_exception: + The expected type, or one of several possible types. + May be ``None`` in order to only make use of ``match`` and/or ``check`` + + The type is checked with :func:`isinstance`, and does not need to be an exact match. + If that is wanted you can use the ``check`` parameter. + + :kwparam str | Pattern[str] match: + A regex to match. + + :kwparam Callable[[BaseException], bool] check: + If specified, a callable that will be called with the exception as a parameter + after checking the type and the match regex if specified. + If it returns ``True`` it will be considered a match, if not it will + be considered a failed match. + + :meth:`RaisesExc.matches` can also be used standalone to check individual exceptions. + + Examples:: + + with RaisesGroup(RaisesExc(ValueError, match="string")) + ... + with RaisesGroup(RaisesExc(check=lambda x: x.args == (3, "hello"))): + ... + with RaisesGroup(RaisesExc(check=lambda x: type(x) is ValueError)): + ... + """ + + # Trio bundled hypothesis monkeypatching, we will probably instead assume that + # hypothesis will handle that in their pytest plugin by the time this is released. + # Alternatively we could add a version of get_pretty_function_description ourselves + # https://github.com/HypothesisWorks/hypothesis/blob/8ced2f59f5c7bea3344e35d2d53e1f8f8eb9fcd8/hypothesis-python/src/hypothesis/internal/reflection.py#L439 + + # At least one of the three parameters must be passed. + @overload + def __init__( + self, + expected_exception: ( + type[BaseExcT_co_default] | tuple[type[BaseExcT_co_default], ...] + ), + /, + *, + match: str | Pattern[str] | None = ..., + check: Callable[[BaseExcT_co_default], bool] | None = ..., + ) -> None: ... + + @overload + def __init__( + self: RaisesExc[BaseException], # Give E a value. + /, + *, + match: str | Pattern[str] | None, + # If exception_type is not provided, check() must do any typechecks itself. + check: Callable[[BaseException], bool] | None = ..., + ) -> None: ... + + @overload + def __init__(self, /, *, check: Callable[[BaseException], bool]) -> None: ... + + def __init__( + self, + expected_exception: ( + type[BaseExcT_co_default] | tuple[type[BaseExcT_co_default], ...] | None + ) = None, + /, + *, + match: str | Pattern[str] | None = None, + check: Callable[[BaseExcT_co_default], bool] | None = None, + ): + super().__init__(match=match, check=check) + if isinstance(expected_exception, tuple): + expected_exceptions = expected_exception + elif expected_exception is None: + expected_exceptions = () + else: + expected_exceptions = (expected_exception,) + + if (expected_exceptions == ()) and match is None and check is None: + raise ValueError("You must specify at least one parameter to match on.") + + self.expected_exceptions = tuple( + self._parse_exc(e, expected="a BaseException type") + for e in expected_exceptions + ) + + self._just_propagate = False + + def matches( + self, + exception: BaseException | None, + ) -> TypeGuard[BaseExcT_co_default]: + """Check if an exception matches the requirements of this :class:`RaisesExc`. + If it fails, :attr:`RaisesExc.fail_reason` will be set. + + Examples:: + + assert RaisesExc(ValueError).matches(my_exception): + # is equivalent to + assert isinstance(my_exception, ValueError) + + # this can be useful when checking e.g. the ``__cause__`` of an exception. + with pytest.raises(ValueError) as excinfo: + ... + assert RaisesExc(SyntaxError, match="foo").matches(excinfo.value.__cause__) + # above line is equivalent to + assert isinstance(excinfo.value.__cause__, SyntaxError) + assert re.search("foo", str(excinfo.value.__cause__) + + """ + self._just_propagate = False + if exception is None: + self._fail_reason = "exception is None" + return False + if not self._check_type(exception): + self._just_propagate = True + return False + + if not self._check_match(exception): + return False + + return self._check_check(exception) + + def __repr__(self) -> str: + parameters = [] + if self.expected_exceptions: + parameters.append(_exception_type_name(self.expected_exceptions)) + if self.match is not None: + # If no flags were specified, discard the redundant re.compile() here. + parameters.append( + f"match={_match_pattern(self.match)!r}", + ) + if self.check is not None: + parameters.append(f"check={repr_callable(self.check)}") + return f"RaisesExc({', '.join(parameters)})" + + def _check_type(self, exception: BaseException) -> TypeGuard[BaseExcT_co_default]: + self._fail_reason = _check_raw_type(self.expected_exceptions, exception) + return self._fail_reason is None + + def __enter__(self) -> ExceptionInfo[BaseExcT_co_default]: + self.excinfo: ExceptionInfo[BaseExcT_co_default] = ExceptionInfo.for_later() + return self.excinfo + + # TODO: move common code into superclass + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: types.TracebackType | None, + ) -> bool: + __tracebackhide__ = True + if exc_type is None: + if not self.expected_exceptions: + fail("DID NOT RAISE any exception") + if len(self.expected_exceptions) > 1: + fail(f"DID NOT RAISE any of {self.expected_exceptions!r}") + + fail(f"DID NOT RAISE {self.expected_exceptions[0]!r}") + + assert self.excinfo is not None, ( + "Internal error - should have been constructed in __enter__" + ) + + if not self.matches(exc_val): + if self._just_propagate: + return False + raise AssertionError(self._fail_reason) + + # Cast to narrow the exception type now that it's verified.... + # even though the TypeGuard in self.matches should be narrowing + exc_info = cast( + "tuple[type[BaseExcT_co_default], BaseExcT_co_default, types.TracebackType]", + (exc_type, exc_val, exc_tb), + ) + self.excinfo.fill_unfilled(exc_info) + return True + + +@final +class RaisesGroup(AbstractRaises[BaseExceptionGroup[BaseExcT_co]]): + """ + .. versionadded:: 8.4 + + Contextmanager for checking for an expected :exc:`ExceptionGroup`. + This works similar to :func:`pytest.raises`, but allows for specifying the structure of an :exc:`ExceptionGroup`. + :meth:`ExceptionInfo.group_contains` also tries to handle exception groups, + but it is very bad at checking that you *didn't* get unexpected exceptions. + + The catching behaviour differs from :ref:`except* `, being much + stricter about the structure by default. + By using ``allow_unwrapped=True`` and ``flatten_subgroups=True`` you can match + :ref:`except* ` fully when expecting a single exception. + + :param args: + Any number of exception types, :class:`RaisesGroup` or :class:`RaisesExc` + to specify the exceptions contained in this exception. + All specified exceptions must be present in the raised group, *and no others*. + + If you expect a variable number of exceptions you need to use + :func:`pytest.raises(ExceptionGroup) ` and manually check + the contained exceptions. Consider making use of :meth:`RaisesExc.matches`. + + It does not care about the order of the exceptions, so + ``RaisesGroup(ValueError, TypeError)`` + is equivalent to + ``RaisesGroup(TypeError, ValueError)``. + :kwparam str | re.Pattern[str] | None match: + If specified, a string containing a regular expression, + or a regular expression object, that is tested against the string + representation of the exception group and its :pep:`678` `__notes__` + using :func:`re.search`. + + To match a literal string that may contain :ref:`special characters + `, the pattern can first be escaped with :func:`re.escape`. + + Note that " (5 subgroups)" will be stripped from the ``repr`` before matching. + :kwparam Callable[[E], bool] check: + If specified, a callable that will be called with the group as a parameter + after successfully matching the expected exceptions. If it returns ``True`` + it will be considered a match, if not it will be considered a failed match. + :kwparam bool allow_unwrapped: + If expecting a single exception or :class:`RaisesExc` it will match even + if the exception is not inside an exceptiongroup. + + Using this together with ``match``, ``check`` or expecting multiple exceptions + will raise an error. + :kwparam bool flatten_subgroups: + "flatten" any groups inside the raised exception group, extracting all exceptions + inside any nested groups, before matching. Without this it expects you to + fully specify the nesting structure by passing :class:`RaisesGroup` as expected + parameter. + + Examples:: + + with RaisesGroup(ValueError): + raise ExceptionGroup("", (ValueError(),)) + # match + with RaisesGroup( + ValueError, + ValueError, + RaisesExc(TypeError, match="^expected int$"), + match="^my group$", + ): + raise ExceptionGroup( + "my group", + [ + ValueError(), + TypeError("expected int"), + ValueError(), + ], + ) + # check + with RaisesGroup( + KeyboardInterrupt, + match="^hello$", + check=lambda x: isinstance(x.__cause__, ValueError), + ): + raise BaseExceptionGroup("hello", [KeyboardInterrupt()]) from ValueError + # nested groups + with RaisesGroup(RaisesGroup(ValueError)): + raise ExceptionGroup("", (ExceptionGroup("", (ValueError(),)),)) + + # flatten_subgroups + with RaisesGroup(ValueError, flatten_subgroups=True): + raise ExceptionGroup("", (ExceptionGroup("", (ValueError(),)),)) + + # allow_unwrapped + with RaisesGroup(ValueError, allow_unwrapped=True): + raise ValueError + + + :meth:`RaisesGroup.matches` can also be used directly to check a standalone exception group. + + + The matching algorithm is greedy, which means cases such as this may fail:: + + with RaisesGroup(ValueError, RaisesExc(ValueError, match="hello")): + raise ExceptionGroup("", (ValueError("hello"), ValueError("goodbye"))) + + even though it generally does not care about the order of the exceptions in the group. + To avoid the above you should specify the first :exc:`ValueError` with a :class:`RaisesExc` as well. + + .. note:: + When raised exceptions don't match the expected ones, you'll get a detailed error + message explaining why. This includes ``repr(check)`` if set, which in Python can be + overly verbose, showing memory locations etc etc. + + If installed and imported (in e.g. ``conftest.py``), the ``hypothesis`` library will + monkeypatch this output to provide shorter & more readable repr's. + """ + + # allow_unwrapped=True requires: singular exception, exception not being + # RaisesGroup instance, match is None, check is None + @overload + def __init__( + self, + expected_exception: type[BaseExcT_co] | RaisesExc[BaseExcT_co], + /, + *, + allow_unwrapped: Literal[True], + flatten_subgroups: bool = False, + ) -> None: ... + + # flatten_subgroups = True also requires no nested RaisesGroup + @overload + def __init__( + self, + expected_exception: type[BaseExcT_co] | RaisesExc[BaseExcT_co], + /, + *other_exceptions: type[BaseExcT_co] | RaisesExc[BaseExcT_co], + flatten_subgroups: Literal[True], + match: str | Pattern[str] | None = None, + check: Callable[[BaseExceptionGroup[BaseExcT_co]], bool] | None = None, + ) -> None: ... + + # simplify the typevars if possible (the following 3 are equivalent but go simpler->complicated) + # ... the first handles RaisesGroup[ValueError], the second RaisesGroup[ExceptionGroup[ValueError]], + # the third RaisesGroup[ValueError | ExceptionGroup[ValueError]]. + # ... otherwise, we will get results like RaisesGroup[ValueError | ExceptionGroup[Never]] (I think) + # (technically correct but misleading) + @overload + def __init__( + self: RaisesGroup[ExcT_1], + expected_exception: type[ExcT_1] | RaisesExc[ExcT_1], + /, + *other_exceptions: type[ExcT_1] | RaisesExc[ExcT_1], + match: str | Pattern[str] | None = None, + check: Callable[[ExceptionGroup[ExcT_1]], bool] | None = None, + ) -> None: ... + + @overload + def __init__( + self: RaisesGroup[ExceptionGroup[ExcT_2]], + expected_exception: RaisesGroup[ExcT_2], + /, + *other_exceptions: RaisesGroup[ExcT_2], + match: str | Pattern[str] | None = None, + check: Callable[[ExceptionGroup[ExceptionGroup[ExcT_2]]], bool] | None = None, + ) -> None: ... + + @overload + def __init__( + self: RaisesGroup[ExcT_1 | ExceptionGroup[ExcT_2]], + expected_exception: type[ExcT_1] | RaisesExc[ExcT_1] | RaisesGroup[ExcT_2], + /, + *other_exceptions: type[ExcT_1] | RaisesExc[ExcT_1] | RaisesGroup[ExcT_2], + match: str | Pattern[str] | None = None, + check: ( + Callable[[ExceptionGroup[ExcT_1 | ExceptionGroup[ExcT_2]]], bool] | None + ) = None, + ) -> None: ... + + # same as the above 3 but handling BaseException + @overload + def __init__( + self: RaisesGroup[BaseExcT_1], + expected_exception: type[BaseExcT_1] | RaisesExc[BaseExcT_1], + /, + *other_exceptions: type[BaseExcT_1] | RaisesExc[BaseExcT_1], + match: str | Pattern[str] | None = None, + check: Callable[[BaseExceptionGroup[BaseExcT_1]], bool] | None = None, + ) -> None: ... + + @overload + def __init__( + self: RaisesGroup[BaseExceptionGroup[BaseExcT_2]], + expected_exception: RaisesGroup[BaseExcT_2], + /, + *other_exceptions: RaisesGroup[BaseExcT_2], + match: str | Pattern[str] | None = None, + check: ( + Callable[[BaseExceptionGroup[BaseExceptionGroup[BaseExcT_2]]], bool] | None + ) = None, + ) -> None: ... + + @overload + def __init__( + self: RaisesGroup[BaseExcT_1 | BaseExceptionGroup[BaseExcT_2]], + expected_exception: type[BaseExcT_1] + | RaisesExc[BaseExcT_1] + | RaisesGroup[BaseExcT_2], + /, + *other_exceptions: type[BaseExcT_1] + | RaisesExc[BaseExcT_1] + | RaisesGroup[BaseExcT_2], + match: str | Pattern[str] | None = None, + check: ( + Callable[ + [BaseExceptionGroup[BaseExcT_1 | BaseExceptionGroup[BaseExcT_2]]], + bool, + ] + | None + ) = None, + ) -> None: ... + + def __init__( + self: RaisesGroup[ExcT_1 | BaseExcT_1 | BaseExceptionGroup[BaseExcT_2]], + expected_exception: type[BaseExcT_1] + | RaisesExc[BaseExcT_1] + | RaisesGroup[BaseExcT_2], + /, + *other_exceptions: type[BaseExcT_1] + | RaisesExc[BaseExcT_1] + | RaisesGroup[BaseExcT_2], + allow_unwrapped: bool = False, + flatten_subgroups: bool = False, + match: str | Pattern[str] | None = None, + check: ( + Callable[[BaseExceptionGroup[BaseExcT_1]], bool] + | Callable[[ExceptionGroup[ExcT_1]], bool] + | None + ) = None, + ): + # The type hint on the `self` and `check` parameters uses different formats + # that are *very* hard to reconcile while adhering to the overloads, so we cast + # it to avoid an error when passing it to super().__init__ + check = cast( + "Callable[[BaseExceptionGroup[ExcT_1|BaseExcT_1|BaseExceptionGroup[BaseExcT_2]]], bool]", + check, + ) + super().__init__(match=match, check=check) + self.allow_unwrapped = allow_unwrapped + self.flatten_subgroups: bool = flatten_subgroups + self.is_baseexception = False + + if allow_unwrapped and other_exceptions: + raise ValueError( + "You cannot specify multiple exceptions with `allow_unwrapped=True.`" + " If you want to match one of multiple possible exceptions you should" + " use a `RaisesExc`." + " E.g. `RaisesExc(check=lambda e: isinstance(e, (...)))`", + ) + if allow_unwrapped and isinstance(expected_exception, RaisesGroup): + raise ValueError( + "`allow_unwrapped=True` has no effect when expecting a `RaisesGroup`." + " You might want it in the expected `RaisesGroup`, or" + " `flatten_subgroups=True` if you don't care about the structure.", + ) + if allow_unwrapped and (match is not None or check is not None): + raise ValueError( + "`allow_unwrapped=True` bypasses the `match` and `check` parameters" + " if the exception is unwrapped. If you intended to match/check the" + " exception you should use a `RaisesExc` object. If you want to match/check" + " the exceptiongroup when the exception *is* wrapped you need to" + " do e.g. `if isinstance(exc.value, ExceptionGroup):" + " assert RaisesGroup(...).matches(exc.value)` afterwards.", + ) + + self.expected_exceptions: tuple[ + type[BaseExcT_co] | RaisesExc[BaseExcT_co] | RaisesGroup[BaseException], ... + ] = tuple( + self._parse_excgroup(e, "a BaseException type, RaisesExc, or RaisesGroup") + for e in ( + expected_exception, + *other_exceptions, + ) + ) + + def _parse_excgroup( + self, + exc: ( + type[BaseExcT_co] + | types.GenericAlias + | RaisesExc[BaseExcT_1] + | RaisesGroup[BaseExcT_2] + ), + expected: str, + ) -> type[BaseExcT_co] | RaisesExc[BaseExcT_1] | RaisesGroup[BaseExcT_2]: + # verify exception type and set `self.is_baseexception` + if isinstance(exc, RaisesGroup): + if self.flatten_subgroups: + raise ValueError( + "You cannot specify a nested structure inside a RaisesGroup with" + " `flatten_subgroups=True`. The parameter will flatten subgroups" + " in the raised exceptiongroup before matching, which would never" + " match a nested structure.", + ) + self.is_baseexception |= exc.is_baseexception + exc._nested = True + return exc + elif isinstance(exc, RaisesExc): + self.is_baseexception |= exc.is_baseexception + exc._nested = True + return exc + elif isinstance(exc, tuple): + raise TypeError( + f"expected exception must be {expected}, not {type(exc).__name__!r}.\n" + "RaisesGroup does not support tuples of exception types when expecting one of " + "several possible exception types like RaisesExc.\n" + "If you meant to expect a group with multiple exceptions, list them as separate arguments." + ) + else: + return super()._parse_exc(exc, expected) + + @overload + def __enter__( + self: RaisesGroup[ExcT_1], + ) -> ExceptionInfo[ExceptionGroup[ExcT_1]]: ... + @overload + def __enter__( + self: RaisesGroup[BaseExcT_1], + ) -> ExceptionInfo[BaseExceptionGroup[BaseExcT_1]]: ... + + def __enter__(self) -> ExceptionInfo[BaseExceptionGroup[BaseException]]: + self.excinfo: ExceptionInfo[BaseExceptionGroup[BaseExcT_co]] = ( + ExceptionInfo.for_later() + ) + return self.excinfo + + def __repr__(self) -> str: + reqs = [ + e.__name__ if isinstance(e, type) else repr(e) + for e in self.expected_exceptions + ] + if self.allow_unwrapped: + reqs.append(f"allow_unwrapped={self.allow_unwrapped}") + if self.flatten_subgroups: + reqs.append(f"flatten_subgroups={self.flatten_subgroups}") + if self.match is not None: + # If no flags were specified, discard the redundant re.compile() here. + reqs.append(f"match={_match_pattern(self.match)!r}") + if self.check is not None: + reqs.append(f"check={repr_callable(self.check)}") + return f"RaisesGroup({', '.join(reqs)})" + + def _unroll_exceptions( + self, + exceptions: Sequence[BaseException], + ) -> Sequence[BaseException]: + """Used if `flatten_subgroups=True`.""" + res: list[BaseException] = [] + for exc in exceptions: + if isinstance(exc, BaseExceptionGroup): + res.extend(self._unroll_exceptions(exc.exceptions)) + + else: + res.append(exc) + return res + + @overload + def matches( + self: RaisesGroup[ExcT_1], + exception: BaseException | None, + ) -> TypeGuard[ExceptionGroup[ExcT_1]]: ... + @overload + def matches( + self: RaisesGroup[BaseExcT_1], + exception: BaseException | None, + ) -> TypeGuard[BaseExceptionGroup[BaseExcT_1]]: ... + + def matches( + self, + exception: BaseException | None, + ) -> bool: + """Check if an exception matches the requirements of this RaisesGroup. + If it fails, `RaisesGroup.fail_reason` will be set. + + Example:: + + with pytest.raises(TypeError) as excinfo: + ... + assert RaisesGroup(ValueError).matches(excinfo.value.__cause__) + # the above line is equivalent to + myexc = excinfo.value.__cause + assert isinstance(myexc, BaseExceptionGroup) + assert len(myexc.exceptions) == 1 + assert isinstance(myexc.exceptions[0], ValueError) + """ + self._fail_reason = None + if exception is None: + self._fail_reason = "exception is None" + return False + if not isinstance(exception, BaseExceptionGroup): + # we opt to only print type of the exception here, as the repr would + # likely be quite long + not_group_msg = f"`{type(exception).__name__}()` is not an exception group" + if len(self.expected_exceptions) > 1: + self._fail_reason = not_group_msg + return False + # if we have 1 expected exception, check if it would work even if + # allow_unwrapped is not set + res = self._check_expected(self.expected_exceptions[0], exception) + if res is None and self.allow_unwrapped: + return True + + if res is None: + self._fail_reason = ( + f"{not_group_msg}, but would match with `allow_unwrapped=True`" + ) + elif self.allow_unwrapped: + self._fail_reason = res + else: + self._fail_reason = not_group_msg + return False + + actual_exceptions: Sequence[BaseException] = exception.exceptions + if self.flatten_subgroups: + actual_exceptions = self._unroll_exceptions(actual_exceptions) + + if not self._check_match(exception): + self._fail_reason = cast(str, self._fail_reason) + old_reason = self._fail_reason + if ( + len(actual_exceptions) == len(self.expected_exceptions) == 1 + and isinstance(expected := self.expected_exceptions[0], type) + and isinstance(actual := actual_exceptions[0], expected) + and self._check_match(actual) + ): + assert self.match is not None, "can't be None if _check_match failed" + assert self._fail_reason is old_reason is not None + self._fail_reason += ( + f"\n" + f" but matched the expected `{self._repr_expected(expected)}`.\n" + f" You might want " + f"`RaisesGroup(RaisesExc({expected.__name__}, match={_match_pattern(self.match)!r}))`" + ) + else: + self._fail_reason = old_reason + return False + + # do the full check on expected exceptions + if not self._check_exceptions( + exception, + actual_exceptions, + ): + self._fail_reason = cast(str, self._fail_reason) + assert self._fail_reason is not None + old_reason = self._fail_reason + # if we're not expecting a nested structure, and there is one, do a second + # pass where we try flattening it + if ( + not self.flatten_subgroups + and not any( + isinstance(e, RaisesGroup) for e in self.expected_exceptions + ) + and any(isinstance(e, BaseExceptionGroup) for e in actual_exceptions) + and self._check_exceptions( + exception, + self._unroll_exceptions(exception.exceptions), + ) + ): + # only indent if it's a single-line reason. In a multi-line there's already + # indented lines that this does not belong to. + indent = " " if "\n" not in self._fail_reason else "" + self._fail_reason = ( + old_reason + + f"\n{indent}Did you mean to use `flatten_subgroups=True`?" + ) + else: + self._fail_reason = old_reason + return False + + # Only run `self.check` once we know `exception` is of the correct type. + if not self._check_check(exception): + reason = ( + cast(str, self._fail_reason) + f" on the {type(exception).__name__}" + ) + if ( + len(actual_exceptions) == len(self.expected_exceptions) == 1 + and isinstance(expected := self.expected_exceptions[0], type) + # we explicitly break typing here :) + and self._check_check(actual_exceptions[0]) # type: ignore[arg-type] + ): + self._fail_reason = reason + ( + f", but did return True for the expected {self._repr_expected(expected)}." + f" You might want RaisesGroup(RaisesExc({expected.__name__}, check=<...>))" + ) + else: + self._fail_reason = reason + return False + + return True + + @staticmethod + def _check_expected( + expected_type: ( + type[BaseException] | RaisesExc[BaseException] | RaisesGroup[BaseException] + ), + exception: BaseException, + ) -> str | None: + """Helper method for `RaisesGroup.matches` and `RaisesGroup._check_exceptions` + to check one of potentially several expected exceptions.""" + if isinstance(expected_type, type): + return _check_raw_type(expected_type, exception) + res = expected_type.matches(exception) + if res: + return None + assert expected_type.fail_reason is not None + if expected_type.fail_reason.startswith("\n"): + return f"\n{expected_type!r}: {indent(expected_type.fail_reason, ' ')}" + return f"{expected_type!r}: {expected_type.fail_reason}" + + @staticmethod + def _repr_expected(e: type[BaseException] | AbstractRaises[BaseException]) -> str: + """Get the repr of an expected type/RaisesExc/RaisesGroup, but we only want + the name if it's a type""" + if isinstance(e, type): + return _exception_type_name(e) + return repr(e) + + @overload + def _check_exceptions( + self: RaisesGroup[ExcT_1], + _exception: Exception, + actual_exceptions: Sequence[Exception], + ) -> TypeGuard[ExceptionGroup[ExcT_1]]: ... + @overload + def _check_exceptions( + self: RaisesGroup[BaseExcT_1], + _exception: BaseException, + actual_exceptions: Sequence[BaseException], + ) -> TypeGuard[BaseExceptionGroup[BaseExcT_1]]: ... + + def _check_exceptions( + self, + _exception: BaseException, + actual_exceptions: Sequence[BaseException], + ) -> bool: + """Helper method for RaisesGroup.matches that attempts to pair up expected and actual exceptions""" + # The _exception parameter is not used, but necessary for the TypeGuard + + # full table with all results + results = ResultHolder(self.expected_exceptions, actual_exceptions) + + # (indexes of) raised exceptions that haven't (yet) found an expected + remaining_actual = list(range(len(actual_exceptions))) + # (indexes of) expected exceptions that haven't found a matching raised + failed_expected: list[int] = [] + # successful greedy matches + matches: dict[int, int] = {} + + # loop over expected exceptions first to get a more predictable result + for i_exp, expected in enumerate(self.expected_exceptions): + for i_rem in remaining_actual: + res = self._check_expected(expected, actual_exceptions[i_rem]) + results.set_result(i_exp, i_rem, res) + if res is None: + remaining_actual.remove(i_rem) + matches[i_exp] = i_rem + break + else: + failed_expected.append(i_exp) + + # All exceptions matched up successfully + if not remaining_actual and not failed_expected: + return True + + # in case of a single expected and single raised we simplify the output + if 1 == len(actual_exceptions) == len(self.expected_exceptions): + assert not matches + self._fail_reason = res + return False + + # The test case is failing, so we can do a slow and exhaustive check to find + # duplicate matches etc that will be helpful in debugging + for i_exp, expected in enumerate(self.expected_exceptions): + for i_actual, actual in enumerate(actual_exceptions): + if results.has_result(i_exp, i_actual): + continue + results.set_result( + i_exp, i_actual, self._check_expected(expected, actual) + ) + + successful_str = ( + f"{len(matches)} matched exception{'s' if len(matches) > 1 else ''}. " + if matches + else "" + ) + + # all expected were found + if not failed_expected and results.no_match_for_actual(remaining_actual): + self._fail_reason = ( + f"{successful_str}Unexpected exception(s):" + f" {[actual_exceptions[i] for i in remaining_actual]!r}" + ) + return False + # all raised exceptions were expected + if not remaining_actual and results.no_match_for_expected(failed_expected): + no_match_for_str = ", ".join( + self._repr_expected(self.expected_exceptions[i]) + for i in failed_expected + ) + self._fail_reason = f"{successful_str}Too few exceptions raised, found no match for: [{no_match_for_str}]" + return False + + # if there's only one remaining and one failed, and the unmatched didn't match anything else, + # we elect to only print why the remaining and the failed didn't match. + if ( + 1 == len(remaining_actual) == len(failed_expected) + and results.no_match_for_actual(remaining_actual) + and results.no_match_for_expected(failed_expected) + ): + self._fail_reason = f"{successful_str}{results.get_result(failed_expected[0], remaining_actual[0])}" + return False + + # there's both expected and raised exceptions without matches + s = "" + if matches: + s += f"\n{successful_str}" + indent_1 = " " * 2 + indent_2 = " " * 4 + + if not remaining_actual: + s += "\nToo few exceptions raised!" + elif not failed_expected: + s += "\nUnexpected exception(s)!" + + if failed_expected: + s += "\nThe following expected exceptions did not find a match:" + rev_matches = {v: k for k, v in matches.items()} + for i_failed in failed_expected: + s += ( + f"\n{indent_1}{self._repr_expected(self.expected_exceptions[i_failed])}" + ) + for i_actual, actual in enumerate(actual_exceptions): + if results.get_result(i_exp, i_actual) is None: + # we print full repr of match target + s += ( + f"\n{indent_2}It matches {backquote(repr(actual))} which was paired with " + + backquote( + self._repr_expected( + self.expected_exceptions[rev_matches[i_actual]] + ) + ) + ) + + if remaining_actual: + s += "\nThe following raised exceptions did not find a match" + for i_actual in remaining_actual: + s += f"\n{indent_1}{actual_exceptions[i_actual]!r}:" + for i_exp, expected in enumerate(self.expected_exceptions): + res = results.get_result(i_exp, i_actual) + if i_exp in failed_expected: + assert res is not None + if res[0] != "\n": + s += "\n" + s += indent(res, indent_2) + if res is None: + # we print full repr of match target + s += ( + f"\n{indent_2}It matches {backquote(self._repr_expected(expected))} " + f"which was paired with {backquote(repr(actual_exceptions[matches[i_exp]]))}" + ) + + if len(self.expected_exceptions) == len(actual_exceptions) and possible_match( + results + ): + s += ( + "\nThere exist a possible match when attempting an exhaustive check," + " but RaisesGroup uses a greedy algorithm. " + "Please make your expected exceptions more stringent with `RaisesExc` etc" + " so the greedy algorithm can function." + ) + self._fail_reason = s + return False + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: types.TracebackType | None, + ) -> bool: + __tracebackhide__ = True + if exc_type is None: + fail(f"DID NOT RAISE any exception, expected `{self.expected_type()}`") + + assert self.excinfo is not None, ( + "Internal error - should have been constructed in __enter__" + ) + + # group_str is the only thing that differs between RaisesExc and RaisesGroup... + # I might just scrap it? Or make it part of fail_reason + group_str = ( + "(group)" + if self.allow_unwrapped and not issubclass(exc_type, BaseExceptionGroup) + else "group" + ) + + if not self.matches(exc_val): + fail(f"Raised exception {group_str} did not match: {self._fail_reason}") + + # Cast to narrow the exception type now that it's verified.... + # even though the TypeGuard in self.matches should be narrowing + exc_info = cast( + "tuple[type[BaseExceptionGroup[BaseExcT_co]], BaseExceptionGroup[BaseExcT_co], types.TracebackType]", + (exc_type, exc_val, exc_tb), + ) + self.excinfo.fill_unfilled(exc_info) + return True + + def expected_type(self) -> str: + subexcs = [] + for e in self.expected_exceptions: + if isinstance(e, RaisesExc): + subexcs.append(repr(e)) + elif isinstance(e, RaisesGroup): + subexcs.append(e.expected_type()) + elif isinstance(e, type): + subexcs.append(e.__name__) + else: # pragma: no cover + raise AssertionError("unknown type") + group_type = "Base" if self.is_baseexception else "" + return f"{group_type}ExceptionGroup({', '.join(subexcs)})" + + +@final +class NotChecked: + """Singleton for unchecked values in ResultHolder""" + + +class ResultHolder: + """Container for results of checking exceptions. + Used in RaisesGroup._check_exceptions and possible_match. + """ + + def __init__( + self, + expected_exceptions: tuple[ + type[BaseException] | AbstractRaises[BaseException], ... + ], + actual_exceptions: Sequence[BaseException], + ) -> None: + self.results: list[list[str | type[NotChecked] | None]] = [ + [NotChecked for _ in expected_exceptions] for _ in actual_exceptions + ] + + def set_result(self, expected: int, actual: int, result: str | None) -> None: + self.results[actual][expected] = result + + def get_result(self, expected: int, actual: int) -> str | None: + res = self.results[actual][expected] + assert res is not NotChecked + # mypy doesn't support identity checking against anything but None + return res # type: ignore[return-value] + + def has_result(self, expected: int, actual: int) -> bool: + return self.results[actual][expected] is not NotChecked + + def no_match_for_expected(self, expected: list[int]) -> bool: + for i in expected: + for actual_results in self.results: + assert actual_results[i] is not NotChecked + if actual_results[i] is None: + return False + return True + + def no_match_for_actual(self, actual: list[int]) -> bool: + for i in actual: + for res in self.results[i]: + assert res is not NotChecked + if res is None: + return False + return True + + +def possible_match(results: ResultHolder, used: set[int] | None = None) -> bool: + if used is None: + used = set() + curr_row = len(used) + if curr_row == len(results.results): + return True + return any( + val is None and i not in used and possible_match(results, used | {i}) + for (i, val) in enumerate(results.results[curr_row]) + ) diff --git a/src/_pytest/recwarn.py b/src/_pytest/recwarn.py index d76ea020f19..440e3efac8a 100644 --- a/src/_pytest/recwarn.py +++ b/src/_pytest/recwarn.py @@ -1,25 +1,29 @@ +# mypy: allow-untyped-defs """Record warnings during test function execution.""" -import re -import warnings + +from __future__ import annotations + +from collections.abc import Callable +from collections.abc import Generator +from collections.abc import Iterator from pprint import pformat +import re from types import TracebackType from typing import Any -from typing import Callable -from typing import Generator -from typing import Iterator -from typing import List -from typing import Optional -from typing import Pattern -from typing import Tuple -from typing import Type +from typing import final +from typing import overload +from typing import TYPE_CHECKING from typing import TypeVar -from typing import Union -from _pytest.compat import final -from _pytest.compat import overload + +if TYPE_CHECKING: + from typing_extensions import Self + +import warnings + from _pytest.deprecated import check_ispytest -from _pytest.deprecated import WARNS_NONE_ARG from _pytest.fixtures import fixture +from _pytest.outcomes import Exit from _pytest.outcomes import fail @@ -27,11 +31,10 @@ @fixture -def recwarn() -> Generator["WarningsRecorder", None, None]: +def recwarn() -> Generator[WarningsRecorder]: """Return a :class:`WarningsRecorder` instance that records all warnings emitted by test functions. - See https://docs.pytest.org/en/latest/how-to/capture-warnings.html for information - on warning categories. + See :ref:`warnings` for information on warning categories. """ wrec = WarningsRecorder(_ispytest=True) with wrec: @@ -41,22 +44,18 @@ def recwarn() -> Generator["WarningsRecorder", None, None]: @overload def deprecated_call( - *, match: Optional[Union[str, Pattern[str]]] = ... -) -> "WarningsRecorder": - ... + *, match: str | re.Pattern[str] | None = ... +) -> WarningsRecorder: ... @overload -def deprecated_call( # noqa: F811 - func: Callable[..., T], *args: Any, **kwargs: Any -) -> T: - ... +def deprecated_call(func: Callable[..., T], *args: Any, **kwargs: Any) -> T: ... -def deprecated_call( # noqa: F811 - func: Optional[Callable[..., Any]] = None, *args: Any, **kwargs: Any -) -> Union["WarningsRecorder", Any]: - """Assert that code produces a ``DeprecationWarning`` or ``PendingDeprecationWarning``. +def deprecated_call( + func: Callable[..., Any] | None = None, *args: Any, **kwargs: Any +) -> WarningsRecorder | Any: + """Assert that code produces a ``DeprecationWarning`` or ``PendingDeprecationWarning`` or ``FutureWarning``. This function can be used as a context manager:: @@ -81,46 +80,46 @@ def deprecated_call( # noqa: F811 """ __tracebackhide__ = True if func is not None: - args = (func,) + args - return warns((DeprecationWarning, PendingDeprecationWarning), *args, **kwargs) + args = (func, *args) + return warns( + (DeprecationWarning, PendingDeprecationWarning, FutureWarning), *args, **kwargs + ) @overload def warns( - expected_warning: Union[Type[Warning], Tuple[Type[Warning], ...]] = ..., + expected_warning: type[Warning] | tuple[type[Warning], ...] = ..., *, - match: Optional[Union[str, Pattern[str]]] = ..., -) -> "WarningsChecker": - ... + match: str | re.Pattern[str] | None = ..., +) -> WarningsChecker: ... @overload -def warns( # noqa: F811 - expected_warning: Union[Type[Warning], Tuple[Type[Warning], ...]], +def warns( + expected_warning: type[Warning] | tuple[type[Warning], ...], func: Callable[..., T], *args: Any, **kwargs: Any, -) -> T: - ... +) -> T: ... -def warns( # noqa: F811 - expected_warning: Union[Type[Warning], Tuple[Type[Warning], ...]] = Warning, +def warns( + expected_warning: type[Warning] | tuple[type[Warning], ...] = Warning, *args: Any, - match: Optional[Union[str, Pattern[str]]] = None, + match: str | re.Pattern[str] | None = None, **kwargs: Any, -) -> Union["WarningsChecker", Any]: +) -> WarningsChecker | Any: r"""Assert that code raises a particular class of warning. - Specifically, the parameter ``expected_warning`` can be a warning class or sequence + Specifically, the parameter ``expected_warning`` can be a warning class or tuple of warning classes, and the code inside the ``with`` block must issue at least one warning of that class or classes. This helper produces a list of :class:`warnings.WarningMessage` objects, one for - each warning raised (regardless of whether it is an ``expected_warning`` or not). + each warning emitted (regardless of whether it is an ``expected_warning`` or not). + Since pytest 8.0, unmatched warnings are also re-emitted when the context closes. - This function can be used as a context manager, which will capture all the raised - warnings inside it:: + This function can be used as a context manager:: >>> import pytest >>> with pytest.warns(RuntimeWarning): @@ -135,8 +134,9 @@ def warns( # noqa: F811 >>> with pytest.warns(UserWarning, match=r'must be \d+$'): ... warnings.warn("value must be 42", UserWarning) - >>> with pytest.warns(UserWarning, match=r'must be \d+$'): - ... warnings.warn("this is not here", UserWarning) + >>> with pytest.warns(UserWarning): # catch re-emitted warning + ... with pytest.warns(UserWarning, match=r'must be \d+$'): + ... warnings.warn("this is not here", UserWarning) Traceback (most recent call last): ... Failed: DID NOT WARN. No warnings of type ...UserWarning... were emitted... @@ -182,21 +182,20 @@ class WarningsRecorder(warnings.catch_warnings): # type:ignore[type-arg] def __init__(self, *, _ispytest: bool = False) -> None: check_ispytest(_ispytest) - # Type ignored due to the way typeshed handles warnings.catch_warnings. - super().__init__(record=True) # type: ignore[call-arg] + super().__init__(record=True) self._entered = False - self._list: List[warnings.WarningMessage] = [] + self._list: list[warnings.WarningMessage] = [] @property - def list(self) -> List["warnings.WarningMessage"]: + def list(self) -> list[warnings.WarningMessage]: """The list of recorded warnings.""" return self._list - def __getitem__(self, i: int) -> "warnings.WarningMessage": + def __getitem__(self, i: int) -> warnings.WarningMessage: """Get a recorded warning by index.""" return self._list[i] - def __iter__(self) -> Iterator["warnings.WarningMessage"]: + def __iter__(self) -> Iterator[warnings.WarningMessage]: """Iterate through the recorded warnings.""" return iter(self._list) @@ -204,11 +203,22 @@ def __len__(self) -> int: """The number of recorded warnings.""" return len(self._list) - def pop(self, cls: Type[Warning] = Warning) -> "warnings.WarningMessage": - """Pop the first recorded warning, raise exception if not exists.""" + def pop(self, cls: type[Warning] = Warning) -> warnings.WarningMessage: + """Pop the first recorded warning which is an instance of ``cls``, + but not an instance of a child class of any other match. + Raises ``AssertionError`` if there is no match. + """ + best_idx: int | None = None for i, w in enumerate(self._list): - if issubclass(w.category, cls): - return self._list.pop(i) + if w.category == cls: + return self._list.pop(i) # exact match, stop looking + if issubclass(w.category, cls) and ( + best_idx is None + or not issubclass(w.category, self._list[best_idx].category) + ): + best_idx = i + if best_idx is not None: + return self._list.pop(best_idx) __tracebackhide__ = True raise AssertionError(f"{cls!r} not found in warning list") @@ -216,9 +226,7 @@ def clear(self) -> None: """Clear the list of recorded warnings.""" self._list[:] = [] - # Type ignored because it doesn't exactly warnings.catch_warnings.__enter__ - # -- it returns a List but we only emulate one. - def __enter__(self) -> "WarningsRecorder": # type: ignore + def __enter__(self) -> Self: if self._entered: __tracebackhide__ = True raise RuntimeError(f"Cannot enter {self!r} twice") @@ -231,9 +239,9 @@ def __enter__(self) -> "WarningsRecorder": # type: ignore def __exit__( self, - exc_type: Optional[Type[BaseException]], - exc_val: Optional[BaseException], - exc_tb: Optional[TracebackType], + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, ) -> None: if not self._entered: __tracebackhide__ = True @@ -250,10 +258,8 @@ def __exit__( class WarningsChecker(WarningsRecorder): def __init__( self, - expected_warning: Optional[ - Union[Type[Warning], Tuple[Type[Warning], ...]] - ] = Warning, - match_expr: Optional[Union[str, Pattern[str]]] = None, + expected_warning: type[Warning] | tuple[type[Warning], ...] = Warning, + match_expr: str | re.Pattern[str] | None = None, *, _ispytest: bool = False, ) -> None: @@ -261,15 +267,14 @@ def __init__( super().__init__(_ispytest=True) msg = "exceptions must be derived from Warning, not %s" - if expected_warning is None: - warnings.warn(WARNS_NONE_ARG, stacklevel=4) - expected_warning_tup = None - elif isinstance(expected_warning, tuple): + if isinstance(expected_warning, tuple): for exc in expected_warning: if not issubclass(exc, Warning): raise TypeError(msg % type(exc)) expected_warning_tup = expected_warning - elif issubclass(expected_warning, Warning): + elif isinstance(expected_warning, type) and issubclass( + expected_warning, Warning + ): expected_warning_tup = (expected_warning,) else: raise TypeError(msg % type(expected_warning)) @@ -277,37 +282,84 @@ def __init__( self.expected_warning = expected_warning_tup self.match_expr = match_expr + def matches(self, warning: warnings.WarningMessage) -> bool: + assert self.expected_warning is not None + return issubclass(warning.category, self.expected_warning) and bool( + self.match_expr is None or re.search(self.match_expr, str(warning.message)) + ) + def __exit__( self, - exc_type: Optional[Type[BaseException]], - exc_val: Optional[BaseException], - exc_tb: Optional[TracebackType], + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, ) -> None: super().__exit__(exc_type, exc_val, exc_tb) __tracebackhide__ = True - def found_str(): + # BaseExceptions like pytest.{skip,fail,xfail,exit} or Ctrl-C within + # pytest.warns should *not* trigger "DID NOT WARN" and get suppressed + # when the warning doesn't happen. Control-flow exceptions should always + # propagate. + if exc_val is not None and ( + not isinstance(exc_val, Exception) + # Exit is an Exception, not a BaseException, for some reason. + or isinstance(exc_val, Exit) + ): + return + + def found_str() -> str: return pformat([record.message for record in self], indent=2) - # only check if we're not currently handling an exception - if exc_type is None and exc_val is None and exc_tb is None: - if self.expected_warning is not None: - if not any(issubclass(r.category, self.expected_warning) for r in self): - __tracebackhide__ = True - fail( - f"DID NOT WARN. No warnings of type {self.expected_warning} were emitted.\n" - f"The list of emitted warnings is: {found_str()}." + try: + if not any(issubclass(w.category, self.expected_warning) for w in self): + fail( + f"DID NOT WARN. No warnings of type {self.expected_warning} were emitted.\n" + f" Emitted warnings: {found_str()}." + ) + elif not any(self.matches(w) for w in self): + fail( + f"DID NOT WARN. No warnings of type {self.expected_warning} matching the regex were emitted.\n" + f" Regex: {self.match_expr}\n" + f" Emitted warnings: {found_str()}." + ) + finally: + # Whether or not any warnings matched, we want to re-emit all unmatched warnings. + for w in self: + if not self.matches(w): + warnings.warn_explicit( + message=w.message, + category=w.category, + filename=w.filename, + lineno=w.lineno, + module=w.__module__, + source=w.source, ) - elif self.match_expr is not None: - for r in self: - if issubclass(r.category, self.expected_warning): - if re.compile(self.match_expr).search(str(r.message)): - break - else: - fail( - f"""\ -DID NOT WARN. No warnings of type {self.expected_warning} matching the regex were emitted. - Regex: {self.match_expr} - Emitted warnings: {found_str()}""" - ) + + # Currently in Python it is possible to pass other types than an + # `str` message when creating `Warning` instances, however this + # causes an exception when :func:`warnings.filterwarnings` is used + # to filter those warnings. See + # https://github.com/python/cpython/issues/103577 for a discussion. + # While this can be considered a bug in CPython, we put guards in + # pytest as the error message produced without this check in place + # is confusing (#10865). + for w in self: + if type(w.message) is not UserWarning: + # If the warning was of an incorrect type then `warnings.warn()` + # creates a UserWarning. Any other warning must have been specified + # explicitly. + continue + if not w.message.args: + # UserWarning() without arguments must have been specified explicitly. + continue + msg = w.message.args[0] + if isinstance(msg, str): + continue + # It's possible that UserWarning was explicitly specified, and + # its first argument was not a string. But that case can't be + # distinguished from an invalid type. + raise TypeError( + f"Warning must be str or Warning, got {msg!r} (type {type(msg).__name__})" + ) diff --git a/src/_pytest/reports.py b/src/_pytest/reports.py index 74e8794b232..480ffae1f9c 100644 --- a/src/_pytest/reports.py +++ b/src/_pytest/reports.py @@ -1,21 +1,20 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + +from collections.abc import Iterable +from collections.abc import Iterator +from collections.abc import Mapping +from collections.abc import Sequence import dataclasses -import os from io import StringIO +import os from pprint import pprint from typing import Any from typing import cast -from typing import Dict -from typing import Iterable -from typing import Iterator -from typing import List -from typing import Mapping +from typing import final +from typing import Literal from typing import NoReturn -from typing import Optional -from typing import Tuple -from typing import Type from typing import TYPE_CHECKING -from typing import TypeVar -from typing import Union from _pytest._code.code import ExceptionChainRepr from _pytest._code.code import ExceptionInfo @@ -29,14 +28,15 @@ from _pytest._code.code import ReprTraceback from _pytest._code.code import TerminalRepr from _pytest._io import TerminalWriter -from _pytest.compat import final from _pytest.config import Config from _pytest.nodes import Collector from _pytest.nodes import Item +from _pytest.outcomes import fail from _pytest.outcomes import skip + if TYPE_CHECKING: - from typing_extensions import Literal + from typing_extensions import Self from _pytest.runner import CallInfo @@ -46,33 +46,29 @@ def getworkerinfoline(node): return node._workerinfocache except AttributeError: d = node.workerinfo - ver = "%s.%s.%s" % d["version_info"][:3] + ver = "{}.{}.{}".format(*d["version_info"][:3]) node._workerinfocache = s = "[{}] {} -- Python {} {}".format( d["id"], d["sysplatform"], ver, d["executable"] ) return s -_R = TypeVar("_R", bound="BaseReport") - - class BaseReport: - when: Optional[str] - location: Optional[Tuple[str, Optional[int], str]] - longrepr: Union[ - None, ExceptionInfo[BaseException], Tuple[str, int, str], str, TerminalRepr - ] - sections: List[Tuple[str, str]] + when: str | None + location: tuple[str, int | None, str] | None + longrepr: ( + None | ExceptionInfo[BaseException] | tuple[str, int, str] | str | TerminalRepr + ) + sections: list[tuple[str, str]] nodeid: str - outcome: "Literal['passed', 'failed', 'skipped']" + outcome: Literal["passed", "failed", "skipped"] def __init__(self, **kw: Any) -> None: self.__dict__.update(kw) if TYPE_CHECKING: # Can have arbitrary fields given to __init__(). - def __getattr__(self, key: str) -> Any: - ... + def __getattr__(self, key: str) -> Any: ... def toterminal(self, out: TerminalWriter) -> None: if hasattr(self, "node"): @@ -94,7 +90,7 @@ def toterminal(self, out: TerminalWriter) -> None: s = "" out.line(s) - def get_sections(self, prefix: str) -> Iterator[Tuple[str, str]]: + def get_sections(self, prefix: str) -> Iterator[tuple[str, str]]: for name, content in self.sections: if name.startswith(prefix): yield prefix, content @@ -176,7 +172,7 @@ def count_towards_summary(self) -> bool: return True @property - def head_line(self) -> Optional[str]: + def head_line(self) -> str | None: """**Experimental** The head line shown with longrepr output for this report, more commonly during traceback representation during failures:: @@ -196,13 +192,28 @@ def head_line(self) -> Optional[str]: return domain return None - def _get_verbose_word(self, config: Config): + def _get_verbose_word_with_markup( + self, config: Config, default_markup: Mapping[str, bool] + ) -> tuple[str, Mapping[str, bool]]: _category, _short, verbose = config.hook.pytest_report_teststatus( report=self, config=config ) - return verbose - def _to_json(self) -> Dict[str, Any]: + if isinstance(verbose, str): + return verbose, default_markup + + if isinstance(verbose, Sequence) and len(verbose) == 2: + word, markup = verbose + if isinstance(word, str) and isinstance(markup, Mapping): + return word, markup + + fail( # pragma: no cover + "pytest_report_teststatus() hook (from a plugin) returned " + f"an invalid verbose value: {verbose!r}.\nExpected either a string " + "or a tuple of (word, markup)." + ) + + def _to_json(self) -> dict[str, Any]: """Return the contents of this report as a dict of builtin entries, suitable for serialization. @@ -213,7 +224,7 @@ def _to_json(self) -> Dict[str, Any]: return _report_to_json(self) @classmethod - def _from_json(cls: Type[_R], reportdict: Dict[str, object]) -> _R: + def _from_json(cls, reportdict: dict[str, object]) -> Self: """Create either a TestReport or CollectReport, depending on the calling class. It is the callers responsibility to know which class to pass here. @@ -227,15 +238,15 @@ def _from_json(cls: Type[_R], reportdict: Dict[str, object]) -> _R: def _report_unserialization_failure( - type_name: str, report_class: Type[BaseReport], reportdict + type_name: str, report_class: type[BaseReport], reportdict ) -> NoReturn: url = "https://github.com/pytest-dev/pytest/issues" stream = StringIO() pprint("-" * 100, stream=stream) - pprint("INTERNALERROR: Unknown entry type returned: %s" % type_name, stream=stream) - pprint("report_name: %s" % report_class, stream=stream) + pprint(f"INTERNALERROR: Unknown entry type returned: {type_name}", stream=stream) + pprint(f"report_name: {report_class}", stream=stream) pprint(reportdict, stream=stream) - pprint("Please report this bug at %s" % url, stream=stream) + pprint(f"Please report this bug at {url}", stream=stream) pprint("-" * 100, stream=stream) raise RuntimeError(stream.getvalue()) @@ -250,21 +261,27 @@ class TestReport(BaseReport): __test__ = False + # Defined by skipping plugin. + # xfail reason if xfailed, otherwise not defined. Use hasattr to distinguish. + wasxfail: str + def __init__( self, nodeid: str, - location: Tuple[str, Optional[int], str], + location: tuple[str, int | None, str], keywords: Mapping[str, Any], - outcome: "Literal['passed', 'failed', 'skipped']", - longrepr: Union[ - None, ExceptionInfo[BaseException], Tuple[str, int, str], str, TerminalRepr - ], - when: "Literal['setup', 'call', 'teardown']", - sections: Iterable[Tuple[str, str]] = (), + outcome: Literal["passed", "failed", "skipped"], + longrepr: None + | ExceptionInfo[BaseException] + | tuple[str, int, str] + | str + | TerminalRepr, + when: Literal["setup", "call", "teardown"], + sections: Iterable[tuple[str, str]] = (), duration: float = 0, start: float = 0, stop: float = 0, - user_properties: Optional[Iterable[Tuple[str, object]]] = None, + user_properties: Iterable[tuple[str, object]] | None = None, **extra, ) -> None: #: Normalized collection nodeid. @@ -275,7 +292,7 @@ def __init__( #: collected one e.g. if a method is inherited from a different module. #: The filesystempath may be relative to ``config.rootdir``. #: The line number is 0-based. - self.location: Tuple[str, Optional[int], str] = location + self.location: tuple[str, int | None, str] = location #: A name -> value dictionary containing all keywords and #: markers associated with a test invocation. @@ -288,7 +305,7 @@ def __init__( self.longrepr = longrepr #: One of 'setup', 'call', 'teardown' to indicate runtest phase. - self.when = when + self.when: Literal["setup", "call", "teardown"] = when #: User properties is a list of tuples (name, value) that holds user #: defined properties of the test. @@ -311,12 +328,10 @@ def __init__( self.__dict__.update(extra) def __repr__(self) -> str: - return "<{} {!r} when={!r} outcome={!r}>".format( - self.__class__.__name__, self.nodeid, self.when, self.outcome - ) + return f"<{self.__class__.__name__} {self.nodeid!r} when={self.when!r} outcome={self.outcome!r}>" @classmethod - def from_item_and_call(cls, item: Item, call: "CallInfo[None]") -> "TestReport": + def from_item_and_call(cls, item: Item, call: CallInfo[None]) -> TestReport: """Create and fill a TestReport with standard item and call info. :param item: The item. @@ -333,13 +348,13 @@ def from_item_and_call(cls, item: Item, call: "CallInfo[None]") -> "TestReport": sections = [] if not call.excinfo: outcome: Literal["passed", "failed", "skipped"] = "passed" - longrepr: Union[ - None, - ExceptionInfo[BaseException], - Tuple[str, int, str], - str, - TerminalRepr, - ] = None + longrepr: ( + None + | ExceptionInfo[BaseException] + | tuple[str, int, str] + | str + | TerminalRepr + ) = None else: if not isinstance(excinfo, ExceptionInfo): outcome = "failed" @@ -347,9 +362,9 @@ def from_item_and_call(cls, item: Item, call: "CallInfo[None]") -> "TestReport": elif isinstance(excinfo.value, skip.Exception): outcome = "skipped" r = excinfo._getreprcrash() - assert ( - r is not None - ), "There should always be a traceback entry for skipping a test." + assert r is not None, ( + "There should always be a traceback entry for skipping a test." + ) if excinfo.value._use_item_location: path, line = item.reportinfo()[:2] assert line is not None @@ -393,12 +408,14 @@ class CollectReport(BaseReport): def __init__( self, nodeid: str, - outcome: "Literal['passed', 'failed', 'skipped']", - longrepr: Union[ - None, ExceptionInfo[BaseException], Tuple[str, int, str], str, TerminalRepr - ], - result: Optional[List[Union[Item, Collector]]], - sections: Iterable[Tuple[str, str]] = (), + outcome: Literal["passed", "failed", "skipped"], + longrepr: None + | ExceptionInfo[BaseException] + | tuple[str, int, str] + | str + | TerminalRepr, + result: list[Item | Collector] | None, + sections: Iterable[tuple[str, str]] = (), **extra, ) -> None: #: Normalized collection nodeid. @@ -424,13 +441,11 @@ def __init__( @property def location( # type:ignore[override] self, - ) -> Optional[Tuple[str, Optional[int], str]]: + ) -> tuple[str, int | None, str] | None: return (self.fspath, None, self.fspath) def __repr__(self) -> str: - return "".format( - self.nodeid, len(self.result), self.outcome - ) + return f"" class CollectErrorRepr(TerminalRepr): @@ -442,8 +457,8 @@ def toterminal(self, out: TerminalWriter) -> None: def pytest_report_to_serializable( - report: Union[CollectReport, TestReport] -) -> Optional[Dict[str, Any]]: + report: CollectReport | TestReport, +) -> dict[str, Any] | None: if isinstance(report, (TestReport, CollectReport)): data = report._to_json() data["$report_type"] = report.__class__.__name__ @@ -453,8 +468,8 @@ def pytest_report_to_serializable( def pytest_report_from_serializable( - data: Dict[str, Any], -) -> Optional[Union[CollectReport, TestReport]]: + data: dict[str, Any], +) -> CollectReport | TestReport | None: if "$report_type" in data: if data["$report_type"] == "TestReport": return TestReport._from_json(data) @@ -466,7 +481,7 @@ def pytest_report_from_serializable( return None -def _report_to_json(report: BaseReport) -> Dict[str, Any]: +def _report_to_json(report: BaseReport) -> dict[str, Any]: """Return the contents of this report as a dict of builtin entries, suitable for serialization. @@ -474,8 +489,8 @@ def _report_to_json(report: BaseReport) -> Dict[str, Any]: """ def serialize_repr_entry( - entry: Union[ReprEntry, ReprEntryNative] - ) -> Dict[str, Any]: + entry: ReprEntry | ReprEntryNative, + ) -> dict[str, Any]: data = dataclasses.asdict(entry) for key, value in data.items(): if hasattr(value, "__dict__"): @@ -483,7 +498,7 @@ def serialize_repr_entry( entry_data = {"type": type(entry).__name__, "data": data} return entry_data - def serialize_repr_traceback(reprtraceback: ReprTraceback) -> Dict[str, Any]: + def serialize_repr_traceback(reprtraceback: ReprTraceback) -> dict[str, Any]: result = dataclasses.asdict(reprtraceback) result["reprentries"] = [ serialize_repr_entry(x) for x in reprtraceback.reprentries @@ -491,18 +506,18 @@ def serialize_repr_traceback(reprtraceback: ReprTraceback) -> Dict[str, Any]: return result def serialize_repr_crash( - reprcrash: Optional[ReprFileLocation], - ) -> Optional[Dict[str, Any]]: + reprcrash: ReprFileLocation | None, + ) -> dict[str, Any] | None: if reprcrash is not None: return dataclasses.asdict(reprcrash) else: return None - def serialize_exception_longrepr(rep: BaseReport) -> Dict[str, Any]: + def serialize_exception_longrepr(rep: BaseReport) -> dict[str, Any]: assert rep.longrepr is not None # TODO: Investigate whether the duck typing is really necessary here. longrepr = cast(ExceptionRepr, rep.longrepr) - result: Dict[str, Any] = { + result: dict[str, Any] = { "reprcrash": serialize_repr_crash(longrepr.reprcrash), "reprtraceback": serialize_repr_traceback(longrepr.reprtraceback), "sections": longrepr.sections, @@ -539,7 +554,7 @@ def serialize_exception_longrepr(rep: BaseReport) -> Dict[str, Any]: return d -def _report_kwargs_from_json(reportdict: Dict[str, Any]) -> Dict[str, Any]: +def _report_kwargs_from_json(reportdict: dict[str, Any]) -> dict[str, Any]: """Return **kwargs that can be used to construct a TestReport or CollectReport instance. @@ -560,7 +575,7 @@ def deserialize_repr_entry(entry_data): if data["reprlocals"]: reprlocals = ReprLocals(data["reprlocals"]["lines"]) - reprentry: Union[ReprEntry, ReprEntryNative] = ReprEntry( + reprentry: ReprEntry | ReprEntryNative = ReprEntry( lines=data["lines"], reprfuncargs=reprfuncargs, reprlocals=reprlocals, @@ -579,7 +594,7 @@ def deserialize_repr_traceback(repr_traceback_dict): ] return ReprTraceback(**repr_traceback_dict) - def deserialize_repr_crash(repr_crash_dict: Optional[Dict[str, Any]]): + def deserialize_repr_crash(repr_crash_dict: dict[str, Any] | None): if repr_crash_dict is not None: return ReprFileLocation(**repr_crash_dict) else: @@ -606,9 +621,9 @@ def deserialize_repr_crash(repr_crash_dict: Optional[Dict[str, Any]]): description, ) ) - exception_info: Union[ - ExceptionChainRepr, ReprExceptionInfo - ] = ExceptionChainRepr(chain) + exception_info: ExceptionChainRepr | ReprExceptionInfo = ExceptionChainRepr( + chain + ) else: exception_info = ReprExceptionInfo( reprtraceback=reprtraceback, diff --git a/src/_pytest/runner.py b/src/_pytest/runner.py index f861c05a451..26e4e838b77 100644 --- a/src/_pytest/runner.py +++ b/src/_pytest/runner.py @@ -1,19 +1,20 @@ +# mypy: allow-untyped-defs """Basic collect and runtest protocol implementations.""" + +from __future__ import annotations + import bdb +from collections.abc import Callable import dataclasses import os import sys -from typing import Callable +import types from typing import cast -from typing import Dict +from typing import final from typing import Generic -from typing import List -from typing import Optional -from typing import Tuple -from typing import Type +from typing import Literal from typing import TYPE_CHECKING from typing import TypeVar -from typing import Union from .reports import BaseReport from .reports import CollectErrorRepr @@ -23,10 +24,10 @@ from _pytest._code.code import ExceptionChainRepr from _pytest._code.code import ExceptionInfo from _pytest._code.code import TerminalRepr -from _pytest.compat import final from _pytest.config.argparsing import Parser from _pytest.deprecated import check_ispytest from _pytest.nodes import Collector +from _pytest.nodes import Directory from _pytest.nodes import Item from _pytest.nodes import Node from _pytest.outcomes import Exit @@ -34,12 +35,11 @@ from _pytest.outcomes import Skipped from _pytest.outcomes import TEST_OUTCOME -if sys.version_info[:2] < (3, 11): + +if sys.version_info < (3, 11): from exceptiongroup import BaseExceptionGroup if TYPE_CHECKING: - from typing_extensions import Literal - from _pytest.main import Session from _pytest.terminal import TerminalReporter @@ -61,19 +61,21 @@ def pytest_addoption(parser: Parser) -> None: "--durations-min", action="store", type=float, - default=0.005, + default=None, metavar="N", help="Minimal duration in seconds for inclusion in slowest list. " - "Default: 0.005.", + "Default: 0.005 (or 0.0 if -vv is given).", ) -def pytest_terminal_summary(terminalreporter: "TerminalReporter") -> None: +def pytest_terminal_summary(terminalreporter: TerminalReporter) -> None: durations = terminalreporter.config.option.durations durations_min = terminalreporter.config.option.durations_min - verbose = terminalreporter.config.getvalue("verbose") + verbose = terminalreporter.config.get_verbosity() if durations is None: return + if durations_min is None: + durations_min = 0.005 if verbose < 2 else 0.0 tr = terminalreporter dlist = [] for replist in tr.stats.values(): @@ -82,33 +84,34 @@ def pytest_terminal_summary(terminalreporter: "TerminalReporter") -> None: dlist.append(rep) if not dlist: return - dlist.sort(key=lambda x: x.duration, reverse=True) # type: ignore[no-any-return] + dlist.sort(key=lambda x: x.duration, reverse=True) if not durations: tr.write_sep("=", "slowest durations") else: - tr.write_sep("=", "slowest %s durations" % durations) + tr.write_sep("=", f"slowest {durations} durations") dlist = dlist[:durations] for i, rep in enumerate(dlist): - if verbose < 2 and rep.duration < durations_min: + if rep.duration < durations_min: tr.write_line("") - tr.write_line( - "(%s durations < %gs hidden. Use -vv to show these durations.)" - % (len(dlist) - i, durations_min) - ) + message = f"({len(dlist) - i} durations < {durations_min:g}s hidden." + if terminalreporter.config.option.durations_min is None: + message += " Use -vv to show these durations." + message += ")" + tr.write_line(message) break tr.write_line(f"{rep.duration:02.2f}s {rep.when:<8} {rep.nodeid}") -def pytest_sessionstart(session: "Session") -> None: +def pytest_sessionstart(session: Session) -> None: session._setupstate = SetupState() -def pytest_sessionfinish(session: "Session") -> None: +def pytest_sessionfinish(session: Session) -> None: session._setupstate.teardown_exact(None) -def pytest_runtest_protocol(item: Item, nextitem: Optional[Item]) -> bool: +def pytest_runtest_protocol(item: Item, nextitem: Item | None) -> bool: ihook = item.ihook ihook.pytest_runtest_logstart(nodeid=item.nodeid, location=item.location) runtestprotocol(item, nextitem=nextitem) @@ -117,8 +120,8 @@ def pytest_runtest_protocol(item: Item, nextitem: Optional[Item]) -> bool: def runtestprotocol( - item: Item, log: bool = True, nextitem: Optional[Item] = None -) -> List[TestReport]: + item: Item, log: bool = True, nextitem: Item | None = None +) -> list[TestReport]: hasrequest = hasattr(item, "_request") if hasrequest and not item._request: # type: ignore[attr-defined] # This only happens if the item is re-run, as is done by @@ -131,6 +134,10 @@ def runtestprotocol( show_test_item(item) if not item.config.getoption("setuponly", False): reports.append(call_and_report(item, "call", log)) + # If the session is about to fail or stop, teardown everything - this is + # necessary to correctly report fixture teardown errors (see #11706) + if item.session.shouldfail or item.session.shouldstop: + nextitem = None reports.append(call_and_report(item, "teardown", log, nextitem=nextitem)) # After all teardown hooks have been called # want funcargs and request info to go away. @@ -163,6 +170,8 @@ def pytest_runtest_call(item: Item) -> None: del sys.last_type del sys.last_value del sys.last_traceback + if sys.version_info >= (3, 12, 0): + del sys.last_exc # type:ignore[attr-defined] except AttributeError: pass try: @@ -171,20 +180,22 @@ def pytest_runtest_call(item: Item) -> None: # Store trace info to allow postmortem debugging sys.last_type = type(e) sys.last_value = e + if sys.version_info >= (3, 12, 0): + sys.last_exc = e # type:ignore[attr-defined] assert e.__traceback__ is not None # Skip *this* frame sys.last_traceback = e.__traceback__.tb_next - raise e + raise -def pytest_runtest_teardown(item: Item, nextitem: Optional[Item]) -> None: +def pytest_runtest_teardown(item: Item, nextitem: Item | None) -> None: _update_current_test_var(item, "teardown") item.session._setupstate.teardown_exact(nextitem) _update_current_test_var(item, None) def _update_current_test_var( - item: Item, when: Optional["Literal['setup', 'call', 'teardown']"] + item: Item, when: Literal["setup", "call", "teardown"] | None ) -> None: """Update :envvar:`PYTEST_CURRENT_TEST` to reflect the current item and stage. @@ -200,7 +211,7 @@ def _update_current_test_var( os.environ.pop(var_name) -def pytest_report_teststatus(report: BaseReport) -> Optional[Tuple[str, str, str]]: +def pytest_report_teststatus(report: BaseReport) -> tuple[str, str, str] | None: if report.when in ("setup", "teardown"): if report.failed: # category, shortletter, verbose-word @@ -217,19 +228,32 @@ def pytest_report_teststatus(report: BaseReport) -> Optional[Tuple[str, str, str def call_and_report( - item: Item, when: "Literal['setup', 'call', 'teardown']", log: bool = True, **kwds + item: Item, when: Literal["setup", "call", "teardown"], log: bool = True, **kwds ) -> TestReport: - call = call_runtest_hook(item, when, **kwds) - hook = item.ihook - report: TestReport = hook.pytest_runtest_makereport(item=item, call=call) + ihook = item.ihook + if when == "setup": + runtest_hook: Callable[..., None] = ihook.pytest_runtest_setup + elif when == "call": + runtest_hook = ihook.pytest_runtest_call + elif when == "teardown": + runtest_hook = ihook.pytest_runtest_teardown + else: + assert False, f"Unhandled runtest hook case: {when}" + reraise: tuple[type[BaseException], ...] = (Exit,) + if not item.config.getoption("usepdb", False): + reraise += (KeyboardInterrupt,) + call = CallInfo.from_call( + lambda: runtest_hook(item=item, **kwds), when=when, reraise=reraise + ) + report: TestReport = ihook.pytest_runtest_makereport(item=item, call=call) if log: - hook.pytest_runtest_logreport(report=report) + ihook.pytest_runtest_logreport(report=report) if check_interactive_exception(call, report): - hook.pytest_exception_interact(node=item, call=call, report=report) + ihook.pytest_exception_interact(node=item, call=call, report=report) return report -def check_interactive_exception(call: "CallInfo[object]", report: BaseReport) -> bool: +def check_interactive_exception(call: CallInfo[object], report: BaseReport) -> bool: """Check whether the call raised an exception that should be reported as interactive.""" if call.excinfo is None: @@ -244,25 +268,6 @@ def check_interactive_exception(call: "CallInfo[object]", report: BaseReport) -> return True -def call_runtest_hook( - item: Item, when: "Literal['setup', 'call', 'teardown']", **kwds -) -> "CallInfo[None]": - if when == "setup": - ihook: Callable[..., None] = item.ihook.pytest_runtest_setup - elif when == "call": - ihook = item.ihook.pytest_runtest_call - elif when == "teardown": - ihook = item.ihook.pytest_runtest_teardown - else: - assert False, f"Unhandled runtest hook case: {when}" - reraise: Tuple[Type[BaseException], ...] = (Exit,) - if not item.config.getoption("usepdb", False): - reraise += (KeyboardInterrupt,) - return CallInfo.from_call( - lambda: ihook(item=item, **kwds), when=when, reraise=reraise - ) - - TResult = TypeVar("TResult", covariant=True) @@ -271,9 +276,9 @@ def call_runtest_hook( class CallInfo(Generic[TResult]): """Result/Exception info of a function invocation.""" - _result: Optional[TResult] + _result: TResult | None #: The captured exception of the call, if it raised. - excinfo: Optional[ExceptionInfo[BaseException]] + excinfo: ExceptionInfo[BaseException] | None #: The system time when the call started, in seconds since the epoch. start: float #: The system time when the call ended, in seconds since the epoch. @@ -281,16 +286,16 @@ class CallInfo(Generic[TResult]): #: The call duration, in seconds. duration: float #: The context of invocation: "collect", "setup", "call" or "teardown". - when: "Literal['collect', 'setup', 'call', 'teardown']" + when: Literal["collect", "setup", "call", "teardown"] def __init__( self, - result: Optional[TResult], - excinfo: Optional[ExceptionInfo[BaseException]], + result: TResult | None, + excinfo: ExceptionInfo[BaseException] | None, start: float, stop: float, duration: float, - when: "Literal['collect', 'setup', 'call', 'teardown']", + when: Literal["collect", "setup", "call", "teardown"], *, _ispytest: bool = False, ) -> None: @@ -318,16 +323,15 @@ def result(self) -> TResult: @classmethod def from_call( cls, - func: "Callable[[], TResult]", - when: "Literal['collect', 'setup', 'call', 'teardown']", - reraise: Optional[ - Union[Type[BaseException], Tuple[Type[BaseException], ...]] - ] = None, - ) -> "CallInfo[TResult]": + func: Callable[[], TResult], + when: Literal["collect", "setup", "call", "teardown"], + reraise: type[BaseException] | tuple[type[BaseException], ...] | None = None, + ) -> CallInfo[TResult]: """Call func, wrapping the result in a CallInfo. :param func: The function to call. Called without arguments. + :type func: Callable[[], _pytest.runner.TResult] :param when: The phase in which the function is called. :param reraise: @@ -335,23 +339,19 @@ def from_call( function, instead of being wrapped in the CallInfo. """ excinfo = None - start = timing.time() - precise_start = timing.perf_counter() + instant = timing.Instant() try: - result: Optional[TResult] = func() + result: TResult | None = func() except BaseException: excinfo = ExceptionInfo.from_current() if reraise is not None and isinstance(excinfo.value, reraise): raise result = None - # use the perf counter - precise_stop = timing.perf_counter() - duration = precise_stop - precise_start - stop = timing.time() + duration = instant.elapsed() return cls( - start=start, - stop=stop, - duration=duration, + start=duration.start.time, + stop=duration.stop.time, + duration=duration.seconds, when=when, result=result, excinfo=excinfo, @@ -369,16 +369,36 @@ def pytest_runtest_makereport(item: Item, call: CallInfo[None]) -> TestReport: def pytest_make_collect_report(collector: Collector) -> CollectReport: - call = CallInfo.from_call(lambda: list(collector.collect()), "collect") - longrepr: Union[None, Tuple[str, int, str], str, TerminalRepr] = None + def collect() -> list[Item | Collector]: + # Before collecting, if this is a Directory, load the conftests. + # If a conftest import fails to load, it is considered a collection + # error of the Directory collector. This is why it's done inside of the + # CallInfo wrapper. + # + # Note: initial conftests are loaded early, not here. + if isinstance(collector, Directory): + collector.config.pluginmanager._loadconftestmodules( + collector.path, + collector.config.getoption("importmode"), + rootpath=collector.config.rootpath, + consider_namespace_packages=collector.config.getini( + "consider_namespace_packages" + ), + ) + + return list(collector.collect()) + + call = CallInfo.from_call( + collect, "collect", reraise=(KeyboardInterrupt, SystemExit) + ) + longrepr: None | tuple[str, int, str] | str | TerminalRepr = None if not call.excinfo: outcome: Literal["passed", "skipped", "failed"] = "passed" else: skip_exceptions = [Skipped] unittest = sys.modules.get("unittest") if unittest is not None: - # Type ignored because unittest is loaded dynamically. - skip_exceptions.append(unittest.SkipTest) # type: ignore + skip_exceptions.append(unittest.SkipTest) if isinstance(call.excinfo.value, tuple(skip_exceptions)): outcome = "skipped" r_ = collector._repr_failure_py(call.excinfo, "line") @@ -465,13 +485,13 @@ class SetupState: def __init__(self) -> None: # The stack is in the dict insertion order. - self.stack: Dict[ + self.stack: dict[ Node, - Tuple[ + tuple[ # Node's finalizers. - List[Callable[[], object]], - # Node's exception, if its setup raised. - Optional[Union[OutcomeException, Exception]], + list[Callable[[], object]], + # Node's exception and original traceback, if its setup raised. + tuple[OutcomeException | Exception, types.TracebackType | None] | None, ], ] = {} @@ -484,7 +504,7 @@ def setup(self, item: Item) -> None: for col, (finalizers, exc) in self.stack.items(): assert col in needed_collectors, "previous item was not torn down properly" if exc: - raise exc + raise exc[0].with_traceback(exc[1]) for col in needed_collectors[len(self.stack) :]: assert col not in self.stack @@ -493,8 +513,8 @@ def setup(self, item: Item) -> None: try: col.setup() except TEST_OUTCOME as exc: - self.stack[col] = (self.stack[col][0], exc) - raise exc + self.stack[col] = (self.stack[col][0], (exc, exc.__traceback__)) + raise def addfinalizer(self, finalizer: Callable[[], object], node: Node) -> None: """Attach a finalizer to the given node. @@ -506,15 +526,15 @@ def addfinalizer(self, finalizer: Callable[[], object], node: Node) -> None: assert node in self.stack, (node, self.stack) self.stack[node][0].append(finalizer) - def teardown_exact(self, nextitem: Optional[Item]) -> None: + def teardown_exact(self, nextitem: Item | None) -> None: """Teardown the current stack up until reaching nodes that nextitem also descends from. When nextitem is None (meaning we're at the last item), the entire stack is torn down. """ - needed_collectors = nextitem and nextitem.listchain() or [] - exceptions: List[BaseException] = [] + needed_collectors = (nextitem and nextitem.listchain()) or [] + exceptions: list[BaseException] = [] while self.stack: if list(self.stack.keys()) == needed_collectors[: len(self.stack)]: break diff --git a/src/_pytest/scope.py b/src/_pytest/scope.py index 7a746fb9fa9..2b007e87893 100644 --- a/src/_pytest/scope.py +++ b/src/_pytest/scope.py @@ -7,15 +7,15 @@ Also this makes the module light to import, as it should. """ + +from __future__ import annotations + from enum import Enum from functools import total_ordering -from typing import Optional -from typing import TYPE_CHECKING +from typing import Literal -if TYPE_CHECKING: - from typing_extensions import Literal - _ScopeName = Literal["session", "package", "module", "class", "function"] +_ScopeName = Literal["session", "package", "module", "class", "function"] @total_ordering @@ -33,35 +33,35 @@ class Scope(Enum): """ # Scopes need to be listed from lower to higher. - Function: "_ScopeName" = "function" - Class: "_ScopeName" = "class" - Module: "_ScopeName" = "module" - Package: "_ScopeName" = "package" - Session: "_ScopeName" = "session" + Function = "function" + Class = "class" + Module = "module" + Package = "package" + Session = "session" - def next_lower(self) -> "Scope": + def next_lower(self) -> Scope: """Return the next lower scope.""" index = _SCOPE_INDICES[self] if index == 0: raise ValueError(f"{self} is the lower-most scope") return _ALL_SCOPES[index - 1] - def next_higher(self) -> "Scope": + def next_higher(self) -> Scope: """Return the next higher scope.""" index = _SCOPE_INDICES[self] if index == len(_SCOPE_INDICES) - 1: raise ValueError(f"{self} is the upper-most scope") return _ALL_SCOPES[index + 1] - def __lt__(self, other: "Scope") -> bool: + def __lt__(self, other: Scope) -> bool: self_index = _SCOPE_INDICES[self] other_index = _SCOPE_INDICES[other] return self_index < other_index @classmethod def from_user( - cls, scope_name: "_ScopeName", descr: str, where: Optional[str] = None - ) -> "Scope": + cls, scope_name: _ScopeName, descr: str, where: str | None = None + ) -> Scope: """ Given a scope name from the user, return the equivalent Scope enum. Should be used whenever we want to convert a user provided scope name to its enum object. diff --git a/src/_pytest/setuponly.py b/src/_pytest/setuponly.py index 583590d6b70..7e6b46bcdb4 100644 --- a/src/_pytest/setuponly.py +++ b/src/_pytest/setuponly.py @@ -1,8 +1,7 @@ -from typing import Generator -from typing import Optional -from typing import Union +from __future__ import annotations + +from collections.abc import Generator -import pytest from _pytest._io.saferepr import saferepr from _pytest.config import Config from _pytest.config import ExitCode @@ -10,6 +9,7 @@ from _pytest.fixtures import FixtureDef from _pytest.fixtures import SubRequest from _pytest.scope import Scope +import pytest def pytest_addoption(parser: Parser) -> None: @@ -28,37 +28,42 @@ def pytest_addoption(parser: Parser) -> None: ) -@pytest.hookimpl(hookwrapper=True) +@pytest.hookimpl(wrapper=True) def pytest_fixture_setup( fixturedef: FixtureDef[object], request: SubRequest -) -> Generator[None, None, None]: - yield - if request.config.option.setupshow: - if hasattr(request, "param"): - # Save the fixture parameter so ._show_fixture_action() can - # display it now and during the teardown (in .finish()). - if fixturedef.ids: - if callable(fixturedef.ids): - param = fixturedef.ids(request.param) +) -> Generator[None, object, object]: + try: + return (yield) + finally: + if request.config.option.setupshow: + if hasattr(request, "param"): + # Save the fixture parameter so ._show_fixture_action() can + # display it now and during the teardown (in .finish()). + if fixturedef.ids: + if callable(fixturedef.ids): + param = fixturedef.ids(request.param) + else: + param = fixturedef.ids[request.param_index] else: - param = fixturedef.ids[request.param_index] - else: - param = request.param - fixturedef.cached_param = param # type: ignore[attr-defined] - _show_fixture_action(fixturedef, "SETUP") + param = request.param + fixturedef.cached_param = param # type: ignore[attr-defined] + _show_fixture_action(fixturedef, request.config, "SETUP") -def pytest_fixture_post_finalizer(fixturedef: FixtureDef[object]) -> None: +def pytest_fixture_post_finalizer( + fixturedef: FixtureDef[object], request: SubRequest +) -> None: if fixturedef.cached_result is not None: - config = fixturedef._fixturemanager.config + config = request.config if config.option.setupshow: - _show_fixture_action(fixturedef, "TEARDOWN") + _show_fixture_action(fixturedef, request.config, "TEARDOWN") if hasattr(fixturedef, "cached_param"): - del fixturedef.cached_param # type: ignore[attr-defined] + del fixturedef.cached_param -def _show_fixture_action(fixturedef: FixtureDef[object], msg: str) -> None: - config = fixturedef._fixturemanager.config +def _show_fixture_action( + fixturedef: FixtureDef[object], config: Config, msg: str +) -> None: capman = config.pluginmanager.getplugin("capturemanager") if capman: capman.suspend_global_capture() @@ -68,13 +73,9 @@ def _show_fixture_action(fixturedef: FixtureDef[object], msg: str) -> None: # Use smaller indentation the higher the scope: Session = 0, Package = 1, etc. scope_indent = list(reversed(Scope)).index(fixturedef._scope) tw.write(" " * 2 * scope_indent) - tw.write( - "{step} {scope} {fixture}".format( - step=msg.ljust(8), # align the output to TEARDOWN - scope=fixturedef.scope[0].upper(), - fixture=fixturedef.argname, - ) - ) + + scopename = fixturedef.scope[0].upper() + tw.write(f"{msg:<8} {scopename} {fixturedef.argname}") if msg == "SETUP": deps = sorted(arg for arg in fixturedef.argnames if arg != "request") @@ -82,7 +83,7 @@ def _show_fixture_action(fixturedef: FixtureDef[object], msg: str) -> None: tw.write(" (fixtures used: {})".format(", ".join(deps))) if hasattr(fixturedef, "cached_param"): - tw.write(f"[{saferepr(fixturedef.cached_param, maxsize=42)}]") # type: ignore[attr-defined] + tw.write(f"[{saferepr(fixturedef.cached_param, maxsize=42)}]") tw.flush() @@ -91,7 +92,7 @@ def _show_fixture_action(fixturedef: FixtureDef[object], msg: str) -> None: @pytest.hookimpl(tryfirst=True) -def pytest_cmdline_main(config: Config) -> Optional[Union[int, ExitCode]]: +def pytest_cmdline_main(config: Config) -> int | ExitCode | None: if config.option.setuponly: config.option.setupshow = True return None diff --git a/src/_pytest/setupplan.py b/src/_pytest/setupplan.py index 1a4ebdd99ca..4e124cce243 100644 --- a/src/_pytest/setupplan.py +++ b/src/_pytest/setupplan.py @@ -1,12 +1,11 @@ -from typing import Optional -from typing import Union +from __future__ import annotations -import pytest from _pytest.config import Config from _pytest.config import ExitCode from _pytest.config.argparsing import Parser from _pytest.fixtures import FixtureDef from _pytest.fixtures import SubRequest +import pytest def pytest_addoption(parser: Parser) -> None: @@ -23,7 +22,7 @@ def pytest_addoption(parser: Parser) -> None: @pytest.hookimpl(tryfirst=True) def pytest_fixture_setup( fixturedef: FixtureDef[object], request: SubRequest -) -> Optional[object]: +) -> object | None: # Will return a dummy fixture if the setuponly option is provided. if request.config.option.setupplan: my_cache_key = fixturedef.cache_key(request) @@ -33,7 +32,7 @@ def pytest_fixture_setup( @pytest.hookimpl(tryfirst=True) -def pytest_cmdline_main(config: Config) -> Optional[Union[int, ExitCode]]: +def pytest_cmdline_main(config: Config) -> int | ExitCode | None: if config.option.setupplan: config.option.setuponly = True config.option.setupshow = True diff --git a/src/_pytest/skipping.py b/src/_pytest/skipping.py index 26ce73758a0..ec118f2c92f 100644 --- a/src/_pytest/skipping.py +++ b/src/_pytest/skipping.py @@ -1,14 +1,16 @@ +# mypy: allow-untyped-defs """Support for skip/xfail functions and markers.""" + +from __future__ import annotations + +from collections.abc import Generator +from collections.abc import Mapping import dataclasses import os import platform import sys import traceback -from collections.abc import Mapping -from typing import Generator from typing import Optional -from typing import Tuple -from typing import Type from _pytest.config import Config from _pytest.config import hookimpl @@ -18,7 +20,9 @@ from _pytest.outcomes import fail from _pytest.outcomes import skip from _pytest.outcomes import xfail +from _pytest.raises import AbstractRaises from _pytest.reports import BaseReport +from _pytest.reports import TestReport from _pytest.runner import CallInfo from _pytest.stash import StashKey @@ -81,7 +85,7 @@ def nop(*args, **kwargs): ) -def evaluate_condition(item: Item, mark: Mark, condition: object) -> Tuple[bool, str]: +def evaluate_condition(item: Item, mark: Mark, condition: object) -> tuple[bool, str]: """Evaluate a single skipif/xfail condition. If an old-style string condition is given, it is eval()'d, otherwise the @@ -103,20 +107,18 @@ def evaluate_condition(item: Item, mark: Mark, condition: object) -> Tuple[bool, ): if not isinstance(dictionary, Mapping): raise ValueError( - "pytest_markeval_namespace() needs to return a dict, got {!r}".format( - dictionary - ) + f"pytest_markeval_namespace() needs to return a dict, got {dictionary!r}" ) globals_.update(dictionary) if hasattr(item, "obj"): - globals_.update(item.obj.__globals__) # type: ignore[attr-defined] + globals_.update(item.obj.__globals__) try: filename = f"<{mark.name} condition>" condition_code = compile(condition, filename, "eval") result = eval(condition_code, globals_) except SyntaxError as exc: msglines = [ - "Error evaluating %r condition" % mark.name, + f"Error evaluating {mark.name!r} condition", " " + condition, " " + " " * (exc.offset or 0) + "^", "SyntaxError: invalid syntax", @@ -124,7 +126,7 @@ def evaluate_condition(item: Item, mark: Mark, condition: object) -> Tuple[bool, fail("\n".join(msglines), pytrace=False) except Exception as exc: msglines = [ - "Error evaluating %r condition" % mark.name, + f"Error evaluating {mark.name!r} condition", " " + condition, *traceback.format_exception_only(type(exc), exc), ] @@ -136,7 +138,7 @@ def evaluate_condition(item: Item, mark: Mark, condition: object) -> Tuple[bool, result = bool(condition) except Exception as exc: msglines = [ - "Error evaluating %r condition as a boolean" % mark.name, + f"Error evaluating {mark.name!r} condition as a boolean", *traceback.format_exception_only(type(exc), exc), ] fail("\n".join(msglines), pytrace=False) @@ -148,7 +150,7 @@ def evaluate_condition(item: Item, mark: Mark, condition: object) -> Tuple[bool, else: # XXX better be checked at collection time msg = ( - "Error evaluating %r: " % mark.name + f"Error evaluating {mark.name!r}: " + "you need to specify reason=STRING when using booleans as conditions." ) fail(msg, pytrace=False) @@ -163,7 +165,7 @@ class Skip: reason: str = "unconditional skip" -def evaluate_skip_marks(item: Item) -> Optional[Skip]: +def evaluate_skip_marks(item: Item) -> Skip | None: """Evaluate skip and skipif marks on item, returning Skip if triggered.""" for mark in item.iter_markers(name="skipif"): if "condition" not in mark.kwargs: @@ -195,15 +197,20 @@ def evaluate_skip_marks(item: Item) -> Optional[Skip]: class Xfail: """The result of evaluate_xfail_marks().""" - __slots__ = ("reason", "run", "strict", "raises") + __slots__ = ("raises", "reason", "run", "strict") reason: str run: bool strict: bool - raises: Optional[Tuple[Type[BaseException], ...]] + raises: ( + type[BaseException] + | tuple[type[BaseException], ...] + | AbstractRaises[BaseException] + | None + ) -def evaluate_xfail_marks(item: Item) -> Optional[Xfail]: +def evaluate_xfail_marks(item: Item) -> Xfail | None: """Evaluate xfail marks on item, returning Xfail if triggered.""" for mark in item.iter_markers(name="xfail"): run = mark.kwargs.get("run", True) @@ -243,8 +250,8 @@ def pytest_runtest_setup(item: Item) -> None: xfail("[NOTRUN] " + xfailed.reason) -@hookimpl(hookwrapper=True) -def pytest_runtest_call(item: Item) -> Generator[None, None, None]: +@hookimpl(wrapper=True) +def pytest_runtest_call(item: Item) -> Generator[None]: xfailed = item.stash.get(xfailed_key, None) if xfailed is None: item.stash[xfailed_key] = xfailed = evaluate_xfail_marks(item) @@ -252,33 +259,44 @@ def pytest_runtest_call(item: Item) -> Generator[None, None, None]: if xfailed and not item.config.option.runxfail and not xfailed.run: xfail("[NOTRUN] " + xfailed.reason) - yield + try: + return (yield) + finally: + # The test run may have added an xfail mark dynamically. + xfailed = item.stash.get(xfailed_key, None) + if xfailed is None: + item.stash[xfailed_key] = xfailed = evaluate_xfail_marks(item) - # The test run may have added an xfail mark dynamically. - xfailed = item.stash.get(xfailed_key, None) - if xfailed is None: - item.stash[xfailed_key] = xfailed = evaluate_xfail_marks(item) - -@hookimpl(hookwrapper=True) -def pytest_runtest_makereport(item: Item, call: CallInfo[None]): - outcome = yield - rep = outcome.get_result() +@hookimpl(wrapper=True) +def pytest_runtest_makereport( + item: Item, call: CallInfo[None] +) -> Generator[None, TestReport, TestReport]: + rep = yield xfailed = item.stash.get(xfailed_key, None) if item.config.option.runxfail: pass # don't interfere elif call.excinfo and isinstance(call.excinfo.value, xfail.Exception): assert call.excinfo.value.msg is not None - rep.wasxfail = "reason: " + call.excinfo.value.msg + rep.wasxfail = call.excinfo.value.msg rep.outcome = "skipped" elif not rep.skipped and xfailed: if call.excinfo: raises = xfailed.raises - if raises is not None and not isinstance(call.excinfo.value, raises): - rep.outcome = "failed" - else: + if raises is None or ( + ( + isinstance(raises, (type, tuple)) + and isinstance(call.excinfo.value, raises) + ) + or ( + isinstance(raises, AbstractRaises) + and raises.matches(call.excinfo.value) + ) + ): rep.outcome = "skipped" rep.wasxfail = xfailed.reason + else: + rep.outcome = "failed" elif call.when == "call": if xfailed.strict: rep.outcome = "failed" @@ -286,9 +304,10 @@ def pytest_runtest_makereport(item: Item, call: CallInfo[None]): else: rep.outcome = "passed" rep.wasxfail = xfailed.reason + return rep -def pytest_report_teststatus(report: BaseReport) -> Optional[Tuple[str, str, str]]: +def pytest_report_teststatus(report: BaseReport) -> tuple[str, str, str] | None: if hasattr(report, "wasxfail"): if report.skipped: return "xfailed", "x", "XFAIL" diff --git a/src/_pytest/stash.py b/src/_pytest/stash.py index e61d75b95f7..6a9ff884e04 100644 --- a/src/_pytest/stash.py +++ b/src/_pytest/stash.py @@ -1,9 +1,9 @@ +from __future__ import annotations + from typing import Any from typing import cast -from typing import Dict from typing import Generic from typing import TypeVar -from typing import Union __all__ = ["Stash", "StashKey"] @@ -19,6 +19,8 @@ class StashKey(Generic[T]): A ``StashKey`` is associated with the type ``T`` of the value of the key. A ``StashKey`` is unique and cannot conflict with another key. + + .. versionadded:: 7.0 """ __slots__ = () @@ -61,12 +63,14 @@ class Stash: some_str = stash[some_str_key] # The static type of some_bool is bool. some_bool = stash[some_bool_key] + + .. versionadded:: 7.0 """ __slots__ = ("_storage",) def __init__(self) -> None: - self._storage: Dict[StashKey[Any], object] = {} + self._storage: dict[StashKey[Any], object] = {} def __setitem__(self, key: StashKey[T], value: T) -> None: """Set a value for key.""" @@ -79,7 +83,7 @@ def __getitem__(self, key: StashKey[T]) -> T: """ return cast(T, self._storage[key]) - def get(self, key: StashKey[T], default: D) -> Union[T, D]: + def get(self, key: StashKey[T], default: D) -> T | D: """Get the value for key, or return default if the key wasn't set before.""" try: diff --git a/src/_pytest/stepwise.py b/src/_pytest/stepwise.py index 74ad9dbd4dd..8901540eb59 100644 --- a/src/_pytest/stepwise.py +++ b/src/_pytest/stepwise.py @@ -1,16 +1,21 @@ -from typing import List -from typing import Optional +from __future__ import annotations + +import dataclasses +from datetime import datetime +from datetime import timedelta +from typing import Any from typing import TYPE_CHECKING -import pytest from _pytest import nodes +from _pytest.cacheprovider import Cache from _pytest.config import Config from _pytest.config.argparsing import Parser from _pytest.main import Session from _pytest.reports import TestReport + if TYPE_CHECKING: - from _pytest.cacheprovider import Cache + from typing_extensions import Self STEPWISE_CACHE_DIR = "cache/stepwise" @@ -34,12 +39,20 @@ def pytest_addoption(parser: Parser) -> None: help="Ignore the first failing test but stop on the next failing test. " "Implicitly enables --stepwise.", ) + group.addoption( + "--sw-reset", + "--stepwise-reset", + action="store_true", + default=False, + dest="stepwise_reset", + help="Resets stepwise state, restarting the stepwise workflow. " + "Implicitly enables --stepwise.", + ) -@pytest.hookimpl def pytest_configure(config: Config) -> None: - if config.option.stepwise_skip: - # allow --stepwise-skip to work on it's own merits. + # --stepwise-skip/--stepwise-reset implies stepwise. + if config.option.stepwise_skip or config.option.stepwise_reset: config.option.stepwise = True if config.getoption("stepwise"): config.pluginmanager.register(StepwisePlugin(config), "stepwiseplugin") @@ -52,43 +65,108 @@ def pytest_sessionfinish(session: Session) -> None: # Do not update cache if this process is a xdist worker to prevent # race conditions (#10641). return - # Clear the list of failing tests if the plugin is not active. - session.config.cache.set(STEPWISE_CACHE_DIR, []) + + +@dataclasses.dataclass +class StepwiseCacheInfo: + # The nodeid of the last failed test. + last_failed: str | None + + # The number of tests in the last time --stepwise was run. + # We use this information as a simple way to invalidate the cache information, avoiding + # confusing behavior in case the cache is stale. + last_test_count: int | None + + # The date when the cache was last updated, for information purposes only. + last_cache_date_str: str + + @property + def last_cache_date(self) -> datetime: + return datetime.fromisoformat(self.last_cache_date_str) + + @classmethod + def empty(cls) -> Self: + return cls( + last_failed=None, + last_test_count=None, + last_cache_date_str=datetime.now().isoformat(), + ) + + def update_date_to_now(self) -> None: + self.last_cache_date_str = datetime.now().isoformat() class StepwisePlugin: def __init__(self, config: Config) -> None: self.config = config - self.session: Optional[Session] = None - self.report_status = "" + self.session: Session | None = None + self.report_status: list[str] = [] assert config.cache is not None self.cache: Cache = config.cache - self.lastfailed: Optional[str] = self.cache.get(STEPWISE_CACHE_DIR, None) self.skip: bool = config.getoption("stepwise_skip") + self.reset: bool = config.getoption("stepwise_reset") + self.cached_info = self._load_cached_info() + + def _load_cached_info(self) -> StepwiseCacheInfo: + cached_dict: dict[str, Any] | None = self.cache.get(STEPWISE_CACHE_DIR, None) + if cached_dict: + try: + return StepwiseCacheInfo( + cached_dict["last_failed"], + cached_dict["last_test_count"], + cached_dict["last_cache_date_str"], + ) + except (KeyError, TypeError) as e: + error = f"{type(e).__name__}: {e}" + self.report_status.append(f"error reading cache, discarding ({error})") + + # Cache not found or error during load, return a new cache. + return StepwiseCacheInfo.empty() def pytest_sessionstart(self, session: Session) -> None: self.session = session def pytest_collection_modifyitems( - self, config: Config, items: List[nodes.Item] + self, config: Config, items: list[nodes.Item] ) -> None: - if not self.lastfailed: - self.report_status = "no previously failed tests, not skipping." + last_test_count = self.cached_info.last_test_count + self.cached_info.last_test_count = len(items) + + if self.reset: + self.report_status.append("resetting state, not skipping.") + self.cached_info.last_failed = None + return + + if not self.cached_info.last_failed: + self.report_status.append("no previously failed tests, not skipping.") + return + + if last_test_count is not None and last_test_count != len(items): + self.report_status.append( + f"test count changed, not skipping (now {len(items)} tests, previously {last_test_count})." + ) + self.cached_info.last_failed = None return - # check all item nodes until we find a match on last failed + # Check all item nodes until we find a match on last failed. failed_index = None for index, item in enumerate(items): - if item.nodeid == self.lastfailed: + if item.nodeid == self.cached_info.last_failed: failed_index = index break # If the previously failed test was not found among the test items, # do not skip any tests. if failed_index is None: - self.report_status = "previously failed test not found, not skipping." + self.report_status.append("previously failed test not found, not skipping.") else: - self.report_status = f"skipping {failed_index} already passed items." + cache_age = datetime.now() - self.cached_info.last_cache_date + # Round up to avoid showing microseconds. + cache_age = timedelta(seconds=int(cache_age.total_seconds())) + self.report_status.append( + f"skipping {failed_index} already passed items (cache from {cache_age} ago," + f" use --sw-reset to discard)." + ) deselected = items[:failed_index] del items[:failed_index] config.hook.pytest_deselected(items=deselected) @@ -98,13 +176,13 @@ def pytest_runtest_logreport(self, report: TestReport) -> None: if self.skip: # Remove test from the failed ones (if it exists) and unset the skip option # to make sure the following tests will not be skipped. - if report.nodeid == self.lastfailed: - self.lastfailed = None + if report.nodeid == self.cached_info.last_failed: + self.cached_info.last_failed = None self.skip = False else: # Mark test as the last failing and interrupt the test session. - self.lastfailed = report.nodeid + self.cached_info.last_failed = report.nodeid assert self.session is not None self.session.shouldstop = ( "Test failed, continuing from this test next run." @@ -114,12 +192,12 @@ def pytest_runtest_logreport(self, report: TestReport) -> None: # If the test was actually run and did pass. if report.when == "call": # Remove test from the failed ones, if exists. - if report.nodeid == self.lastfailed: - self.lastfailed = None + if report.nodeid == self.cached_info.last_failed: + self.cached_info.last_failed = None - def pytest_report_collectionfinish(self) -> Optional[str]: - if self.config.getoption("verbose") >= 0 and self.report_status: - return f"stepwise: {self.report_status}" + def pytest_report_collectionfinish(self) -> list[str] | None: + if self.config.get_verbosity() >= 0 and self.report_status: + return [f"stepwise: {x}" for x in self.report_status] return None def pytest_sessionfinish(self) -> None: @@ -127,4 +205,5 @@ def pytest_sessionfinish(self) -> None: # Do not update cache if this process is a xdist worker to prevent # race conditions (#10641). return - self.cache.set(STEPWISE_CACHE_DIR, self.lastfailed) + self.cached_info.update_date_to_now() + self.cache.set(STEPWISE_CACHE_DIR, dataclasses.asdict(self.cached_info)) diff --git a/src/_pytest/terminal.py b/src/_pytest/terminal.py index b0cdb58ce00..a95f79ba6b6 100644 --- a/src/_pytest/terminal.py +++ b/src/_pytest/terminal.py @@ -1,46 +1,45 @@ +# mypy: allow-untyped-defs """Terminal reporting of the full testing process. This is a good source for looking at the various reporting hooks. """ + +from __future__ import annotations + import argparse +from collections import Counter +from collections.abc import Callable +from collections.abc import Generator +from collections.abc import Mapping +from collections.abc import Sequence import dataclasses import datetime +from functools import partial import inspect +from pathlib import Path import platform import sys import textwrap -import warnings -from collections import Counter -from functools import partial -from pathlib import Path from typing import Any -from typing import Callable -from typing import cast from typing import ClassVar -from typing import Dict -from typing import Generator -from typing import List -from typing import Mapping +from typing import final +from typing import Literal from typing import NamedTuple -from typing import Optional -from typing import Sequence -from typing import Set from typing import TextIO -from typing import Tuple from typing import TYPE_CHECKING -from typing import Union +import warnings import pluggy -import _pytest._version +from _pytest import compat from _pytest import nodes from _pytest import timing from _pytest._code import ExceptionInfo from _pytest._code.code import ExceptionRepr from _pytest._io import TerminalWriter from _pytest._io.wcwidth import wcswidth +import _pytest._version from _pytest.assertion.util import running_on_ci -from _pytest.compat import final from _pytest.config import _PluggyPlugin from _pytest.config import Config from _pytest.config import ExitCode @@ -54,9 +53,8 @@ from _pytest.reports import CollectReport from _pytest.reports import TestReport -if TYPE_CHECKING: - from typing_extensions import Literal +if TYPE_CHECKING: from _pytest.main import Session @@ -89,7 +87,7 @@ def __init__( dest: str, default: object = None, required: bool = False, - help: Optional[str] = None, + help: str | None = None, ) -> None: super().__init__( option_strings=option_strings, @@ -104,8 +102,8 @@ def __call__( self, parser: argparse.ArgumentParser, namespace: argparse.Namespace, - values: Union[str, Sequence[object], None], - option_string: Optional[str] = None, + values: str | Sequence[object] | None, + option_string: str | None = None, ) -> None: new_count = getattr(namespace, self.dest, 0) - 1 setattr(namespace, self.dest, new_count) @@ -130,12 +128,12 @@ class TestShortLogReport(NamedTuple): category: str letter: str - word: Union[str, Tuple[str, Mapping[str, bool]]] + word: str | tuple[str, Mapping[str, bool]] def pytest_addoption(parser: Parser) -> None: group = parser.getgroup("terminal reporting", "Reporting", after="general") - group._addoption( + group._addoption( # private to use reserved lower-case short option "-v", "--verbose", action="count", @@ -143,21 +141,35 @@ def pytest_addoption(parser: Parser) -> None: dest="verbose", help="Increase verbosity", ) - group._addoption( + group.addoption( "--no-header", action="store_true", default=False, dest="no_header", help="Disable header", ) - group._addoption( + group.addoption( "--no-summary", action="store_true", default=False, dest="no_summary", help="Disable summary", ) - group._addoption( + group.addoption( + "--no-fold-skipped", + action="store_false", + dest="fold_skipped", + default=True, + help="Do not fold skipped tests in short summary.", + ) + group.addoption( + "--force-short-summary", + action="store_true", + dest="force_short_summary", + default=False, + help="Force condensed summary output regardless of verbosity level.", + ) + group._addoption( # private to use reserved lower-case short option "-q", "--quiet", action=MoreQuietAction, @@ -165,14 +177,14 @@ def pytest_addoption(parser: Parser) -> None: dest="verbose", help="Decrease verbosity", ) - group._addoption( + group.addoption( "--verbosity", dest="verbose", type=int, default=0, help="Set verbosity. Default: 0.", ) - group._addoption( + group._addoption( # private to use reserved lower-case short option "-r", action="store", dest="reportchars", @@ -184,7 +196,7 @@ def pytest_addoption(parser: Parser) -> None: "(w)arnings are enabled by default (see --disable-warnings), " "'N' can be used to reset the list. (default: 'fE').", ) - group._addoption( + group.addoption( "--disable-warnings", "--disable-pytest-warnings", default=False, @@ -192,7 +204,7 @@ def pytest_addoption(parser: Parser) -> None: action="store_true", help="Disable warnings summary", ) - group._addoption( + group._addoption( # private to use reserved lower-case short option "-l", "--showlocals", action="store_true", @@ -200,13 +212,13 @@ def pytest_addoption(parser: Parser) -> None: default=False, help="Show locals in tracebacks (disabled by default)", ) - group._addoption( + group.addoption( "--no-showlocals", action="store_false", dest="showlocals", help="Hide locals in tracebacks (negate --showlocals passed through addopts)", ) - group._addoption( + group.addoption( "--tb", metavar="style", action="store", @@ -215,7 +227,14 @@ def pytest_addoption(parser: Parser) -> None: choices=["auto", "long", "short", "no", "line", "native"], help="Traceback print mode (auto/long/short/line/native/no)", ) - group._addoption( + group.addoption( + "--xfail-tb", + action="store_true", + dest="xfail_tb", + default=False, + help="Show tracebacks for xfail (as long as --tb != no)", + ) + group.addoption( "--show-capture", action="store", dest="showcapture", @@ -224,14 +243,14 @@ def pytest_addoption(parser: Parser) -> None: help="Controls how captured stdout/stderr/log is shown on failed tests. " "Default: all.", ) - group._addoption( + group.addoption( "--fulltrace", "--full-trace", action="store_true", default=False, help="Don't cut any tracebacks (default is to cut)", ) - group._addoption( + group.addoption( "--color", metavar="color", action="store", @@ -240,7 +259,7 @@ def pytest_addoption(parser: Parser) -> None: choices=["yes", "no", "auto"], help="Color terminal output (yes/no/auto)", ) - group._addoption( + group.addoption( "--code-highlight", default="yes", choices=["yes", "no"], @@ -255,6 +274,14 @@ def pytest_addoption(parser: Parser) -> None: "progress even when capture=no)", default="progress", ) + Config._add_verbosity_ini( + parser, + Config.VERBOSITY_TEST_CASES, + help=( + "Specify a verbosity level for test case execution, overriding the main level. " + "Higher levels will provide more detailed information about each test case executed." + ), + ) def pytest_configure(config: Config) -> None: @@ -295,7 +322,7 @@ def getreportopt(config: Config) -> str: @hookimpl(trylast=True) # after _pytest.runner -def pytest_report_teststatus(report: BaseReport) -> Tuple[str, str, str]: +def pytest_report_teststatus(report: BaseReport) -> tuple[str, str, str]: letter = "F" if report.passed: letter = "." @@ -323,12 +350,12 @@ class WarningReport: """ message: str - nodeid: Optional[str] = None - fslocation: Optional[Tuple[str, int]] = None + nodeid: str | None = None + fslocation: tuple[str, int] | None = None count_towards_summary: ClassVar = True - def get_location(self, config: Config) -> Optional[str]: + def get_location(self, config: Config) -> str | None: """Return the more user-friendly information about the location of a warning, or None.""" if self.nodeid: return self.nodeid @@ -341,33 +368,39 @@ def get_location(self, config: Config) -> Optional[str]: @final class TerminalReporter: - def __init__(self, config: Config, file: Optional[TextIO] = None) -> None: + def __init__(self, config: Config, file: TextIO | None = None) -> None: import _pytest.config self.config = config self._numcollected = 0 - self._session: Optional[Session] = None - self._showfspath: Optional[bool] = None + self._session: Session | None = None + self._showfspath: bool | None = None - self.stats: Dict[str, List[Any]] = {} - self._main_color: Optional[str] = None - self._known_types: Optional[List[str]] = None + self.stats: dict[str, list[Any]] = {} + self._main_color: str | None = None + self._known_types: list[str] | None = None self.startpath = config.invocation_params.dir if file is None: file = sys.stdout self._tw = _pytest.config.create_terminal_writer(config, file) self._screen_width = self._tw.fullwidth - self.currentfspath: Union[None, Path, str, int] = None + self.currentfspath: None | Path | str | int = None self.reportchars = getreportopt(config) + self.foldskipped = config.option.fold_skipped self.hasmarkup = self._tw.hasmarkup - self.isatty = file.isatty() - self._progress_nodeids_reported: Set[str] = set() + # isatty should be a method but was wrongly implemented as a boolean. + # We use CallableBool here to support both. + self.isatty = compat.CallableBool(file.isatty()) + self._progress_nodeids_reported: set[str] = set() + self._timing_nodeids_reported: set[str] = set() self._show_progress_info = self._determine_show_progress_info() - self._collect_report_last_write: Optional[float] = None - self._already_displayed_warnings: Optional[int] = None - self._keyboardinterrupt_memo: Optional[ExceptionRepr] = None + self._collect_report_last_write = timing.Instant() + self._already_displayed_warnings: int | None = None + self._keyboardinterrupt_memo: ExceptionRepr | None = None - def _determine_show_progress_info(self) -> "Literal['progress', 'count', False]": + def _determine_show_progress_info( + self, + ) -> Literal["progress", "count", "times", False]: """Return whether we should display progress information based on the current config.""" # do not show progress if we are not capturing output (#3038) unless explicitly # overridden by progress-even-when-capture-no @@ -381,10 +414,12 @@ def _determine_show_progress_info(self) -> "Literal['progress', 'count', False]" if self.config.getoption("setupshow", False): return False cfg: str = self.config.getini("console_output_style") - if cfg == "progress" or cfg == "progress-even-when-capture-no": + if cfg in {"progress", "progress-even-when-capture-no"}: return "progress" elif cfg == "count": return "count" + elif cfg == "times": + return "times" else: return False @@ -408,22 +443,22 @@ def no_summary(self) -> bool: @property def showfspath(self) -> bool: if self._showfspath is None: - return self.verbosity >= 0 + return self.config.get_verbosity(Config.VERBOSITY_TEST_CASES) >= 0 return self._showfspath @showfspath.setter - def showfspath(self, value: Optional[bool]) -> None: + def showfspath(self, value: bool | None) -> None: self._showfspath = value @property def showlongtestinfo(self) -> bool: - return self.verbosity > 0 + return self.config.get_verbosity(Config.VERBOSITY_TEST_CASES) > 0 def hasopt(self, char: str) -> bool: char = {"xfailed": "x", "skipped": "s"}.get(char, char) return char in self.reportchars - def write_fspath_result(self, nodeid: str, res, **markup: bool) -> None: + def write_fspath_result(self, nodeid: str, res: str, **markup: bool) -> None: fspath = self.config.rootpath / nodeid.split("::")[0] if self.currentfspath is None or fspath != self.currentfspath: if self.currentfspath is not None and self._show_progress_info: @@ -476,7 +511,7 @@ def write(self, content: str, *, flush: bool = False, **markup: bool) -> None: def flush(self) -> None: self._tw.flush() - def write_line(self, line: Union[str, bytes], **markup: bool) -> None: + def write_line(self, line: str | bytes, **markup: bool) -> None: if not isinstance(line, str): line = str(line, errors="replace") self.ensure_newline() @@ -503,8 +538,8 @@ def rewrite(self, line: str, **markup: bool) -> None: def write_sep( self, sep: str, - title: Optional[str] = None, - fullwidth: Optional[int] = None, + title: str | None = None, + fullwidth: int | None = None, **markup: bool, ) -> None: self.ensure_newline() @@ -554,12 +589,13 @@ def pytest_deselected(self, items: Sequence[Item]) -> None: self._add_stats("deselected", items) def pytest_runtest_logstart( - self, nodeid: str, location: Tuple[str, Optional[int], str] + self, nodeid: str, location: tuple[str, int | None, str] ) -> None: + fspath, lineno, domain = location # Ensure that the path is printed before the # 1st test of a module starts running. if self.showlongtestinfo: - line = self._locationline(nodeid, *location) + line = self._locationline(nodeid, fspath, lineno, domain) self.write_ensure_prefix(line, "") self.flush() elif self.showfspath: @@ -582,7 +618,6 @@ def pytest_runtest_logreport(self, report: TestReport) -> None: if not letter and not word: # Probably passed setup/teardown. return - running_xdist = hasattr(rep, "node") if markup is None: was_xfail = hasattr(report, "wasxfail") if rep.passed and not was_xfail: @@ -595,16 +630,25 @@ def pytest_runtest_logreport(self, report: TestReport) -> None: markup = {"yellow": True} else: markup = {} - if self.verbosity <= 0: + self._progress_nodeids_reported.add(rep.nodeid) + if self.config.get_verbosity(Config.VERBOSITY_TEST_CASES) <= 0: self._tw.write(letter, **markup) + # When running in xdist, the logreport and logfinish of multiple + # items are interspersed, e.g. `logreport`, `logreport`, + # `logfinish`, `logfinish`. To avoid the "past edge" calculation + # from getting confused and overflowing (#7166), do the past edge + # printing here and not in logfinish, except for the 100% which + # should only be printed after all teardowns are finished. + if self._show_progress_info and not self._is_last_item: + self._write_progress_information_if_past_edge() else: - self._progress_nodeids_reported.add(rep.nodeid) line = self._locationline(rep.nodeid, *rep.location) + running_xdist = hasattr(rep, "node") if not running_xdist: self.write_ensure_prefix(line, word, **markup) if rep.skipped or hasattr(report, "wasxfail"): reason = _get_raw_skip_reason(rep) - if self.config.option.verbose < 2: + if self.config.get_verbosity(Config.VERBOSITY_TEST_CASES) < 2: available_width = ( (self._tw.fullwidth - self._tw.width_of_current_line) - len(" [100%]") @@ -622,7 +666,7 @@ def pytest_runtest_logreport(self, report: TestReport) -> None: self._write_progress_information_filling_space() else: self.ensure_newline() - self._tw.write("[%s]" % rep.node.gateway.id) + self._tw.write(f"[{rep.node.gateway.id}]") if self._show_progress_info: self._tw.write( self._get_progress_information_message() + " ", cyan=True @@ -639,43 +683,80 @@ def _is_last_item(self) -> bool: assert self._session is not None return len(self._progress_nodeids_reported) == self._session.testscollected - def pytest_runtest_logfinish(self, nodeid: str) -> None: - assert self._session - if self.verbosity <= 0 and self._show_progress_info: - if self._show_progress_info == "count": - num_tests = self._session.testscollected - progress_length = len(f" [{num_tests}/{num_tests}]") - else: - progress_length = len(" [100%]") + @hookimpl(wrapper=True) + def pytest_runtestloop(self) -> Generator[None, object, object]: + result = yield - self._progress_nodeids_reported.add(nodeid) + # Write the final/100% progress -- deferred until the loop is complete. + if ( + self.config.get_verbosity(Config.VERBOSITY_TEST_CASES) <= 0 + and self._show_progress_info + and self._progress_nodeids_reported + ): + self._write_progress_information_filling_space() - if self._is_last_item: - self._write_progress_information_filling_space() - else: - main_color, _ = self._get_main_color() - w = self._width_of_current_line - past_edge = w + progress_length + 1 >= self._screen_width - if past_edge: - msg = self._get_progress_information_message() - self._tw.write(msg + "\n", **{main_color: True}) + return result def _get_progress_information_message(self) -> str: assert self._session collected = self._session.testscollected if self._show_progress_info == "count": if collected: - progress = self._progress_nodeids_reported + progress = len(self._progress_nodeids_reported) counter_format = f"{{:{len(str(collected))}d}}" format_string = f" [{counter_format}/{{}}]" - return format_string.format(len(progress), collected) + return format_string.format(progress, collected) return f" [ {collected} / {collected} ]" - else: - if collected: - return " [{:3d}%]".format( - len(self._progress_nodeids_reported) * 100 // collected + if self._show_progress_info == "times": + if not collected: + return "" + all_reports = ( + self._get_reports_to_display("passed") + + self._get_reports_to_display("xpassed") + + self._get_reports_to_display("failed") + + self._get_reports_to_display("xfailed") + + self._get_reports_to_display("skipped") + + self._get_reports_to_display("error") + + self._get_reports_to_display("") + ) + current_location = all_reports[-1].location[0] + not_reported = [ + r for r in all_reports if r.nodeid not in self._timing_nodeids_reported + ] + tests_in_module = sum( + i.location[0] == current_location for i in self._session.items + ) + tests_completed = sum( + r.when == "setup" + for r in not_reported + if r.location[0] == current_location + ) + last_in_module = tests_completed == tests_in_module + if self.showlongtestinfo or last_in_module: + self._timing_nodeids_reported.update(r.nodeid for r in not_reported) + return format_node_duration( + sum(r.duration for r in not_reported if isinstance(r, TestReport)) ) - return " [100%]" + return "" + if collected: + return f" [{len(self._progress_nodeids_reported) * 100 // collected:3d}%]" + return " [100%]" + + def _write_progress_information_if_past_edge(self) -> None: + w = self._width_of_current_line + if self._show_progress_info == "count": + assert self._session + num_tests = self._session.testscollected + progress_length = len(f" [{num_tests}/{num_tests}]") + elif self._show_progress_info == "times": + progress_length = len(" 99h 59m") + else: + progress_length = len(" [100%]") + past_edge = w + progress_length + 1 >= self._screen_width + if past_edge: + main_color, _ = self._get_main_color() + msg = self._get_progress_information_message() + self._tw.write(msg + "\n", **{main_color: True}) def _write_progress_information_filling_space(self) -> None: color, _ = self._get_main_color() @@ -690,10 +771,9 @@ def _width_of_current_line(self) -> int: return self._tw.width_of_current_line def pytest_collection(self) -> None: - if self.isatty: + if self.isatty(): if self.config.option.verbose >= 0: self.write("collecting ... ", flush=True, bold=True) - self._collect_report_last_write = timing.time() elif self.config.option.verbose >= 1: self.write("collecting ... ", flush=True, bold=True) @@ -704,7 +784,7 @@ def pytest_collectreport(self, report: CollectReport) -> None: self._add_stats("skipped", [report]) items = [x for x in report.result if isinstance(x, Item)] self._numcollected += len(items) - if self.isatty: + if self.isatty(): self.report_collect() def report_collect(self, final: bool = False) -> None: @@ -712,14 +792,13 @@ def report_collect(self, final: bool = False) -> None: return if not final: - # Only write "collecting" report every 0.5s. - t = timing.time() + # Only write the "collecting" report every `REPORT_COLLECTING_RESOLUTION`. if ( - self._collect_report_last_write is not None - and self._collect_report_last_write > t - REPORT_COLLECTING_RESOLUTION + self._collect_report_last_write.elapsed().seconds + < REPORT_COLLECTING_RESOLUTION ): return - self._collect_report_last_write = t + self._collect_report_last_write = timing.Instant() errors = len(self.stats.get("error", [])) skipped = len(self.stats.get("skipped", [])) @@ -730,14 +809,14 @@ def report_collect(self, final: bool = False) -> None: str(self._numcollected) + " item" + ("" if self._numcollected == 1 else "s") ) if errors: - line += " / %d error%s" % (errors, "s" if errors != 1 else "") + line += f" / {errors} error{'s' if errors != 1 else ''}" if deselected: - line += " / %d deselected" % deselected + line += f" / {deselected} deselected" if skipped: - line += " / %d skipped" % skipped + line += f" / {skipped} skipped" if self._numcollected > selected: - line += " / %d selected" % selected - if self.isatty: + line += f" / {selected} selected" + if self.isatty(): self.rewrite(line, bold=True, erase=True) if final: self.write("\n") @@ -745,9 +824,9 @@ def report_collect(self, final: bool = False) -> None: self.write_line(line) @hookimpl(trylast=True) - def pytest_sessionstart(self, session: "Session") -> None: + def pytest_sessionstart(self, session: Session) -> None: self._session = session - self._sessionstarttime = timing.time() + self._session_start = timing.Instant() if not self.showheader: return self.write_sep("=", "test session starts", bold=True) @@ -758,9 +837,7 @@ def pytest_sessionstart(self, session: "Session") -> None: if pypy_version_info: verinfo = ".".join(map(str, pypy_version_info[:3])) msg += f"[pypy-{verinfo}-{pypy_version_info[3]}]" - msg += ", pytest-{}, pluggy-{}".format( - _pytest._version.version, pluggy.__version__ - ) + msg += f", pytest-{_pytest._version.version}, pluggy-{pluggy.__version__}" if ( self.verbosity > 0 or self.config.option.debug @@ -774,7 +851,7 @@ def pytest_sessionstart(self, session: "Session") -> None: self._write_report_lines_from_hooks(lines) def _write_report_lines_from_hooks( - self, lines: Sequence[Union[str, Sequence[str]]] + self, lines: Sequence[str | Sequence[str]] ) -> None: for line_or_lines in reversed(lines): if isinstance(line_or_lines, str): @@ -783,22 +860,24 @@ def _write_report_lines_from_hooks( for line in line_or_lines: self.write_line(line) - def pytest_report_header(self, config: Config) -> List[str]: + def pytest_report_header(self, config: Config) -> list[str]: result = [f"rootdir: {config.rootpath}"] if config.inipath: result.append("configfile: " + bestrelpath(config.rootpath, config.inipath)) if config.args_source == Config.ArgsSource.TESTPATHS: - testpaths: List[str] = config.getini("testpaths") + testpaths: list[str] = config.getini("testpaths") result.append("testpaths: {}".format(", ".join(testpaths))) plugininfo = config.pluginmanager.list_plugin_distinfo() if plugininfo: - result.append("plugins: %s" % ", ".join(_plugin_nameversions(plugininfo))) + result.append( + "plugins: {}".format(", ".join(_plugin_nameversions(plugininfo))) + ) return result - def pytest_collection_finish(self, session: "Session") -> None: + def pytest_collection_finish(self, session: Session) -> None: self.report_collect(True) lines = self.config.hook.pytest_report_collectionfinish( @@ -821,16 +900,17 @@ def pytest_collection_finish(self, session: "Session") -> None: rep.toterminal(self._tw) def _printcollecteditems(self, items: Sequence[Item]) -> None: - if self.config.option.verbose < 0: - if self.config.option.verbose < -1: + test_cases_verbosity = self.config.get_verbosity(Config.VERBOSITY_TEST_CASES) + if test_cases_verbosity < 0: + if test_cases_verbosity < -1: counts = Counter(item.nodeid.split("::", 1)[0] for item in items) for name, count in sorted(counts.items()): - self._tw.line("%s: %d" % (name, count)) + self._tw.line(f"{name}: {count}") else: for item in items: self._tw.line(item.nodeid) return - stack: List[Node] = [] + stack: list[Node] = [] indent = "" for item in items: needed_collectors = item.listchain()[1:] # strip root node @@ -842,19 +922,18 @@ def _printcollecteditems(self, items: Sequence[Item]) -> None: stack.append(col) indent = (len(stack) - 1) * " " self._tw.line(f"{indent}{col}") - if self.config.option.verbose >= 1: + if test_cases_verbosity >= 1: obj = getattr(col, "obj", None) doc = inspect.getdoc(obj) if obj else None if doc: for line in doc.splitlines(): self._tw.line("{}{}".format(indent + " ", line)) - @hookimpl(hookwrapper=True) + @hookimpl(wrapper=True) def pytest_sessionfinish( - self, session: "Session", exitstatus: Union[int, ExitCode] - ): - outcome = yield - outcome.get_result() + self, session: Session, exitstatus: int | ExitCode + ) -> Generator[None]: + result = yield self._tw.line("") summary_exit_codes = ( ExitCode.OK, @@ -875,17 +954,22 @@ def pytest_sessionfinish( elif session.shouldstop: self.write_sep("!", str(session.shouldstop), red=True) self.summary_stats() + return result - @hookimpl(hookwrapper=True) - def pytest_terminal_summary(self) -> Generator[None, None, None]: + @hookimpl(wrapper=True) + def pytest_terminal_summary(self) -> Generator[None]: self.summary_errors() self.summary_failures() + self.summary_xfailures() self.summary_warnings() self.summary_passes() - yield - self.short_test_summary() - # Display any extra warnings from teardown here (if any). - self.summary_warnings() + self.summary_xpasses() + try: + return (yield) + finally: + self.short_test_summary() + # Display any extra warnings from teardown here (if any). + self.summary_warnings() def pytest_keyboard_interrupt(self, excinfo: ExceptionInfo[BaseException]) -> None: self._keyboardinterrupt_memo = excinfo.getrepr(funcargs=True) @@ -911,7 +995,7 @@ def _report_keyboardinterrupt(self) -> None: ) def _locationline( - self, nodeid: str, fspath: str, lineno: Optional[int], domain: str + self, nodeid: str, fspath: str, lineno: int | None, domain: str ) -> str: def mkrel(nodeid: str) -> str: line = self.config.cwd_relative_nodeid(nodeid) @@ -922,7 +1006,7 @@ def mkrel(nodeid: str) -> str: line += "[".join(values) return line - # collect_fspath comes from testid which has a "/"-normalized path. + # fspath comes from testid which has a "/"-normalized path. if fspath: res = mkrel(nodeid) if self.verbosity >= 2 and nodeid.split("::")[0] != fspath.replace( @@ -956,7 +1040,7 @@ def getreports(self, name: str): def summary_warnings(self) -> None: if self.hasopt("w"): - all_warnings: Optional[List[WarningReport]] = self.stats.get("warnings") + all_warnings: list[WarningReport] | None = self.stats.get("warnings") if not all_warnings: return @@ -969,11 +1053,11 @@ def summary_warnings(self) -> None: if not warning_reports: return - reports_grouped_by_message: Dict[str, List[WarningReport]] = {} + reports_grouped_by_message: dict[str, list[WarningReport]] = {} for wr in warning_reports: reports_grouped_by_message.setdefault(wr.message, []).append(wr) - def collapsed_location_report(reports: List[WarningReport]) -> str: + def collapsed_location_report(reports: list[WarningReport]) -> str: locations = [] for w in reports: location = w.get_location(self.config) @@ -1009,12 +1093,20 @@ def collapsed_location_report(reports: List[WarningReport]) -> str: ) def summary_passes(self) -> None: + self.summary_passes_combined("passed", "PASSES", "P") + + def summary_xpasses(self) -> None: + self.summary_passes_combined("xpassed", "XPASSES", "X") + + def summary_passes_combined( + self, which_reports: str, sep_title: str, needed_opt: str + ) -> None: if self.config.option.tbstyle != "no": - if self.hasopt("P"): - reports: List[TestReport] = self.getreports("passed") + if self.hasopt(needed_opt): + reports: list[TestReport] = self.getreports(which_reports) if not reports: return - self.write_sep("=", "PASSES") + self.write_sep("=", sep_title) for rep in reports: if rep.sections: msg = self._getfailureheadline(rep) @@ -1022,7 +1114,7 @@ def summary_passes(self) -> None: self._outrep_summary(rep) self._handle_teardown_sections(rep.nodeid) - def _get_teardown_reports(self, nodeid: str) -> List[TestReport]: + def _get_teardown_reports(self, nodeid: str) -> list[TestReport]: reports = self.getreports("") return [ report @@ -1048,25 +1140,42 @@ def print_teardown_sections(self, rep: TestReport) -> None: self._tw.line(content) def summary_failures(self) -> None: - if self.config.option.tbstyle != "no": - reports: List[BaseReport] = self.getreports("failed") - if not reports: - return - self.write_sep("=", "FAILURES") - if self.config.option.tbstyle == "line": - for rep in reports: - line = self._getcrashline(rep) - self.write_line(line) - else: - for rep in reports: - msg = self._getfailureheadline(rep) - self.write_sep("_", msg, red=True, bold=True) - self._outrep_summary(rep) - self._handle_teardown_sections(rep.nodeid) + style = self.config.option.tbstyle + self.summary_failures_combined("failed", "FAILURES", style=style) + + def summary_xfailures(self) -> None: + show_tb = self.config.option.xfail_tb + style = self.config.option.tbstyle if show_tb else "no" + self.summary_failures_combined("xfailed", "XFAILURES", style=style) + + def summary_failures_combined( + self, + which_reports: str, + sep_title: str, + *, + style: str, + needed_opt: str | None = None, + ) -> None: + if style != "no": + if not needed_opt or self.hasopt(needed_opt): + reports: list[BaseReport] = self.getreports(which_reports) + if not reports: + return + self.write_sep("=", sep_title) + if style == "line": + for rep in reports: + line = self._getcrashline(rep) + self.write_line(line) + else: + for rep in reports: + msg = self._getfailureheadline(rep) + self.write_sep("_", msg, red=True, bold=True) + self._outrep_summary(rep) + self._handle_teardown_sections(rep.nodeid) def summary_errors(self) -> None: if self.config.option.tbstyle != "no": - reports: List[BaseReport] = self.getreports("error") + reports: list[BaseReport] = self.getreports("error") if not reports: return self.write_sep("=", "ERRORS") @@ -1096,7 +1205,7 @@ def summary_stats(self) -> None: if self.verbosity < -1: return - session_duration = timing.time() - self._sessionstarttime + session_duration = self._session_start.elapsed() (parts, main_color) = self.build_summary_stats_line() line_parts = [] @@ -1111,7 +1220,7 @@ def summary_stats(self) -> None: msg = ", ".join(line_parts) main_markup = {main_color: True} - duration = f" in {format_session_duration(session_duration)}" + duration = f" in {format_session_duration(session_duration.seconds)}" duration_with_markup = self._tw.markup(duration, **main_markup) if display_sep: fullwidth += len(duration_with_markup) - len(duration) @@ -1133,7 +1242,7 @@ def short_test_summary(self) -> None: if not self.reportchars: return - def show_simple(lines: List[str], *, stat: str) -> None: + def show_simple(lines: list[str], *, stat: str) -> None: failed = self.stats.get(stat, []) if not failed: return @@ -1145,13 +1254,13 @@ def show_simple(lines: List[str], *, stat: str) -> None: ) lines.append(line) - def show_xfailed(lines: List[str]) -> None: + def show_xfailed(lines: list[str]) -> None: xfailed = self.stats.get("xfailed", []) for rep in xfailed: - verbose_word = rep._get_verbose_word(self.config) - markup_word = self._tw.markup( - verbose_word, **{_color_for_type["warnings"]: True} + verbose_word, verbose_markup = rep._get_verbose_word_with_markup( + self.config, {_color_for_type["warnings"]: True} ) + markup_word = self._tw.markup(verbose_word, **verbose_markup) nodeid = _get_node_id_with_markup(self._tw, self.config, rep) line = f"{markup_word} {nodeid}" reason = rep.wasxfail @@ -1160,38 +1269,64 @@ def show_xfailed(lines: List[str]) -> None: lines.append(line) - def show_xpassed(lines: List[str]) -> None: + def show_xpassed(lines: list[str]) -> None: xpassed = self.stats.get("xpassed", []) for rep in xpassed: - verbose_word = rep._get_verbose_word(self.config) - markup_word = self._tw.markup( - verbose_word, **{_color_for_type["warnings"]: True} + verbose_word, verbose_markup = rep._get_verbose_word_with_markup( + self.config, {_color_for_type["warnings"]: True} ) + markup_word = self._tw.markup(verbose_word, **verbose_markup) nodeid = _get_node_id_with_markup(self._tw, self.config, rep) + line = f"{markup_word} {nodeid}" reason = rep.wasxfail - lines.append(f"{markup_word} {nodeid} {reason}") + if reason: + line += " - " + str(reason) + lines.append(line) - def show_skipped(lines: List[str]) -> None: - skipped: List[CollectReport] = self.stats.get("skipped", []) + def show_skipped_folded(lines: list[str]) -> None: + skipped: list[CollectReport] = self.stats.get("skipped", []) fskips = _folded_skips(self.startpath, skipped) if skipped else [] if not fskips: return - verbose_word = skipped[0]._get_verbose_word(self.config) - markup_word = self._tw.markup( - verbose_word, **{_color_for_type["warnings"]: True} + verbose_word, verbose_markup = skipped[0]._get_verbose_word_with_markup( + self.config, {_color_for_type["warnings"]: True} ) + markup_word = self._tw.markup(verbose_word, **verbose_markup) prefix = "Skipped: " for num, fspath, lineno, reason in fskips: if reason.startswith(prefix): reason = reason[len(prefix) :] if lineno is not None: - lines.append( - "%s [%d] %s:%d: %s" % (markup_word, num, fspath, lineno, reason) - ) + lines.append(f"{markup_word} [{num}] {fspath}:{lineno}: {reason}") else: - lines.append("%s [%d] %s: %s" % (markup_word, num, fspath, reason)) + lines.append(f"{markup_word} [{num}] {fspath}: {reason}") + + def show_skipped_unfolded(lines: list[str]) -> None: + skipped: list[CollectReport] = self.stats.get("skipped", []) + + for rep in skipped: + assert rep.longrepr is not None + assert isinstance(rep.longrepr, tuple), (rep, rep.longrepr) + assert len(rep.longrepr) == 3, (rep, rep.longrepr) + + verbose_word, verbose_markup = rep._get_verbose_word_with_markup( + self.config, {_color_for_type["warnings"]: True} + ) + markup_word = self._tw.markup(verbose_word, **verbose_markup) + nodeid = _get_node_id_with_markup(self._tw, self.config, rep) + line = f"{markup_word} {nodeid}" + reason = rep.longrepr[2] + if reason: + line += " - " + str(reason) + lines.append(line) - REPORTCHAR_ACTIONS: Mapping[str, Callable[[List[str]], None]] = { + def show_skipped(lines: list[str]) -> None: + if self.foldskipped: + show_skipped_folded(lines) + else: + show_skipped_unfolded(lines) + + REPORTCHAR_ACTIONS: Mapping[str, Callable[[list[str]], None]] = { "x": show_xfailed, "X": show_xpassed, "f": partial(show_simple, stat="failed"), @@ -1200,7 +1335,7 @@ def show_skipped(lines: List[str]) -> None: "E": partial(show_simple, stat="error"), } - lines: List[str] = [] + lines: list[str] = [] for char in self.reportchars: action = REPORTCHAR_ACTIONS.get(char) if action: # skipping e.g. "P" (passed with output) here. @@ -1211,7 +1346,7 @@ def show_skipped(lines: List[str]) -> None: for line in lines: self.write_line(line) - def _get_main_color(self) -> Tuple[str, List[str]]: + def _get_main_color(self) -> tuple[str, list[str]]: if self._main_color is None or self._known_types is None or self._is_last_item: self._set_main_color() assert self._main_color @@ -1231,22 +1366,22 @@ def _determine_main_color(self, unknown_type_seen: bool) -> str: return main_color def _set_main_color(self) -> None: - unknown_types: List[str] = [] - for found_type in self.stats.keys(): + unknown_types: list[str] = [] + for found_type in self.stats: if found_type: # setup/teardown reports have an empty key, ignore them if found_type not in KNOWN_TYPES and found_type not in unknown_types: unknown_types.append(found_type) self._known_types = list(KNOWN_TYPES) + unknown_types self._main_color = self._determine_main_color(bool(unknown_types)) - def build_summary_stats_line(self) -> Tuple[List[Tuple[str, Dict[str, bool]]], str]: + def build_summary_stats_line(self) -> tuple[list[tuple[str, dict[str, bool]]], str]: """ Build the parts used in the last summary stats line. The summary stats line is the line shown at the end, "=== 12 passed, 2 errors in Xs===". This function builds a list of the "parts" that make up for the text in that line, in - the example above it would be: + the example above it would be:: [ ("12 passed", {"green": True}), @@ -1264,14 +1399,14 @@ def build_summary_stats_line(self) -> Tuple[List[Tuple[str, Dict[str, bool]]], s else: return self._build_normal_summary_stats_line() - def _get_reports_to_display(self, key: str) -> List[Any]: + def _get_reports_to_display(self, key: str) -> list[Any]: """Get test/collection reports for the given status key, such as `passed` or `error`.""" reports = self.stats.get(key, []) return [x for x in reports if getattr(x, "count_towards_summary", True)] def _build_normal_summary_stats_line( self, - ) -> Tuple[List[Tuple[str, Dict[str, bool]]], str]: + ) -> tuple[list[tuple[str, dict[str, bool]]], str]: main_color, known_types = self._get_main_color() parts = [] @@ -1281,7 +1416,7 @@ def _build_normal_summary_stats_line( count = len(reports) color = _color_for_type.get(key, _color_for_type_default) markup = {color: True, "bold": color == main_color} - parts.append(("%d %s" % pluralize(count, key), markup)) + parts.append(("%d %s" % pluralize(count, key), markup)) # noqa: UP031 if not parts: parts = [("no tests ran", {_color_for_type_default: True})] @@ -1290,7 +1425,7 @@ def _build_normal_summary_stats_line( def _build_collect_only_summary_stats_line( self, - ) -> Tuple[List[Tuple[str, Dict[str, bool]]], str]: + ) -> tuple[list[tuple[str, dict[str, bool]]], str]: deselected = len(self._get_reports_to_display("deselected")) errors = len(self._get_reports_to_display("error")) @@ -1300,7 +1435,7 @@ def _build_collect_only_summary_stats_line( elif deselected == 0: main_color = "green" - collected_output = "%d %s collected" % pluralize(self._numcollected, "test") + collected_output = "%d %s collected" % pluralize(self._numcollected, "test") # noqa: UP031 parts = [(collected_output, {main_color: True})] else: all_tests_were_deselected = self._numcollected == deselected @@ -1316,7 +1451,7 @@ def _build_collect_only_summary_stats_line( if errors: main_color = _color_for_type["error"] - parts += [("%d %s" % pluralize(errors, "error"), {main_color: True})] + parts += [("%d %s" % pluralize(errors, "error"), {main_color: True})] # noqa: UP031 return parts, main_color @@ -1331,7 +1466,7 @@ def _get_node_id_with_markup(tw: TerminalWriter, config: Config, rep: BaseReport return path -def _format_trimmed(format: str, msg: str, available_width: int) -> Optional[str]: +def _format_trimmed(format: str, msg: str, available_width: int) -> str | None: """Format msg into format, ellipsizing it if doesn't fit in available_width. Returns None if even the ellipsis can't fit. @@ -1357,11 +1492,13 @@ def _format_trimmed(format: str, msg: str, available_width: int) -> Optional[str def _get_line_with_reprcrash_message( - config: Config, rep: BaseReport, tw: TerminalWriter, word_markup: Dict[str, bool] + config: Config, rep: BaseReport, tw: TerminalWriter, word_markup: dict[str, bool] ) -> str: """Get summary line for a report, trying to add reprcrash message.""" - verbose_word = rep._get_verbose_word(config) - word = tw.markup(verbose_word, **word_markup) + verbose_word, verbose_markup = rep._get_verbose_word_with_markup( + config, word_markup + ) + word = tw.markup(verbose_word, **verbose_markup) node = _get_node_id_with_markup(tw, config, rep) line = f"{word} {node}" @@ -1373,11 +1510,13 @@ def _get_line_with_reprcrash_message( except AttributeError: pass else: - if not running_on_ci(): + if ( + running_on_ci() or config.option.verbose >= 2 + ) and not config.option.force_short_summary: + msg = f" - {msg}" + else: available_width = tw.fullwidth - line_width msg = _format_trimmed(" - {}", msg, available_width) - else: - msg = f" - {msg}" if msg is not None: line += msg @@ -1387,8 +1526,8 @@ def _get_line_with_reprcrash_message( def _folded_skips( startpath: Path, skipped: Sequence[CollectReport], -) -> List[Tuple[int, str, Optional[int], str]]: - d: Dict[Tuple[str, Optional[int], str], List[CollectReport]] = {} +) -> list[tuple[int, str, int | None, str]]: + d: dict[tuple[str, int | None, str], list[CollectReport]] = {} for event in skipped: assert event.longrepr is not None assert isinstance(event.longrepr, tuple), (event, event.longrepr) @@ -1405,11 +1544,11 @@ def _folded_skips( and "skip" in keywords and "pytestmark" not in keywords ): - key: Tuple[str, Optional[int], str] = (fspath, None, reason) + key: tuple[str, int | None, str] = (fspath, None, reason) else: key = (fspath, lineno, reason) d.setdefault(key, []).append(event) - values: List[Tuple[int, str, Optional[int], str]] = [] + values: list[tuple[int, str, int | None, str]] = [] for key, events in d.items(): values.append((len(events), *key)) return values @@ -1424,7 +1563,7 @@ def _folded_skips( _color_for_type_default = "yellow" -def pluralize(count: int, noun: str) -> Tuple[int, str]: +def pluralize(count: int, noun: str) -> tuple[int, str]: # No need to pluralize words such as `failed` or `passed`. if noun not in ["error", "warnings", "test"]: return count, noun @@ -1437,11 +1576,11 @@ def pluralize(count: int, noun: str) -> Tuple[int, str]: return count, noun + "s" if count != 1 else noun -def _plugin_nameversions(plugininfo) -> List[str]: - values: List[str] = [] +def _plugin_nameversions(plugininfo) -> list[str]: + values: list[str] = [] for plugin, dist in plugininfo: # Gets us name and version! - name = "{dist.project_name}-{dist.version}".format(dist=dist) + name = f"{dist.project_name}-{dist.version}" # Questionable convenience, but it keeps things short. if name.startswith("pytest-"): name = name[7:] @@ -1460,13 +1599,36 @@ def format_session_duration(seconds: float) -> str: return f"{seconds:.2f}s ({dt})" +def format_node_duration(seconds: float) -> str: + """Format the given seconds in a human readable manner to show in the test progress.""" + # The formatting is designed to be compact and readable, with at most 7 characters + # for durations below 100 hours. + if seconds < 0.00001: + return f" {seconds * 1000000:.3f}us" + if seconds < 0.0001: + return f" {seconds * 1000000:.2f}us" + if seconds < 0.001: + return f" {seconds * 1000000:.1f}us" + if seconds < 0.01: + return f" {seconds * 1000:.3f}ms" + if seconds < 0.1: + return f" {seconds * 1000:.2f}ms" + if seconds < 1: + return f" {seconds * 1000:.1f}ms" + if seconds < 60: + return f" {seconds:.3f}s" + if seconds < 3600: + return f" {seconds // 60:.0f}m {seconds % 60:.0f}s" + return f" {seconds // 3600:.0f}h {(seconds % 3600) // 60:.0f}m" + + def _get_raw_skip_reason(report: TestReport) -> str: """Get the reason string of a skip/xfail/xpass test report. The string is just the part given by the user. """ if hasattr(report, "wasxfail"): - reason = cast(str, report.wasxfail) + reason = report.wasxfail if reason.startswith("reason: "): reason = reason[len("reason: ") :] return reason diff --git a/src/_pytest/threadexception.py b/src/_pytest/threadexception.py index 43341e739a0..eb57783be26 100644 --- a/src/_pytest/threadexception.py +++ b/src/_pytest/threadexception.py @@ -1,88 +1,152 @@ +from __future__ import annotations + +import collections +from collections.abc import Callable +import functools +import sys import threading import traceback +from typing import NamedTuple +from typing import TYPE_CHECKING import warnings -from types import TracebackType -from typing import Any -from typing import Callable -from typing import Generator -from typing import Optional -from typing import Type +from _pytest.config import Config +from _pytest.nodes import Item +from _pytest.stash import StashKey +from _pytest.tracemalloc import tracemalloc_message import pytest -# Copied from cpython/Lib/test/support/threading_helper.py, with modifications. -class catch_threading_exception: - """Context manager catching threading.Thread exception using - threading.excepthook. - - Storing exc_value using a custom hook can create a reference cycle. The - reference cycle is broken explicitly when the context manager exits. - - Storing thread using a custom hook can resurrect it if it is set to an - object which is being finalized. Exiting the context manager clears the - stored object. - - Usage: - with threading_helper.catch_threading_exception() as cm: - # code spawning a thread which raises an exception - ... - # check the thread exception: use cm.args - ... - # cm.args attribute no longer exists at this point - # (to break a reference cycle) - """ - - def __init__(self) -> None: - self.args: Optional["threading.ExceptHookArgs"] = None - self._old_hook: Optional[Callable[["threading.ExceptHookArgs"], Any]] = None - - def _hook(self, args: "threading.ExceptHookArgs") -> None: - self.args = args - - def __enter__(self) -> "catch_threading_exception": - self._old_hook = threading.excepthook - threading.excepthook = self._hook - return self - - def __exit__( - self, - exc_type: Optional[Type[BaseException]], - exc_val: Optional[BaseException], - exc_tb: Optional[TracebackType], - ) -> None: - assert self._old_hook is not None - threading.excepthook = self._old_hook - self._old_hook = None - del self.args - - -def thread_exception_runtest_hook() -> Generator[None, None, None]: - with catch_threading_exception() as cm: - yield - if cm.args: - thread_name = "" if cm.args.thread is None else cm.args.thread.name - msg = f"Exception in thread {thread_name}\n\n" - msg += "".join( - traceback.format_exception( - cm.args.exc_type, - cm.args.exc_value, - cm.args.exc_traceback, - ) +if TYPE_CHECKING: + pass + +if sys.version_info < (3, 11): + from exceptiongroup import ExceptionGroup + + +class ThreadExceptionMeta(NamedTuple): + msg: str + cause_msg: str + exc_value: BaseException | None + + +thread_exceptions: StashKey[collections.deque[ThreadExceptionMeta | BaseException]] = ( + StashKey() +) + + +def collect_thread_exception(config: Config) -> None: + pop_thread_exception = config.stash[thread_exceptions].pop + errors: list[pytest.PytestUnhandledThreadExceptionWarning | RuntimeError] = [] + meta = None + hook_error = None + try: + while True: + try: + meta = pop_thread_exception() + except IndexError: + break + + if isinstance(meta, BaseException): + hook_error = RuntimeError("Failed to process thread exception") + hook_error.__cause__ = meta + errors.append(hook_error) + continue + + msg = meta.msg + try: + warnings.warn(pytest.PytestUnhandledThreadExceptionWarning(msg)) + except pytest.PytestUnhandledThreadExceptionWarning as e: + # This except happens when the warning is treated as an error (e.g. `-Werror`). + if meta.exc_value is not None: + # Exceptions have a better way to show the traceback, but + # warnings do not, so hide the traceback from the msg and + # set the cause so the traceback shows up in the right place. + e.args = (meta.cause_msg,) + e.__cause__ = meta.exc_value + errors.append(e) + + if len(errors) == 1: + raise errors[0] + if errors: + raise ExceptionGroup("multiple thread exception warnings", errors) + finally: + del errors, meta, hook_error + + +def cleanup( + *, config: Config, prev_hook: Callable[[threading.ExceptHookArgs], object] +) -> None: + try: + try: + # We don't join threads here, so exceptions raised from any + # threads still running by the time _threading_atexits joins them + # do not get captured (see #13027). + collect_thread_exception(config) + finally: + threading.excepthook = prev_hook + finally: + del config.stash[thread_exceptions] + + +def thread_exception_hook( + args: threading.ExceptHookArgs, + /, + *, + append: Callable[[ThreadExceptionMeta | BaseException], object], +) -> None: + try: + # we need to compute these strings here as they might change after + # the excepthook finishes and before the metadata object is + # collected by a pytest hook + thread_name = "" if args.thread is None else args.thread.name + summary = f"Exception in thread {thread_name}" + traceback_message = "\n\n" + "".join( + traceback.format_exception( + args.exc_type, + args.exc_value, + args.exc_traceback, + ) + ) + tracemalloc_tb = "\n" + tracemalloc_message(args.thread) + msg = summary + traceback_message + tracemalloc_tb + cause_msg = summary + tracemalloc_tb + + append( + ThreadExceptionMeta( + # Compute these strings here as they might change later + msg=msg, + cause_msg=cause_msg, + exc_value=args.exc_value, ) - warnings.warn(pytest.PytestUnhandledThreadExceptionWarning(msg)) + ) + except BaseException as e: + append(e) + # Raising this will cause the exception to be logged twice, once in our + # collect_thread_exception and once by sys.excepthook + # which is fine - this should never happen anyway and if it does + # it should probably be reported as a pytest bug. + raise + + +def pytest_configure(config: Config) -> None: + prev_hook = threading.excepthook + deque: collections.deque[ThreadExceptionMeta | BaseException] = collections.deque() + config.stash[thread_exceptions] = deque + config.add_cleanup(functools.partial(cleanup, config=config, prev_hook=prev_hook)) + threading.excepthook = functools.partial(thread_exception_hook, append=deque.append) -@pytest.hookimpl(hookwrapper=True, trylast=True) -def pytest_runtest_setup() -> Generator[None, None, None]: - yield from thread_exception_runtest_hook() +@pytest.hookimpl(trylast=True) +def pytest_runtest_setup(item: Item) -> None: + collect_thread_exception(item.config) -@pytest.hookimpl(hookwrapper=True, tryfirst=True) -def pytest_runtest_call() -> Generator[None, None, None]: - yield from thread_exception_runtest_hook() +@pytest.hookimpl(trylast=True) +def pytest_runtest_call(item: Item) -> None: + collect_thread_exception(item.config) -@pytest.hookimpl(hookwrapper=True, tryfirst=True) -def pytest_runtest_teardown() -> Generator[None, None, None]: - yield from thread_exception_runtest_hook() +@pytest.hookimpl(trylast=True) +def pytest_runtest_teardown(item: Item) -> None: + collect_thread_exception(item.config) diff --git a/src/_pytest/timing.py b/src/_pytest/timing.py index 925163a5858..221eeffc4fd 100644 --- a/src/_pytest/timing.py +++ b/src/_pytest/timing.py @@ -5,8 +5,90 @@ Fixture "mock_timing" also interacts with this module for pytest's own tests. """ + +from __future__ import annotations + +import dataclasses +from datetime import datetime +from datetime import timezone from time import perf_counter from time import sleep from time import time +from typing import TYPE_CHECKING + + +if TYPE_CHECKING: + from pytest import MonkeyPatch + + +@dataclasses.dataclass(frozen=True) +class Instant: + """ + Represents an instant in time, used to both get the timestamp value and to measure + the duration of a time span. + + Inspired by Rust's `std::time::Instant`. + """ + + # Creation time of this instant, using time.time(), to measure actual time. + # Note: using a `lambda` to correctly get the mocked time via `MockTiming`. + time: float = dataclasses.field(default_factory=lambda: time(), init=False) + + # Performance counter tick of the instant, used to measure precise elapsed time. + # Note: using a `lambda` to correctly get the mocked time via `MockTiming`. + perf_count: float = dataclasses.field( + default_factory=lambda: perf_counter(), init=False + ) + + def elapsed(self) -> Duration: + """Measure the duration since `Instant` was created.""" + return Duration(start=self, stop=Instant()) + + def as_utc(self) -> datetime: + """Instant as UTC datetime.""" + return datetime.fromtimestamp(self.time, timezone.utc) + + +@dataclasses.dataclass(frozen=True) +class Duration: + """A span of time as measured by `Instant.elapsed()`.""" + + start: Instant + stop: Instant + + @property + def seconds(self) -> float: + """Elapsed time of the duration in seconds, measured using a performance counter for precise timing.""" + return self.stop.perf_count - self.start.perf_count + + +@dataclasses.dataclass +class MockTiming: + """Mocks _pytest.timing with a known object that can be used to control timing in tests + deterministically. + + pytest itself should always use functions from `_pytest.timing` instead of `time` directly. + + This then allows us more control over time during testing, if testing code also + uses `_pytest.timing` functions. + + Time is static, and only advances through `sleep` calls, thus tests might sleep over large + numbers and obtain accurate time() calls at the end, making tests reliable and instant.""" + + _current_time: float = datetime(2020, 5, 22, 14, 20, 50).timestamp() + + def sleep(self, seconds: float) -> None: + self._current_time += seconds + + def time(self) -> float: + return self._current_time + + def patch(self, monkeypatch: MonkeyPatch) -> None: + from _pytest import timing # noqa: PLW0406 + + monkeypatch.setattr(timing, "sleep", self.sleep) + monkeypatch.setattr(timing, "time", self.time) + monkeypatch.setattr(timing, "perf_counter", self.time) + __all__ = ["perf_counter", "sleep", "time"] diff --git a/src/_pytest/tmpdir.py b/src/_pytest/tmpdir.py index 3cc2bace55b..dcd5784f88f 100644 --- a/src/_pytest/tmpdir.py +++ b/src/_pytest/tmpdir.py @@ -1,68 +1,63 @@ +# mypy: allow-untyped-defs """Support for providing temporary directories to test functions.""" + +from __future__ import annotations + +from collections.abc import Generator import dataclasses import os -import re -import tempfile from pathlib import Path +import re from shutil import rmtree +import tempfile from typing import Any -from typing import Dict -from typing import Generator -from typing import Optional -from typing import TYPE_CHECKING -from typing import Union - -from _pytest.nodes import Item -from _pytest.reports import CollectReport -from _pytest.stash import StashKey - -if TYPE_CHECKING: - from typing_extensions import Literal - - RetentionType = Literal["all", "failed", "none"] - - -from _pytest.config.argparsing import Parser +from typing import final +from typing import Literal +from .pathlib import cleanup_dead_symlinks from .pathlib import LOCK_TIMEOUT from .pathlib import make_numbered_dir from .pathlib import make_numbered_dir_with_cleanup from .pathlib import rm_rf -from .pathlib import cleanup_dead_symlinks -from _pytest.compat import final, get_user_id +from _pytest.compat import get_user_id from _pytest.config import Config from _pytest.config import ExitCode from _pytest.config import hookimpl +from _pytest.config.argparsing import Parser from _pytest.deprecated import check_ispytest from _pytest.fixtures import fixture from _pytest.fixtures import FixtureRequest from _pytest.monkeypatch import MonkeyPatch +from _pytest.nodes import Item +from _pytest.reports import TestReport +from _pytest.stash import StashKey + -tmppath_result_key = StashKey[Dict[str, bool]]() +tmppath_result_key = StashKey[dict[str, bool]]() +RetentionType = Literal["all", "failed", "none"] @final @dataclasses.dataclass class TempPathFactory: - """Factory for temporary directories under the common base temp directory. - - The base directory can be configured using the ``--basetemp`` option. + """Factory for temporary directories under the common base temp directory, + as discussed at :ref:`temporary directory location and retention`. """ - _given_basetemp: Optional[Path] + _given_basetemp: Path | None # pluggy TagTracerSub, not currently exposed, so Any. _trace: Any - _basetemp: Optional[Path] + _basetemp: Path | None _retention_count: int - _retention_policy: "RetentionType" + _retention_policy: RetentionType def __init__( self, - given_basetemp: Optional[Path], + given_basetemp: Path | None, retention_count: int, - retention_policy: "RetentionType", + retention_policy: RetentionType, trace, - basetemp: Optional[Path] = None, + basetemp: Path | None = None, *, _ispytest: bool = False, ) -> None: @@ -85,7 +80,7 @@ def from_config( config: Config, *, _ispytest: bool = False, - ) -> "TempPathFactory": + ) -> TempPathFactory: """Create a factory according to pytest configuration. :meta private: @@ -201,7 +196,7 @@ def getbasetemp(self) -> Path: return basetemp -def get_user() -> Optional[str]: +def get_user() -> str | None: """Return the current user name, or None if getuser() does not work in the current environment (see #1010).""" try: @@ -209,7 +204,7 @@ def get_user() -> Optional[str]: import getpass return getpass.getuser() - except (ImportError, KeyError): + except (ImportError, OSError, KeyError): return None @@ -259,26 +254,17 @@ def _mk_tmp(request: FixtureRequest, factory: TempPathFactory) -> Path: @fixture def tmp_path( request: FixtureRequest, tmp_path_factory: TempPathFactory -) -> Generator[Path, None, None]: - """Return a temporary directory path object which is unique to each test - function invocation, created as a sub directory of the base temporary - directory. - - By default, a new base temporary directory is created each test session, - and old bases are removed after 3 sessions, to aid in debugging. - This behavior can be configured with :confval:`tmp_path_retention_count` and - :confval:`tmp_path_retention_policy`. - If ``--basetemp`` is used then it is cleared each session. See :ref:`base - temporary directory`. - - The returned object is a :class:`pathlib.Path` object. +) -> Generator[Path]: + """Return a temporary directory (as :class:`pathlib.Path` object) + which is unique to each test function invocation. + The temporary directory is created as a subdirectory + of the base temporary directory, with configurable retention, + as discussed in :ref:`temporary directory location and retention`. """ - path = _mk_tmp(request, tmp_path_factory) yield path # Remove the tmpdir if the policy is "failed" and the test passed. - tmp_path_factory: TempPathFactory = request.session.config._tmp_path_factory # type: ignore policy = tmp_path_factory._retention_policy result_dict = request.node.stash[tmppath_result_key] @@ -290,7 +276,7 @@ def tmp_path( del request.node.stash[tmppath_result_key] -def pytest_sessionfinish(session, exitstatus: Union[int, ExitCode]): +def pytest_sessionfinish(session, exitstatus: int | ExitCode): """After each session, remove base directory if all the tests passed, the policy is "failed", and the basetemp is not specified by a user. """ @@ -315,10 +301,12 @@ def pytest_sessionfinish(session, exitstatus: Union[int, ExitCode]): cleanup_dead_symlinks(basetemp) -@hookimpl(tryfirst=True, hookwrapper=True) -def pytest_runtest_makereport(item: Item, call): - outcome = yield - result: CollectReport = outcome.get_result() - - empty: Dict[str, bool] = {} - item.stash.setdefault(tmppath_result_key, empty)[result.when] = result.passed +@hookimpl(wrapper=True, tryfirst=True) +def pytest_runtest_makereport( + item: Item, call +) -> Generator[None, TestReport, TestReport]: + rep = yield + assert rep.when is not None + empty: dict[str, bool] = {} + item.stash.setdefault(tmppath_result_key, empty)[rep.when] = rep.passed + return rep diff --git a/src/_pytest/tracemalloc.py b/src/_pytest/tracemalloc.py new file mode 100644 index 00000000000..5d0b19855c7 --- /dev/null +++ b/src/_pytest/tracemalloc.py @@ -0,0 +1,24 @@ +from __future__ import annotations + + +def tracemalloc_message(source: object) -> str: + if source is None: + return "" + + try: + import tracemalloc + except ImportError: + return "" + + tb = tracemalloc.get_object_traceback(source) + if tb is not None: + formatted_tb = "\n".join(tb.format()) + # Use a leading new line to better separate the (large) output + # from the traceback to the previous warning text. + return f"\nObject allocated at:\n{formatted_tb}" + # No need for a leading new line. + url = "https://docs.pytest.org/en/stable/how-to/capture-warnings.html#resource-warnings" + return ( + "Enable tracemalloc to get traceback where the object was allocated.\n" + f"See {url} for more info." + ) diff --git a/src/_pytest/unittest.py b/src/_pytest/unittest.py index d42a12a3a9e..ef6ef64a062 100644 --- a/src/_pytest/unittest.py +++ b/src/_pytest/unittest.py @@ -1,24 +1,26 @@ +# mypy: allow-untyped-defs """Discover and run std-library "unittest" style tests.""" + +from __future__ import annotations + +from collections.abc import Callable +from collections.abc import Generator +from collections.abc import Iterable +from collections.abc import Iterator +from enum import auto +from enum import Enum +import inspect import sys import traceback import types -from typing import Any -from typing import Callable -from typing import Generator -from typing import Iterable -from typing import List -from typing import Optional -from typing import Tuple -from typing import Type from typing import TYPE_CHECKING from typing import Union import _pytest._code -import pytest -from _pytest.compat import getimfunc from _pytest.compat import is_async_function from _pytest.config import hookimpl from _pytest.fixtures import FixtureRequest +from _pytest.monkeypatch import MonkeyPatch from _pytest.nodes import Collector from _pytest.nodes import Item from _pytest.outcomes import exit @@ -29,32 +31,42 @@ from _pytest.python import Function from _pytest.python import Module from _pytest.runner import CallInfo -from _pytest.scope import Scope +import pytest + + +if sys.version_info[:2] < (3, 11): + from exceptiongroup import ExceptionGroup if TYPE_CHECKING: import unittest + import twisted.trial.unittest - _SysExcInfoType = Union[ - Tuple[Type[BaseException], BaseException, types.TracebackType], - Tuple[None, None, None], - ] + +_SysExcInfoType = Union[ + tuple[type[BaseException], BaseException, types.TracebackType], + tuple[None, None, None], +] def pytest_pycollect_makeitem( - collector: Union[Module, Class], name: str, obj: object -) -> Optional["UnitTestCase"]: - # Has unittest been imported and is obj a subclass of its TestCase? + collector: Module | Class, name: str, obj: object +) -> UnitTestCase | None: try: + # Has unittest been imported? ut = sys.modules["unittest"] + # Is obj a subclass of unittest.TestCase? # Type ignored because `ut` is an opaque module. if not issubclass(obj, ut.TestCase): # type: ignore return None except Exception: return None + # Is obj a concrete class? + # Abstract classes can't be instantiated so no point collecting them. + if inspect.isabstract(obj): + return None # Yes, so let's collect it. - item: UnitTestCase = UnitTestCase.from_parent(collector, name=name, obj=obj) - return item + return UnitTestCase.from_parent(collector, name=name, obj=obj) class UnitTestCase(Class): @@ -62,7 +74,15 @@ class UnitTestCase(Class): # to declare that our children do not support funcargs. nofuncargs = True - def collect(self) -> Iterable[Union[Item, Collector]]: + def newinstance(self): + # TestCase __init__ takes the method (test) name. The TestCase + # constructor treats the name "runTest" as a special no-op, so it can be + # used when a dummy instance is needed. While unittest.TestCase has a + # default, some subclasses omit the default (#9610), so always supply + # it. + return self.obj("runTest") + + def collect(self) -> Iterable[Item | Collector]: from unittest import TestLoader cls = self.obj @@ -71,157 +91,155 @@ def collect(self) -> Iterable[Union[Item, Collector]]: skipped = _is_skipped(cls) if not skipped: - self._inject_setup_teardown_fixtures(cls) - self._inject_setup_class_fixture() + self._register_unittest_setup_method_fixture(cls) + self._register_unittest_setup_class_fixture(cls) + self._register_setup_class_fixture() + + self.session._fixturemanager.parsefactories(self.newinstance(), self.nodeid) - self.session._fixturemanager.parsefactories(self, unittest=True) loader = TestLoader() foundsomething = False for name in loader.getTestCaseNames(self.obj): x = getattr(self.obj, name) if not getattr(x, "__test__", True): continue - funcobj = getimfunc(x) - yield TestCaseFunction.from_parent(self, name=name, callobj=funcobj) + yield TestCaseFunction.from_parent(self, name=name) foundsomething = True if not foundsomething: runtest = getattr(self.obj, "runTest", None) if runtest is not None: ut = sys.modules.get("twisted.trial.unittest", None) - # Type ignored because `ut` is an opaque module. - if ut is None or runtest != ut.TestCase.runTest: # type: ignore + if ut is None or runtest != ut.TestCase.runTest: yield TestCaseFunction.from_parent(self, name="runTest") - def _inject_setup_teardown_fixtures(self, cls: type) -> None: - """Injects a hidden auto-use fixture to invoke setUpClass/setup_method and corresponding - teardown functions (#517).""" - class_fixture = _make_xunit_fixture( - cls, - "setUpClass", - "tearDownClass", - "doClassCleanups", - scope=Scope.Class, - pass_self=False, - ) - if class_fixture: - cls.__pytest_class_setup = class_fixture # type: ignore[attr-defined] - - method_fixture = _make_xunit_fixture( - cls, - "setup_method", - "teardown_method", - None, - scope=Scope.Function, - pass_self=True, - ) - if method_fixture: - cls.__pytest_method_setup = method_fixture # type: ignore[attr-defined] - - -def _make_xunit_fixture( - obj: type, - setup_name: str, - teardown_name: str, - cleanup_name: Optional[str], - scope: Scope, - pass_self: bool, -): - setup = getattr(obj, setup_name, None) - teardown = getattr(obj, teardown_name, None) - if setup is None and teardown is None: - return None - - if cleanup_name: - cleanup = getattr(obj, cleanup_name, lambda *args: None) - else: - - def cleanup(*args): - pass - - @pytest.fixture( - scope=scope.value, - autouse=True, - # Use a unique name to speed up lookup. - name=f"_unittest_{setup_name}_fixture_{obj.__qualname__}", - ) - def fixture(self, request: FixtureRequest) -> Generator[None, None, None]: - if _is_skipped(self): - reason = self.__unittest_skip_why__ - raise pytest.skip.Exception(reason, _use_item_location=True) - if setup is not None: - try: - if pass_self: - setup(self, request.function) - else: + def _register_unittest_setup_class_fixture(self, cls: type) -> None: + """Register an auto-use fixture to invoke setUpClass and + tearDownClass (#517).""" + setup = getattr(cls, "setUpClass", None) + teardown = getattr(cls, "tearDownClass", None) + if setup is None and teardown is None: + return None + cleanup = getattr(cls, "doClassCleanups", lambda: None) + + def process_teardown_exceptions() -> None: + # tearDown_exceptions is a list set in the class containing exc_infos for errors during + # teardown for the class. + exc_infos = getattr(cls, "tearDown_exceptions", None) + if not exc_infos: + return + exceptions = [exc for (_, exc, _) in exc_infos] + # If a single exception, raise it directly as this provides a more readable + # error (hopefully this will improve in #12255). + if len(exceptions) == 1: + raise exceptions[0] + else: + raise ExceptionGroup("Unittest class cleanup errors", exceptions) + + def unittest_setup_class_fixture( + request: FixtureRequest, + ) -> Generator[None]: + cls = request.cls + if _is_skipped(cls): + reason = cls.__unittest_skip_why__ + raise pytest.skip.Exception(reason, _use_item_location=True) + if setup is not None: + try: setup() - # unittest does not call the cleanup function for every BaseException, so we - # follow this here. - except Exception: - if pass_self: - cleanup(self) - else: + # unittest does not call the cleanup function for every BaseException, so we + # follow this here. + except Exception: cleanup() - - raise - yield - try: - if teardown is not None: - if pass_self: - teardown(self, request.function) - else: + process_teardown_exceptions() + raise + yield + try: + if teardown is not None: teardown() - finally: - if pass_self: - cleanup(self) - else: + finally: cleanup() + process_teardown_exceptions() + + self.session._fixturemanager._register_fixture( + # Use a unique name to speed up lookup. + name=f"_unittest_setUpClass_fixture_{cls.__qualname__}", + func=unittest_setup_class_fixture, + nodeid=self.nodeid, + scope="class", + autouse=True, + ) - return fixture + def _register_unittest_setup_method_fixture(self, cls: type) -> None: + """Register an auto-use fixture to invoke setup_method and + teardown_method (#517).""" + setup = getattr(cls, "setup_method", None) + teardown = getattr(cls, "teardown_method", None) + if setup is None and teardown is None: + return None + + def unittest_setup_method_fixture( + request: FixtureRequest, + ) -> Generator[None]: + self = request.instance + if _is_skipped(self): + reason = self.__unittest_skip_why__ + raise pytest.skip.Exception(reason, _use_item_location=True) + if setup is not None: + setup(self, request.function) + yield + if teardown is not None: + teardown(self, request.function) + + self.session._fixturemanager._register_fixture( + # Use a unique name to speed up lookup. + name=f"_unittest_setup_method_fixture_{cls.__qualname__}", + func=unittest_setup_method_fixture, + nodeid=self.nodeid, + scope="function", + autouse=True, + ) class TestCaseFunction(Function): nofuncargs = True - _excinfo: Optional[List[_pytest._code.ExceptionInfo[BaseException]]] = None - _testcase: Optional["unittest.TestCase"] = None - - def _getobj(self): - assert self.parent is not None - # Unlike a regular Function in a Class, where `item.obj` returns - # a *bound* method (attached to an instance), TestCaseFunction's - # `obj` returns an *unbound* method (not attached to an instance). - # This inconsistency is probably not desirable, but needs some - # consideration before changing. - return getattr(self.parent.obj, self.originalname) # type: ignore[attr-defined] + _excinfo: list[_pytest._code.ExceptionInfo[BaseException]] | None = None + + def _getinstance(self): + assert isinstance(self.parent, UnitTestCase) + return self.parent.obj(self.name) + + # Backward compat for pytest-django; can be removed after pytest-django + # updates + some slack. + @property + def _testcase(self): + return self.instance def setup(self) -> None: # A bound method to be called during teardown() if set (see 'runtest()'). - self._explicit_tearDown: Optional[Callable[[], None]] = None - assert self.parent is not None - self._testcase = self.parent.obj(self.name) # type: ignore[attr-defined] - self._obj = getattr(self._testcase, self.name) - if hasattr(self, "_request"): - self._request._fillfixtures() + self._explicit_tearDown: Callable[[], None] | None = None + super().setup() def teardown(self) -> None: if self._explicit_tearDown is not None: self._explicit_tearDown() self._explicit_tearDown = None - self._testcase = None self._obj = None + del self._instance + super().teardown() - def startTest(self, testcase: "unittest.TestCase") -> None: + def startTest(self, testcase: unittest.TestCase) -> None: pass - def _addexcinfo(self, rawexcinfo: "_SysExcInfoType") -> None: - # Unwrap potential exception info (see twisted trial support below). - rawexcinfo = getattr(rawexcinfo, "_rawexcinfo", rawexcinfo) + def _addexcinfo(self, rawexcinfo: _SysExcInfoType) -> None: + rawexcinfo = _handle_twisted_exc_info(rawexcinfo) try: - excinfo = _pytest._code.ExceptionInfo[BaseException].from_exc_info(rawexcinfo) # type: ignore[arg-type] + excinfo = _pytest._code.ExceptionInfo[BaseException].from_exc_info( + rawexcinfo # type: ignore[arg-type] + ) # Invoke the attributes to trigger storing the traceback # trial causes some issue there. - excinfo.value - excinfo.traceback + _ = excinfo.value + _ = excinfo.traceback except TypeError: try: try: @@ -237,7 +255,7 @@ def _addexcinfo(self, rawexcinfo: "_SysExcInfoType") -> None: except BaseException: fail( "ERROR: Unknown Incompatible Exception " - "representation:\n%r" % (rawexcinfo,), + f"representation:\n{rawexcinfo!r}", pytrace=False, ) except KeyboardInterrupt: @@ -247,7 +265,7 @@ def _addexcinfo(self, rawexcinfo: "_SysExcInfoType") -> None: self.__dict__.setdefault("_excinfo", []).append(excinfo) def addError( - self, testcase: "unittest.TestCase", rawexcinfo: "_SysExcInfoType" + self, testcase: unittest.TestCase, rawexcinfo: _SysExcInfoType ) -> None: try: if isinstance(rawexcinfo[1], exit.Exception): @@ -257,11 +275,11 @@ def addError( self._addexcinfo(rawexcinfo) def addFailure( - self, testcase: "unittest.TestCase", rawexcinfo: "_SysExcInfoType" + self, testcase: unittest.TestCase, rawexcinfo: _SysExcInfoType ) -> None: self._addexcinfo(rawexcinfo) - def addSkip(self, testcase: "unittest.TestCase", reason: str) -> None: + def addSkip(self, testcase: unittest.TestCase, reason: str) -> None: try: raise pytest.skip.Exception(reason, _use_item_location=True) except skip.Exception: @@ -269,8 +287,8 @@ def addSkip(self, testcase: "unittest.TestCase", reason: str) -> None: def addExpectedFailure( self, - testcase: "unittest.TestCase", - rawexcinfo: "_SysExcInfoType", + testcase: unittest.TestCase, + rawexcinfo: _SysExcInfoType, reason: str = "", ) -> None: try: @@ -280,8 +298,8 @@ def addExpectedFailure( def addUnexpectedSuccess( self, - testcase: "unittest.TestCase", - reason: Optional["twisted.trial.unittest.Todo"] = None, + testcase: unittest.TestCase, + reason: twisted.trial.unittest.Todo | None = None, ) -> None: msg = "Unexpected success" if reason: @@ -292,26 +310,26 @@ def addUnexpectedSuccess( except fail.Exception: self._addexcinfo(sys.exc_info()) - def addSuccess(self, testcase: "unittest.TestCase") -> None: + def addSuccess(self, testcase: unittest.TestCase) -> None: pass - def stopTest(self, testcase: "unittest.TestCase") -> None: + def stopTest(self, testcase: unittest.TestCase) -> None: pass - def addDuration(self, testcase: "unittest.TestCase", elapsed: float) -> None: + def addDuration(self, testcase: unittest.TestCase, elapsed: float) -> None: pass def runtest(self) -> None: from _pytest.debugging import maybe_wrap_pytest_function_for_tracing - assert self._testcase is not None + testcase = self.instance + assert testcase is not None maybe_wrap_pytest_function_for_tracing(self) # Let the unittest framework handle async functions. if is_async_function(self.obj): - # Type ignored because self acts as the TestResult, but is not actually one. - self._testcase(result=self) # type: ignore[arg-type] + testcase(result=self) else: # When --pdb is given, we want to postpone calling tearDown() otherwise # when entering the pdb prompt, tearDown() would have probably cleaned up @@ -323,16 +341,16 @@ def runtest(self) -> None: assert isinstance(self.parent, UnitTestCase) skipped = _is_skipped(self.obj) or _is_skipped(self.parent.obj) if self.config.getoption("usepdb") and not skipped: - self._explicit_tearDown = self._testcase.tearDown - setattr(self._testcase, "tearDown", lambda *args: None) + self._explicit_tearDown = testcase.tearDown + setattr(testcase, "tearDown", lambda *args: None) # We need to update the actual bound method with self.obj, because # wrap_pytest_function_for_tracing replaces self.obj by a wrapper. - setattr(self._testcase, self.name, self.obj) + setattr(testcase, self.name, self.obj) try: - self._testcase(result=self) # type: ignore[arg-type] + testcase(result=self) finally: - delattr(self._testcase, self.name) + delattr(testcase, self.name) def _traceback_filter( self, excinfo: _pytest._code.ExceptionInfo[BaseException] @@ -361,11 +379,7 @@ def pytest_runtest_makereport(item: Item, call: CallInfo[None]) -> None: # its own nose.SkipTest. For unittest TestCases, SkipTest is already # handled internally, and doesn't reach here. unittest = sys.modules.get("unittest") - if ( - unittest - and call.excinfo - and isinstance(call.excinfo.value, unittest.SkipTest) # type: ignore[attr-defined] - ): + if unittest and call.excinfo and isinstance(call.excinfo.value, unittest.SkipTest): excinfo = call.excinfo call2 = CallInfo[None].from_call( lambda: pytest.skip(str(excinfo.value)), call.when @@ -373,49 +387,130 @@ def pytest_runtest_makereport(item: Item, call: CallInfo[None]) -> None: call.excinfo = call2.excinfo -# Twisted trial support. +def _is_skipped(obj) -> bool: + """Return True if the given object has been marked with @unittest.skip.""" + return bool(getattr(obj, "__unittest_skip__", False)) + + +def pytest_configure() -> None: + """Register the TestCaseFunction class as an IReporter if twisted.trial is available.""" + if _get_twisted_version() is not TwistedVersion.NotInstalled: + from twisted.trial.itrial import IReporter + from zope.interface import classImplements + + classImplements(TestCaseFunction, IReporter) + + +class TwistedVersion(Enum): + """ + The Twisted version installed in the environment. + + We have different workarounds in place for different versions of Twisted. + """ + + # Twisted version 24 or prior. + Version24 = auto() + # Twisted version 25 or later. + Version25 = auto() + # Twisted version is not available. + NotInstalled = auto() + + +def _get_twisted_version() -> TwistedVersion: + # We need to check if "twisted.trial.unittest" is specifically present in sys.modules. + # This is because we intend to integrate with Trial only when it's actively running + # the test suite, but not needed when only other Twisted components are in use. + if "twisted.trial.unittest" not in sys.modules: + return TwistedVersion.NotInstalled + import importlib.metadata -@hookimpl(hookwrapper=True) -def pytest_runtest_protocol(item: Item) -> Generator[None, None, None]: - if isinstance(item, TestCaseFunction) and "twisted.trial.unittest" in sys.modules: - ut: Any = sys.modules["twisted.python.failure"] - Failure__init__ = ut.Failure.__init__ - check_testcase_implements_trial_reporter() + import packaging.version - def excstore( + version_str = importlib.metadata.version("twisted") + version = packaging.version.parse(version_str) + if version.major <= 24: + return TwistedVersion.Version24 + else: + return TwistedVersion.Version25 + + +# Name of the attribute in `twisted.python.Failure` instances that stores +# the `sys.exc_info()` tuple. +# See twisted.trial support in `pytest_runtest_protocol`. +TWISTED_RAW_EXCINFO_ATTR = "_twisted_raw_excinfo" + + +@hookimpl(wrapper=True) +def pytest_runtest_protocol(item: Item) -> Iterator[None]: + if _get_twisted_version() is TwistedVersion.Version24: + import twisted.python.failure as ut + + # Monkeypatch `Failure.__init__` to store the raw exception info. + original__init__ = ut.Failure.__init__ + + def store_raw_exception_info( self, exc_value=None, exc_type=None, exc_tb=None, captureVars=None - ): + ): # pragma: no cover if exc_value is None: - self._rawexcinfo = sys.exc_info() + raw_exc_info = sys.exc_info() else: if exc_type is None: exc_type = type(exc_value) - self._rawexcinfo = (exc_type, exc_value, exc_tb) + if exc_tb is None: + exc_tb = sys.exc_info()[2] + raw_exc_info = (exc_type, exc_value, exc_tb) + setattr(self, TWISTED_RAW_EXCINFO_ATTR, tuple(raw_exc_info)) try: - Failure__init__( + original__init__( self, exc_value, exc_type, exc_tb, captureVars=captureVars ) - except TypeError: - Failure__init__(self, exc_value, exc_type, exc_tb) + except TypeError: # pragma: no cover + original__init__(self, exc_value, exc_type, exc_tb) - ut.Failure.__init__ = excstore - yield - ut.Failure.__init__ = Failure__init__ + with MonkeyPatch.context() as patcher: + patcher.setattr(ut.Failure, "__init__", store_raw_exception_info) + return (yield) else: - yield - - -def check_testcase_implements_trial_reporter(done: List[int] = []) -> None: - if done: - return - from zope.interface import classImplements - from twisted.trial.itrial import IReporter - - classImplements(TestCaseFunction, IReporter) - done.append(1) - - -def _is_skipped(obj) -> bool: - """Return True if the given object has been marked with @unittest.skip.""" - return bool(getattr(obj, "__unittest_skip__", False)) + return (yield) + + +def _handle_twisted_exc_info( + rawexcinfo: _SysExcInfoType | BaseException, +) -> _SysExcInfoType: + """ + Twisted passes a custom Failure instance to `addError()` instead of using `sys.exc_info()`. + Therefore, if `rawexcinfo` is a `Failure` instance, convert it into the equivalent `sys.exc_info()` tuple + as expected by pytest. + """ + twisted_version = _get_twisted_version() + if twisted_version is TwistedVersion.NotInstalled: + # Unfortunately, because we cannot import `twisted.python.failure` at the top of the file + # and use it in the signature, we need to use `type:ignore` here because we cannot narrow + # the type properly in the `if` statement above. + return rawexcinfo # type:ignore[return-value] + elif twisted_version is TwistedVersion.Version24: + # Twisted calls addError() passing its own classes (like `twisted.python.Failure`), which violates + # the `addError()` signature, so we extract the original `sys.exc_info()` tuple which is stored + # in the object. + if hasattr(rawexcinfo, TWISTED_RAW_EXCINFO_ATTR): + saved_exc_info = getattr(rawexcinfo, TWISTED_RAW_EXCINFO_ATTR) + # Delete the attribute from the original object to avoid leaks. + delattr(rawexcinfo, TWISTED_RAW_EXCINFO_ATTR) + return saved_exc_info # type:ignore[no-any-return] + return rawexcinfo # type:ignore[return-value] + elif twisted_version is TwistedVersion.Version25: + if isinstance(rawexcinfo, BaseException): + import twisted.python.failure + + if isinstance(rawexcinfo, twisted.python.failure.Failure): + tb = rawexcinfo.__traceback__ + if tb is None: + tb = sys.exc_info()[2] + return type(rawexcinfo.value), rawexcinfo.value, tb + + return rawexcinfo # type:ignore[return-value] + else: + # Ideally we would use assert_never() here, but it is not available in all Python versions + # we support, plus we do not require `type_extensions` currently. + assert False, f"Unexpected Twisted version: {twisted_version}" diff --git a/src/_pytest/unraisableexception.py b/src/_pytest/unraisableexception.py index fcb5d8237c1..0faca36aa00 100644 --- a/src/_pytest/unraisableexception.py +++ b/src/_pytest/unraisableexception.py @@ -1,93 +1,163 @@ +from __future__ import annotations + +import collections +from collections.abc import Callable +import functools +import gc import sys import traceback +from typing import NamedTuple +from typing import TYPE_CHECKING import warnings -from types import TracebackType -from typing import Any -from typing import Callable -from typing import Generator -from typing import Optional -from typing import Type +from _pytest.config import Config +from _pytest.nodes import Item +from _pytest.stash import StashKey +from _pytest.tracemalloc import tracemalloc_message import pytest -# Copied from cpython/Lib/test/support/__init__.py, with modifications. -class catch_unraisable_exception: - """Context manager catching unraisable exception using sys.unraisablehook. - - Storing the exception value (cm.unraisable.exc_value) creates a reference - cycle. The reference cycle is broken explicitly when the context manager - exits. - - Storing the object (cm.unraisable.object) can resurrect it if it is set to - an object which is being finalized. Exiting the context manager clears the - stored object. - - Usage: - with catch_unraisable_exception() as cm: - # code creating an "unraisable exception" - ... - # check the unraisable exception: use cm.unraisable - ... - # cm.unraisable attribute no longer exists at this point - # (to break a reference cycle) - """ - - def __init__(self) -> None: - self.unraisable: Optional["sys.UnraisableHookArgs"] = None - self._old_hook: Optional[Callable[["sys.UnraisableHookArgs"], Any]] = None - - def _hook(self, unraisable: "sys.UnraisableHookArgs") -> None: - # Storing unraisable.object can resurrect an object which is being - # finalized. Storing unraisable.exc_value creates a reference cycle. - self.unraisable = unraisable - - def __enter__(self) -> "catch_unraisable_exception": - self._old_hook = sys.unraisablehook - sys.unraisablehook = self._hook - return self - - def __exit__( - self, - exc_type: Optional[Type[BaseException]], - exc_val: Optional[BaseException], - exc_tb: Optional[TracebackType], - ) -> None: - assert self._old_hook is not None - sys.unraisablehook = self._old_hook - self._old_hook = None - del self.unraisable - - -def unraisable_exception_runtest_hook() -> Generator[None, None, None]: - with catch_unraisable_exception() as cm: - yield - if cm.unraisable: - if cm.unraisable.err_msg is not None: - err_msg = cm.unraisable.err_msg - else: - err_msg = "Exception ignored in" - msg = f"{err_msg}: {cm.unraisable.object!r}\n\n" - msg += "".join( - traceback.format_exception( - cm.unraisable.exc_type, - cm.unraisable.exc_value, - cm.unraisable.exc_traceback, - ) +if TYPE_CHECKING: + pass + +if sys.version_info < (3, 11): + from exceptiongroup import ExceptionGroup + + +# This is a stash item and not a simple constant to allow pytester to override it. +gc_collect_iterations_key = StashKey[int]() + + +def gc_collect_harder(iterations: int) -> None: + for _ in range(iterations): + gc.collect() + + +class UnraisableMeta(NamedTuple): + msg: str + cause_msg: str + exc_value: BaseException | None + + +unraisable_exceptions: StashKey[collections.deque[UnraisableMeta | BaseException]] = ( + StashKey() +) + + +def collect_unraisable(config: Config) -> None: + pop_unraisable = config.stash[unraisable_exceptions].pop + errors: list[pytest.PytestUnraisableExceptionWarning | RuntimeError] = [] + meta = None + hook_error = None + try: + while True: + try: + meta = pop_unraisable() + except IndexError: + break + + if isinstance(meta, BaseException): + hook_error = RuntimeError("Failed to process unraisable exception") + hook_error.__cause__ = meta + errors.append(hook_error) + continue + + msg = meta.msg + try: + warnings.warn(pytest.PytestUnraisableExceptionWarning(msg)) + except pytest.PytestUnraisableExceptionWarning as e: + # This except happens when the warning is treated as an error (e.g. `-Werror`). + if meta.exc_value is not None: + # Exceptions have a better way to show the traceback, but + # warnings do not, so hide the traceback from the msg and + # set the cause so the traceback shows up in the right place. + e.args = (meta.cause_msg,) + e.__cause__ = meta.exc_value + errors.append(e) + + if len(errors) == 1: + raise errors[0] + if errors: + raise ExceptionGroup("multiple unraisable exception warnings", errors) + finally: + del errors, meta, hook_error + + +def cleanup( + *, config: Config, prev_hook: Callable[[sys.UnraisableHookArgs], object] +) -> None: + # A single collection doesn't necessarily collect everything. + # Constant determined experimentally by the Trio project. + gc_collect_iterations = config.stash.get(gc_collect_iterations_key, 5) + try: + try: + gc_collect_harder(gc_collect_iterations) + collect_unraisable(config) + finally: + sys.unraisablehook = prev_hook + finally: + del config.stash[unraisable_exceptions] + + +def unraisable_hook( + unraisable: sys.UnraisableHookArgs, + /, + *, + append: Callable[[UnraisableMeta | BaseException], object], +) -> None: + try: + # we need to compute these strings here as they might change after + # the unraisablehook finishes and before the metadata object is + # collected by a pytest hook + err_msg = ( + "Exception ignored in" if unraisable.err_msg is None else unraisable.err_msg + ) + summary = f"{err_msg}: {unraisable.object!r}" + traceback_message = "\n\n" + "".join( + traceback.format_exception( + unraisable.exc_type, + unraisable.exc_value, + unraisable.exc_traceback, + ) + ) + tracemalloc_tb = "\n" + tracemalloc_message(unraisable.object) + msg = summary + traceback_message + tracemalloc_tb + cause_msg = summary + tracemalloc_tb + + append( + UnraisableMeta( + msg=msg, + cause_msg=cause_msg, + exc_value=unraisable.exc_value, ) - warnings.warn(pytest.PytestUnraisableExceptionWarning(msg)) + ) + except BaseException as e: + append(e) + # Raising this will cause the exception to be logged twice, once in our + # collect_unraisable and once by the unraisablehook calling machinery + # which is fine - this should never happen anyway and if it does + # it should probably be reported as a pytest bug. + raise + + +def pytest_configure(config: Config) -> None: + prev_hook = sys.unraisablehook + deque: collections.deque[UnraisableMeta | BaseException] = collections.deque() + config.stash[unraisable_exceptions] = deque + config.add_cleanup(functools.partial(cleanup, config=config, prev_hook=prev_hook)) + sys.unraisablehook = functools.partial(unraisable_hook, append=deque.append) -@pytest.hookimpl(hookwrapper=True, tryfirst=True) -def pytest_runtest_setup() -> Generator[None, None, None]: - yield from unraisable_exception_runtest_hook() +@pytest.hookimpl(trylast=True) +def pytest_runtest_setup(item: Item) -> None: + collect_unraisable(item.config) -@pytest.hookimpl(hookwrapper=True, tryfirst=True) -def pytest_runtest_call() -> Generator[None, None, None]: - yield from unraisable_exception_runtest_hook() +@pytest.hookimpl(trylast=True) +def pytest_runtest_call(item: Item) -> None: + collect_unraisable(item.config) -@pytest.hookimpl(hookwrapper=True, tryfirst=True) -def pytest_runtest_teardown() -> Generator[None, None, None]: - yield from unraisable_exception_runtest_hook() +@pytest.hookimpl(trylast=True) +def pytest_runtest_teardown(item: Item) -> None: + collect_unraisable(item.config) diff --git a/src/_pytest/warning_types.py b/src/_pytest/warning_types.py index bd5f4187343..5e78debb682 100644 --- a/src/_pytest/warning_types.py +++ b/src/_pytest/warning_types.py @@ -1,13 +1,13 @@ +from __future__ import annotations + import dataclasses import inspect -import warnings from types import FunctionType from typing import Any +from typing import final from typing import Generic -from typing import Type from typing import TypeVar - -from _pytest.compat import final +import warnings class PytestWarning(UserWarning): @@ -50,14 +50,8 @@ class PytestDeprecationWarning(PytestWarning, DeprecationWarning): __module__ = "pytest" -class PytestRemovedIn8Warning(PytestDeprecationWarning): - """Warning class for features that will be removed in pytest 8.""" - - __module__ = "pytest" - - -class PytestReturnNotNoneWarning(PytestRemovedIn8Warning): - """Warning emitted when a test function is returning value other than None.""" +class PytestRemovedIn9Warning(PytestDeprecationWarning): + """Warning class for features that will be removed in pytest 9.""" __module__ = "pytest" @@ -73,21 +67,16 @@ class PytestExperimentalApiWarning(PytestWarning, FutureWarning): __module__ = "pytest" @classmethod - def simple(cls, apiname: str) -> "PytestExperimentalApiWarning": - return cls( - "{apiname} is an experimental api that may change over time".format( - apiname=apiname - ) - ) + def simple(cls, apiname: str) -> PytestExperimentalApiWarning: + return cls(f"{apiname} is an experimental api that may change over time") @final -class PytestUnhandledCoroutineWarning(PytestReturnNotNoneWarning): - """Warning emitted for an unhandled coroutine. +class PytestReturnNotNoneWarning(PytestWarning): + """ + Warning emitted when a test function returns a value other than ``None``. - A coroutine was encountered when collecting test functions, but was not - handled by any async-aware plugin. - Coroutine test functions are not natively supported. + See :ref:`return-not-none` for details. """ __module__ = "pytest" @@ -137,7 +126,7 @@ class UnformattedWarning(Generic[_W]): as opposed to a direct message. """ - category: Type["_W"] + category: type[_W] template: str def format(self, **kwargs: Any) -> _W: @@ -145,6 +134,13 @@ def format(self, **kwargs: Any) -> _W: return self.category(self.template.format(**kwargs)) +@final +class PytestFDWarning(PytestWarning): + """When the lsof plugin finds leaked fds.""" + + __module__ = "pytest" + + def warn_explicit_for(method: FunctionType, message: PytestWarning) -> None: """ Issue the warning :param:`message` for the definition of the given :param:`method` diff --git a/src/_pytest/warnings.py b/src/_pytest/warnings.py index 4aaa9445293..806681a5020 100644 --- a/src/_pytest/warnings.py +++ b/src/_pytest/warnings.py @@ -1,37 +1,32 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + +from collections.abc import Generator +from contextlib import contextmanager +from contextlib import ExitStack import sys +from typing import Literal import warnings -from contextlib import contextmanager -from typing import Generator -from typing import Optional -from typing import TYPE_CHECKING -import pytest from _pytest.config import apply_warning_filters from _pytest.config import Config from _pytest.config import parse_warning_filter from _pytest.main import Session from _pytest.nodes import Item from _pytest.terminal import TerminalReporter - -if TYPE_CHECKING: - from typing_extensions import Literal - - -def pytest_configure(config: Config) -> None: - config.addinivalue_line( - "markers", - "filterwarnings(warning): add a warning filter to the given test. " - "see https://docs.pytest.org/en/stable/how-to/capture-warnings.html#pytest-mark-filterwarnings ", - ) +from _pytest.tracemalloc import tracemalloc_message +import pytest @contextmanager def catch_warnings_for_item( config: Config, ihook, - when: "Literal['config', 'collect', 'runtest']", - item: Optional[Item], -) -> Generator[None, None, None]: + when: Literal["config", "collect", "runtest"], + item: Item | None, + *, + record: bool = True, +) -> Generator[None]: """Context manager that catches warnings generated in the contained execution block. ``item`` can be None if we are not in the context of an item execution. @@ -40,15 +35,15 @@ def catch_warnings_for_item( """ config_filters = config.getini("filterwarnings") cmdline_filters = config.known_args_namespace.pythonwarnings or [] - with warnings.catch_warnings(record=True) as log: - # mypy can't infer that record=True means log is not None; help it. - assert log is not None - + with warnings.catch_warnings(record=record) as log: if not sys.warnoptions: # If user is not explicitly configuring warning filters, show deprecation warnings by default (#2908). warnings.filterwarnings("always", category=DeprecationWarning) warnings.filterwarnings("always", category=PendingDeprecationWarning) + # To be enabled in pytest 9.0.0. + # warnings.filterwarnings("error", category=pytest.PytestRemovedIn9Warning) + apply_warning_filters(config_filters, cmdline_filters) # apply filters from "filterwarnings" marks @@ -58,91 +53,100 @@ def catch_warnings_for_item( for arg in mark.args: warnings.filterwarnings(*parse_warning_filter(arg, escape=False)) - yield - - for warning_message in log: - ihook.pytest_warning_recorded.call_historic( - kwargs=dict( - warning_message=warning_message, - nodeid=nodeid, - when=when, - location=None, - ) - ) + try: + yield + finally: + if record: + # mypy can't infer that record=True means log is not None; help it. + assert log is not None + + for warning_message in log: + ihook.pytest_warning_recorded.call_historic( + kwargs=dict( + warning_message=warning_message, + nodeid=nodeid, + when=when, + location=None, + ) + ) def warning_record_to_str(warning_message: warnings.WarningMessage) -> str: """Convert a warnings.WarningMessage to a string.""" - warn_msg = warning_message.message - msg = warnings.formatwarning( - str(warn_msg), + return warnings.formatwarning( + str(warning_message.message), warning_message.category, warning_message.filename, warning_message.lineno, warning_message.line, - ) - if warning_message.source is not None: - try: - import tracemalloc - except ImportError: - pass - else: - tb = tracemalloc.get_object_traceback(warning_message.source) - if tb is not None: - formatted_tb = "\n".join(tb.format()) - # Use a leading new line to better separate the (large) output - # from the traceback to the previous warning text. - msg += f"\nObject allocated at:\n{formatted_tb}" - else: - # No need for a leading new line. - url = "https://docs.pytest.org/en/stable/how-to/capture-warnings.html#resource-warnings" - msg += "Enable tracemalloc to get traceback where the object was allocated.\n" - msg += f"See {url} for more info." - return msg - - -@pytest.hookimpl(hookwrapper=True, tryfirst=True) -def pytest_runtest_protocol(item: Item) -> Generator[None, None, None]: + ) + tracemalloc_message(warning_message.source) + + +@pytest.hookimpl(wrapper=True, tryfirst=True) +def pytest_runtest_protocol(item: Item) -> Generator[None, object, object]: with catch_warnings_for_item( config=item.config, ihook=item.ihook, when="runtest", item=item ): - yield + return (yield) -@pytest.hookimpl(hookwrapper=True, tryfirst=True) -def pytest_collection(session: Session) -> Generator[None, None, None]: +@pytest.hookimpl(wrapper=True, tryfirst=True) +def pytest_collection(session: Session) -> Generator[None, object, object]: config = session.config with catch_warnings_for_item( config=config, ihook=config.hook, when="collect", item=None ): - yield + return (yield) -@pytest.hookimpl(hookwrapper=True) +@pytest.hookimpl(wrapper=True) def pytest_terminal_summary( terminalreporter: TerminalReporter, -) -> Generator[None, None, None]: +) -> Generator[None]: config = terminalreporter.config with catch_warnings_for_item( config=config, ihook=config.hook, when="config", item=None ): - yield + return (yield) -@pytest.hookimpl(hookwrapper=True) -def pytest_sessionfinish(session: Session) -> Generator[None, None, None]: +@pytest.hookimpl(wrapper=True) +def pytest_sessionfinish(session: Session) -> Generator[None]: config = session.config with catch_warnings_for_item( config=config, ihook=config.hook, when="config", item=None ): - yield + return (yield) -@pytest.hookimpl(hookwrapper=True) +@pytest.hookimpl(wrapper=True) def pytest_load_initial_conftests( - early_config: "Config", -) -> Generator[None, None, None]: + early_config: Config, +) -> Generator[None]: with catch_warnings_for_item( config=early_config, ihook=early_config.hook, when="config", item=None ): - yield + return (yield) + + +def pytest_configure(config: Config) -> None: + with ExitStack() as stack: + stack.enter_context( + catch_warnings_for_item( + config=config, + ihook=config.hook, + when="config", + item=None, + # this disables recording because the terminalreporter has + # finished by the time it comes to reporting logged warnings + # from the end of config cleanup. So for now, this is only + # useful for setting a warning filter with an 'error' action. + record=False, + ) + ) + config.addinivalue_line( + "markers", + "filterwarnings(warning): add a warning filter to the given test. " + "see https://docs.pytest.org/en/stable/how-to/capture-warnings.html#pytest-mark-filterwarnings ", + ) + config.add_cleanup(stack.pop_all().close) diff --git a/src/py.py b/src/py.py index 7813c9b93cd..5c661e66c1f 100644 --- a/src/py.py +++ b/src/py.py @@ -1,10 +1,15 @@ # shim for pylib going away # if pylib is installed this file will get skipped # (`py/__init__.py` has higher precedence) +from __future__ import annotations + import sys import _pytest._py.error as error import _pytest._py.path as path + sys.modules["py.error"] = error sys.modules["py.path"] = path + +__all__ = ["error", "path"] diff --git a/src/pytest/__init__.py b/src/pytest/__init__.py index 9c1d5d204d8..297b524bcc2 100644 --- a/src/pytest/__init__.py +++ b/src/pytest/__init__.py @@ -1,5 +1,8 @@ # PYTHON_ARGCOMPLETE_OK """pytest: unit and functional testing with Python.""" + +from __future__ import annotations + from _pytest import __version__ from _pytest import version_tuple from _pytest._code import ExceptionInfo @@ -20,6 +23,7 @@ from _pytest.debugging import pytestPDB as __pytestPDB from _pytest.doctest import DoctestItem from _pytest.fixtures import fixture +from _pytest.fixtures import FixtureDef from _pytest.fixtures import FixtureLookupError from _pytest.fixtures import FixtureRequest from _pytest.fixtures import yield_fixture @@ -27,7 +31,9 @@ from _pytest.legacypath import TempdirFactory from _pytest.legacypath import Testdir from _pytest.logging import LogCaptureFixture +from _pytest.main import Dir from _pytest.main import Session +from _pytest.mark import HIDDEN_PARAM from _pytest.mark import Mark from _pytest.mark import MARK_GEN as mark from _pytest.mark import MarkDecorator @@ -35,6 +41,7 @@ from _pytest.mark import param from _pytest.monkeypatch import MonkeyPatch from _pytest.nodes import Collector +from _pytest.nodes import Directory from _pytest.nodes import File from _pytest.nodes import Item from _pytest.outcomes import exit @@ -53,7 +60,9 @@ from _pytest.python import Module from _pytest.python import Package from _pytest.python_api import approx -from _pytest.python_api import raises +from _pytest.raises import raises +from _pytest.raises import RaisesExc +from _pytest.raises import RaisesGroup from _pytest.recwarn import deprecated_call from _pytest.recwarn import WarningsRecorder from _pytest.recwarn import warns @@ -62,6 +71,7 @@ from _pytest.runner import CallInfo from _pytest.stash import Stash from _pytest.stash import StashKey +from _pytest.terminal import TerminalReporter from _pytest.terminal import TestShortLogReport from _pytest.tmpdir import TempPathFactory from _pytest.warning_types import PytestAssertRewriteWarning @@ -70,50 +80,41 @@ from _pytest.warning_types import PytestConfigWarning from _pytest.warning_types import PytestDeprecationWarning from _pytest.warning_types import PytestExperimentalApiWarning -from _pytest.warning_types import PytestRemovedIn8Warning +from _pytest.warning_types import PytestFDWarning +from _pytest.warning_types import PytestRemovedIn9Warning from _pytest.warning_types import PytestReturnNotNoneWarning -from _pytest.warning_types import PytestUnhandledCoroutineWarning from _pytest.warning_types import PytestUnhandledThreadExceptionWarning from _pytest.warning_types import PytestUnknownMarkWarning from _pytest.warning_types import PytestUnraisableExceptionWarning from _pytest.warning_types import PytestWarning + set_trace = __pytestPDB.set_trace __all__ = [ - "__version__", - "approx", + "HIDDEN_PARAM", "Cache", "CallInfo", "CaptureFixture", "Class", - "cmdline", - "Collector", "CollectReport", + "Collector", "Config", - "console_main", - "deprecated_call", + "Dir", + "Directory", "DoctestItem", - "exit", "ExceptionInfo", "ExitCode", - "fail", "File", - "fixture", + "FixtureDef", "FixtureLookupError", "FixtureRequest", - "freeze_includes", "Function", - "hookimpl", "HookRecorder", - "hookspec", - "importorskip", "Item", "LineMatcher", "LogCaptureFixture", - "main", - "mark", "Mark", "MarkDecorator", "MarkGenerator", @@ -122,7 +123,6 @@ "MonkeyPatch", "OptionGroup", "Package", - "param", "Parser", "PytestAssertRewriteWarning", "PytestCacheWarning", @@ -130,42 +130,51 @@ "PytestConfigWarning", "PytestDeprecationWarning", "PytestExperimentalApiWarning", - "PytestRemovedIn8Warning", - "PytestReturnNotNoneWarning", - "Pytester", + "PytestFDWarning", "PytestPluginManager", - "PytestUnhandledCoroutineWarning", + "PytestRemovedIn9Warning", + "PytestReturnNotNoneWarning", "PytestUnhandledThreadExceptionWarning", "PytestUnknownMarkWarning", "PytestUnraisableExceptionWarning", "PytestWarning", - "raises", + "Pytester", + "RaisesExc", + "RaisesGroup", "RecordedHookCall", - "register_assert_rewrite", "RunResult", "Session", - "set_trace", - "skip", "Stash", "StashKey", - "version_tuple", - "TempdirFactory", "TempPathFactory", - "Testdir", + "TempdirFactory", + "TerminalReporter", "TestReport", "TestShortLogReport", + "Testdir", "UsageError", "WarningsRecorder", + "__version__", + "approx", + "cmdline", + "console_main", + "deprecated_call", + "exit", + "fail", + "fixture", + "freeze_includes", + "hookimpl", + "hookspec", + "importorskip", + "main", + "mark", + "param", + "raises", + "register_assert_rewrite", + "set_trace", + "skip", + "version_tuple", "warns", "xfail", "yield_fixture", ] - - -def __getattr__(name: str) -> object: - if name == "Instance": - # The import emits a deprecation warning. - from _pytest.python import Instance - - return Instance - raise AttributeError(f"module {__name__} has no attribute {name}") diff --git a/src/pytest/__main__.py b/src/pytest/__main__.py index b170152937b..cccab5d57b8 100644 --- a/src/pytest/__main__.py +++ b/src/pytest/__main__.py @@ -1,5 +1,9 @@ """The pytest entry point.""" + +from __future__ import annotations + import pytest + if __name__ == "__main__": raise SystemExit(pytest.console_main()) diff --git a/testing/_py/test_local.py b/testing/_py/test_local.py index 348682b5396..7064d1daa9b 100644 --- a/testing/_py/test_local.py +++ b/testing/_py/test_local.py @@ -1,21 +1,25 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + import contextlib import multiprocessing import os import sys import time -import warnings from unittest import mock +import warnings -import pytest from py import error from py.path import local +import pytest + @contextlib.contextmanager def ignore_encoding_warning(): with warnings.catch_warnings(): - with contextlib.suppress(NameError): # new in 3.10 - warnings.simplefilter("ignore", EncodingWarning) + if sys.version_info >= (3, 10): + warnings.simplefilter("ignore", EncodingWarning) # noqa: F821 yield @@ -181,7 +185,7 @@ def test_listdir_fnmatchstring(self, path1): def test_listdir_filter(self, path1): p = path1.listdir(lambda x: x.check(dir=1)) assert path1.join("sampledir") in p - assert not path1.join("samplefile") in p + assert path1.join("samplefile") not in p def test_listdir_sorted(self, path1): p = path1.listdir(lambda x: x.check(basestarts="sample"), sort=True) @@ -201,17 +205,17 @@ def test_visit_norecurse(self, path1): for i in path1.visit(None, lambda x: x.basename != "sampledir"): lst.append(i.relto(path1)) assert "sampledir" in lst - assert not path1.sep.join(["sampledir", "otherfile"]) in lst + assert path1.sep.join(["sampledir", "otherfile"]) not in lst @pytest.mark.parametrize( "fil", - ["*dir", "*dir", pytest.mark.skip("sys.version_info <" " (3,6)")(b"*dir")], + ["*dir", "*dir", pytest.mark.skip("sys.version_info < (3,6)")(b"*dir")], ) def test_visit_filterfunc_is_string(self, path1, fil): lst = [] for i in path1.visit(fil): lst.append(i.relto(path1)) - assert len(lst), 2 + assert len(lst), 2 # noqa: PLC1802,RUF040 assert "sampledir" in lst assert "otherdir" in lst @@ -549,11 +553,11 @@ def batch_make_numbered_dirs(rootdir, repeats): for i in range(repeats): dir_ = local.make_numbered_dir(prefix="repro-", rootdir=rootdir) file_ = dir_.join("foo") - file_.write_text("%s" % i, encoding="utf-8") + file_.write_text(f"{i}", encoding="utf-8") actual = int(file_.read_text(encoding="utf-8")) - assert ( - actual == i - ), f"int(file_.read_text(encoding='utf-8')) is {actual} instead of {i}" + assert actual == i, ( + f"int(file_.read_text(encoding='utf-8')) is {actual} instead of {i}" + ) dir_.join(".lock").remove(ignore_errors=True) return True @@ -561,9 +565,9 @@ def batch_make_numbered_dirs(rootdir, repeats): class TestLocalPath(CommonFSTests): def test_join_normpath(self, tmpdir): assert tmpdir.join(".") == tmpdir - p = tmpdir.join("../%s" % tmpdir.basename) + p = tmpdir.join(f"../{tmpdir.basename}") assert p == tmpdir - p = tmpdir.join("..//%s/" % tmpdir.basename) + p = tmpdir.join(f"..//{tmpdir.basename}/") assert p == tmpdir @skiponwin32 @@ -665,7 +669,7 @@ def test_tilde_expansion(self, monkeypatch, tmpdir): assert p == os.path.expanduser("~") @pytest.mark.skipif( - not sys.platform.startswith("win32"), reason="case insensitive only on windows" + not sys.platform.startswith("win32"), reason="case-insensitive only on windows" ) def test_eq_hash_are_case_insensitive_on_windows(self): a = local("/some/path") @@ -720,7 +724,7 @@ def test_write_and_ensure(self, path1): @pytest.mark.parametrize("bin", (False, True)) def test_dump(self, tmpdir, bin): - path = tmpdir.join("dumpfile%s" % int(bin)) + path = tmpdir.join(f"dumpfile{int(bin)}") try: d = {"answer": 42} path.dump(d, bin=bin) @@ -734,7 +738,6 @@ def test_dump(self, tmpdir, bin): def test_setmtime(self): import tempfile - import time try: fd, name = tempfile.mkstemp() @@ -743,6 +746,7 @@ def test_setmtime(self): name = tempfile.mktemp() open(name, "w").close() try: + # Do not use _pytest.timing here, as we do not want time mocking to affect this test. mtime = int(time.time()) - 100 path = local(name) assert path.mtime() != mtime @@ -820,7 +824,7 @@ def test_long_filenames(self, tmpdir): # depending on how the paths are used), but > 4096 (which is the # Linux' limitation) - the behaviour of paths with names > 4096 chars # is undetermined - newfilename = "/test" * 60 # type:ignore[unreachable] + newfilename = "/test" * 60 # type:ignore[unreachable,unused-ignore] l1 = tmpdir.join(newfilename) l1.ensure(file=True) l1.write_text("foo", encoding="utf-8") @@ -851,7 +855,7 @@ def test_fnmatch_file_abspath(self, tmpdir): assert b.fnmatch(pattern) def test_sysfind(self): - name = sys.platform == "win32" and "cmd" or "test" + name = (sys.platform == "win32" and "cmd") or "test" x = local.sysfind(name) assert x.check(file=1) assert local.sysfind("jaksdkasldqwe") is None @@ -868,6 +872,9 @@ def test_fspath_protocol_other_class(self, fake_fspath_obj): py_path.strpath, str_path ) + @pytest.mark.xfail( + reason="#11603", raises=(error.EEXIST, error.ENOENT), strict=False + ) def test_make_numbered_dir_multiprocess_safe(self, tmpdir): # https://github.com/pytest-dev/py/issues/30 with multiprocessing.Pool() as pool: @@ -893,7 +900,7 @@ def test_sysfind_bat_exe_before(self, tmpdir, monkeypatch): class TestExecution: pytestmark = skiponwin32 - def test_sysfind_no_permisson_ignored(self, monkeypatch, tmpdir): + def test_sysfind_no_permission_ignored(self, monkeypatch, tmpdir): noperm = tmpdir.ensure("noperm", dir=True) monkeypatch.setenv("PATH", str(noperm), prepend=":") noperm.chmod(0) @@ -941,7 +948,7 @@ def test_make_numbered_dir(self, tmpdir): prefix="base.", rootdir=tmpdir, keep=2, lock_timeout=0 ) assert numdir.check() - assert numdir.basename == "base.%d" % i + assert numdir.basename == f"base.{i}" if i >= 1: assert numdir.new(ext=str(i - 1)).check() if i >= 2: @@ -986,7 +993,7 @@ def test_locked_make_numbered_dir(self, tmpdir): for i in range(10): numdir = local.make_numbered_dir(prefix="base2.", rootdir=tmpdir, keep=2) assert numdir.check() - assert numdir.basename == "base2.%d" % i + assert numdir.basename == f"base2.{i}" for j in range(i): assert numdir.new(ext=str(j)).check() @@ -1080,14 +1087,14 @@ def test_pyimport_check_filepath_consistency(self, monkeypatch, tmpdir): name = "pointsback123" ModuleType = type(os) p = tmpdir.ensure(name + ".py") - for ending in (".pyc", "$py.class", ".pyo"): - mod = ModuleType(name) - pseudopath = tmpdir.ensure(name + ending) - mod.__file__ = str(pseudopath) - monkeypatch.setitem(sys.modules, name, mod) - newmod = p.pyimport() - assert mod == newmod - monkeypatch.undo() + with monkeypatch.context() as mp: + for ending in (".pyc", "$py.class", ".pyo"): + mod = ModuleType(name) + pseudopath = tmpdir.ensure(name + ending) + mod.__file__ = str(pseudopath) + mp.setitem(sys.modules, name, mod) + newmod = p.pyimport() + assert mod == newmod mod = ModuleType(name) pseudopath = tmpdir.ensure(name + "123.py") mod.__file__ = str(pseudopath) @@ -1236,14 +1243,14 @@ class TestWINLocalPath: def test_owner_group_not_implemented(self, path1): with pytest.raises(NotImplementedError): - path1.stat().owner + _ = path1.stat().owner with pytest.raises(NotImplementedError): - path1.stat().group + _ = path1.stat().group def test_chmod_simple_int(self, path1): mode = path1.stat().mode # Ensure that we actually change the mode to something different. - path1.chmod(mode == 0 and 1 or 0) + path1.chmod((mode == 0 and 1) or 0) try: print(path1.stat().mode) print(mode) @@ -1363,8 +1370,8 @@ def test_realpath_file(self, tmpdir): assert realpath.basename == "file" def test_owner(self, path1, tmpdir): - from pwd import getpwuid # type:ignore[attr-defined] - from grp import getgrgid # type:ignore[attr-defined] + from grp import getgrgid # type:ignore[attr-defined,unused-ignore] + from pwd import getpwuid # type:ignore[attr-defined,unused-ignore] stat = path1.stat() assert stat.path == path1 @@ -1398,6 +1405,7 @@ def test_atime(self, tmpdir): import time path = tmpdir.ensure("samplefile") + # Do not use _pytest.timing here, as we do not want time mocking to affect this test. now = time.time() atime1 = path.atime() # we could wait here but timer resolution is very @@ -1519,9 +1527,9 @@ def test_chown_identity_rec_mayfail(self, path1): path1.chown(owner, group) -class TestUnicodePy2Py3: +class TestUnicode: def test_join_ensure(self, tmpdir, monkeypatch): - if sys.version_info >= (3, 0) and "LANG" not in os.environ: + if "LANG" not in os.environ: pytest.skip("cannot run test without locale") x = local(tmpdir.strpath) part = "hällo" @@ -1529,7 +1537,7 @@ def test_join_ensure(self, tmpdir, monkeypatch): assert x.join(part) == y def test_listdir(self, tmpdir): - if sys.version_info >= (3, 0) and "LANG" not in os.environ: + if "LANG" not in os.environ: pytest.skip("cannot run test without locale") x = local(tmpdir.strpath) part = "hällo" @@ -1573,4 +1581,4 @@ def test_default_encoding(self, tmpdir): x.write_text(part, "ascii") s = x.read_text("ascii") assert s == part - assert type(s) == type(part) + assert type(s) is type(part) diff --git a/testing/acceptance_test.py b/testing/acceptance_test.py index 5658f2fd6b8..2101fe9ad76 100644 --- a/testing/acceptance_test.py +++ b/testing/acceptance_test.py @@ -1,13 +1,21 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + +from collections.abc import Sequence import dataclasses +import importlib.metadata import os +from pathlib import Path +import subprocess import sys import types -import pytest -from _pytest.compat import importlib_metadata +import setuptools + from _pytest.config import ExitCode from _pytest.pathlib import symlink_or_skip from _pytest.pytester import Pytester +import pytest def prepend_pythonpath(*dirs) -> str: @@ -139,7 +147,7 @@ class DummyDist: def my_dists(): return (DummyDist(entry_points),) - monkeypatch.setattr(importlib_metadata, "distributions", my_dists) + monkeypatch.setattr(importlib.metadata, "distributions", my_dists) params = ("-p", "mycov") if load_cov_early else () pytester.runpytest_inprocess(*params) if load_cov_early: @@ -185,7 +193,8 @@ def test_not_collectable_arguments(self, pytester: Pytester) -> None: assert result.ret == ExitCode.USAGE_ERROR result.stderr.fnmatch_lines( [ - f"ERROR: found no collectors for {p2}", + f"ERROR: not found: {p2}", + "(no match in any of *)", "", ] ) @@ -341,6 +350,45 @@ def test_func(i): assert res.ret == 0 res.stdout.fnmatch_lines(["*1 passed*"]) + def test_direct_addressing_selects_duplicates(self, pytester: Pytester) -> None: + p = pytester.makepyfile( + """ + import pytest + + @pytest.mark.parametrize("a", [1, 2, 10, 11, 2, 1, 12, 11]) + def test_func(a): + pass + """ + ) + result = pytester.runpytest(p) + result.assert_outcomes(failed=0, passed=8) + + def test_direct_addressing_selects_duplicates_1(self, pytester: Pytester) -> None: + p = pytester.makepyfile( + """ + import pytest + + @pytest.mark.parametrize("a", [1, 2, 10, 11, 2, 1, 12, 1_1,2_1]) + def test_func(a): + pass + """ + ) + result = pytester.runpytest(p) + result.assert_outcomes(failed=0, passed=9) + + def test_direct_addressing_selects_duplicates_2(self, pytester: Pytester) -> None: + p = pytester.makepyfile( + """ + import pytest + + @pytest.mark.parametrize("a", ["a","b","c","a","a1"]) + def test_func(a): + pass + """ + ) + result = pytester.runpytest(p) + result.assert_outcomes(failed=0, passed=5) + def test_direct_addressing_notfound(self, pytester: Pytester) -> None: p = pytester.makepyfile( """ @@ -357,7 +405,7 @@ def test_docstring_on_hookspec(self) -> None: for name, value in vars(hookspec).items(): if name.startswith("pytest_"): - assert value.__doc__, "no docstring for %s" % name + assert value.__doc__, f"no docstring for {name}" def test_initialization_error_issue49(self, pytester: Pytester) -> None: pytester.makeconftest( @@ -499,6 +547,32 @@ def test_foo(data): res = pytester.runpytest(p) res.assert_outcomes(passed=3) + # Warning ignore because of: + # https://github.com/python/cpython/issues/85308 + # Can be removed once Python<3.12 support is dropped. + @pytest.mark.filterwarnings("ignore:'encoding' argument not specified") + def test_command_line_args_from_file( + self, pytester: Pytester, tmp_path: Path + ) -> None: + pytester.makepyfile( + test_file=""" + import pytest + + class TestClass: + @pytest.mark.parametrize("a", ["x","y"]) + def test_func(self, a): + pass + """ + ) + tests = [ + "test_file.py::TestClass::test_func[x]", + "test_file.py::TestClass::test_func[y]", + "-q", + ] + args_file = pytester.maketxtfile(tests="\n".join(tests)) + result = pytester.runpytest(f"@{args_file}") + result.assert_outcomes(failed=0, passed=2) + class TestInvocationVariants: def test_earlyinit(self, pytester: Pytester) -> None: @@ -650,10 +724,14 @@ def test_cmdline_python_package(self, pytester: Pytester, monkeypatch) -> None: assert result.ret != 0 result.stderr.fnmatch_lines(["*not*found*test_missing*"]) - def test_cmdline_python_namespace_package( + @pytest.mark.skipif( + int(setuptools.__version__.split(".")[0]) >= 80, + reason="modern setuptools removing pkg_resources", + ) + def test_cmdline_python_legacy_namespace_package( self, pytester: Pytester, monkeypatch ) -> None: - """Test --pyargs option with namespace packages (#1567). + """Test --pyargs option with legacy namespace packages (#1567). Ref: https://packaging.python.org/guides/packaging-namespace-packages/ """ @@ -899,28 +977,43 @@ def test_calls_showall(self, pytester: Pytester, mock_timing) -> None: pytester.makepyfile(self.source) result = pytester.runpytest_inprocess("--durations=0") assert result.ret == 0 - - tested = "3" - for x in tested: - for y in ("call",): # 'setup', 'call', 'teardown': - for line in result.stdout.lines: - if ("test_%s" % x) in line and y in line: - break - else: - raise AssertionError(f"not found {x} {y}") + TestDurations.check_tests_in_output(result.stdout.lines, 2, 3) def test_calls_showall_verbose(self, pytester: Pytester, mock_timing) -> None: pytester.makepyfile(self.source) result = pytester.runpytest_inprocess("--durations=0", "-vv") assert result.ret == 0 + TestDurations.check_tests_in_output(result.stdout.lines, 1, 2, 3) - for x in "123": - for y in ("call",): # 'setup', 'call', 'teardown': - for line in result.stdout.lines: - if ("test_%s" % x) in line and y in line: - break - else: - raise AssertionError(f"not found {x} {y}") + def test_calls_showall_durationsmin(self, pytester: Pytester, mock_timing) -> None: + pytester.makepyfile(self.source) + result = pytester.runpytest_inprocess("--durations=0", "--durations-min=0.015") + assert result.ret == 0 + TestDurations.check_tests_in_output(result.stdout.lines, 3) + + def test_calls_showall_durationsmin_verbose( + self, pytester: Pytester, mock_timing + ) -> None: + pytester.makepyfile(self.source) + result = pytester.runpytest_inprocess( + "--durations=0", "--durations-min=0.015", "-vv" + ) + assert result.ret == 0 + TestDurations.check_tests_in_output(result.stdout.lines, 3) + + @staticmethod + def check_tests_in_output( + lines: Sequence[str], *expected_test_numbers: int, number_of_tests: int = 3 + ) -> None: + found_test_numbers = { + test_number + for test_number in range(1, number_of_tests + 1) + if any( + line.endswith(f"test_{test_number}") and " call " in line + for line in lines + ) + } + assert found_test_numbers == set(expected_test_numbers) def test_with_deselected(self, pytester: Pytester, mock_timing) -> None: pytester.makepyfile(self.source) @@ -1164,8 +1257,7 @@ def test_usage_error_code(pytester: Pytester) -> None: assert result.ret == ExitCode.USAGE_ERROR -@pytest.mark.filterwarnings("default::pytest.PytestUnhandledCoroutineWarning") -def test_warn_on_async_function(pytester: Pytester) -> None: +def test_error_on_async_function(pytester: Pytester) -> None: # In the below we .close() the coroutine only to avoid # "RuntimeWarning: coroutine 'test_2' was never awaited" # which messes with other tests. @@ -1184,21 +1276,16 @@ def test_3(): result = pytester.runpytest() result.stdout.fnmatch_lines( [ - "test_async.py::test_1", - "test_async.py::test_2", - "test_async.py::test_3", "*async def functions are not natively supported*", - "*3 skipped, 3 warnings in*", + "*test_async.py::test_1*", + "*test_async.py::test_2*", + "*test_async.py::test_3*", ] ) - # ensure our warning message appears only once - assert ( - result.stdout.str().count("async def functions are not natively supported") == 1 - ) + result.assert_outcomes(failed=3) -@pytest.mark.filterwarnings("default::pytest.PytestUnhandledCoroutineWarning") -def test_warn_on_async_gen_function(pytester: Pytester) -> None: +def test_error_on_async_gen_function(pytester: Pytester) -> None: pytester.makepyfile( test_async=""" async def test_1(): @@ -1212,17 +1299,111 @@ def test_3(): result = pytester.runpytest() result.stdout.fnmatch_lines( [ - "test_async.py::test_1", - "test_async.py::test_2", - "test_async.py::test_3", "*async def functions are not natively supported*", - "*3 skipped, 3 warnings in*", + "*test_async.py::test_1*", + "*test_async.py::test_2*", + "*test_async.py::test_3*", ] ) - # ensure our warning message appears only once - assert ( - result.stdout.str().count("async def functions are not natively supported") == 1 + result.assert_outcomes(failed=3) + + +def test_warning_on_sync_test_async_fixture(pytester: Pytester) -> None: + pytester.makepyfile( + test_sync=""" + import pytest + + @pytest.fixture + async def async_fixture(): + ... + + def test_foo(async_fixture): + # suppress unawaited coroutine warning + try: + async_fixture.send(None) + except StopIteration: + pass + """ + ) + result = pytester.runpytest() + result.stdout.fnmatch_lines( + [ + "*== warnings summary ==*", + ( + "*PytestRemovedIn9Warning: 'test_foo' requested an async " + "fixture 'async_fixture', with no plugin or hook that handled it. " + "This is usually an error, as pytest does not natively support it. " + "This will turn into an error in pytest 9." + ), + " See: https://docs.pytest.org/en/stable/deprecations.html#sync-test-depending-on-async-fixture", + ] ) + result.assert_outcomes(passed=1, warnings=1) + + +def test_warning_on_sync_test_async_fixture_gen(pytester: Pytester) -> None: + pytester.makepyfile( + test_sync=""" + import pytest + + @pytest.fixture + async def async_fixture(): + yield + + def test_foo(async_fixture): + # async gens don't emit unawaited-coroutine + ... + """ + ) + result = pytester.runpytest() + result.stdout.fnmatch_lines( + [ + "*== warnings summary ==*", + ( + "*PytestRemovedIn9Warning: 'test_foo' requested an async " + "fixture 'async_fixture', with no plugin or hook that handled it. " + "This is usually an error, as pytest does not natively support it. " + "This will turn into an error in pytest 9." + ), + " See: https://docs.pytest.org/en/stable/deprecations.html#sync-test-depending-on-async-fixture", + ] + ) + result.assert_outcomes(passed=1, warnings=1) + + +def test_warning_on_sync_test_async_autouse_fixture(pytester: Pytester) -> None: + pytester.makepyfile( + test_sync=""" + import pytest + + @pytest.fixture(autouse=True) + async def async_fixture(): + ... + + # We explicitly request the fixture to be able to + # suppress the RuntimeWarning for unawaited coroutine. + def test_foo(async_fixture): + try: + async_fixture.send(None) + except StopIteration: + pass + """ + ) + result = pytester.runpytest() + result.stdout.fnmatch_lines( + [ + "*== warnings summary ==*", + ( + "*PytestRemovedIn9Warning: 'test_foo' requested an async " + "fixture 'async_fixture' with autouse=True, with no plugin or hook " + "that handled it. " + "This is usually an error, as pytest does not natively support it. " + "This will turn into an error in pytest 9." + ), + " See: https://docs.pytest.org/en/stable/deprecations.html#sync-test-depending-on-async-fixture", + ] + ) + result.assert_outcomes(passed=1, warnings=1) def test_pdb_can_be_rewritten(pytester: Pytester) -> None: @@ -1308,6 +1489,7 @@ def test_no_brokenpipeerror_message(pytester: Pytester) -> None: popen.stderr.close() +@pytest.mark.filterwarnings("default") def test_function_return_non_none_warning(pytester: Pytester) -> None: pytester.makepyfile( """ @@ -1317,3 +1499,156 @@ def test_stuff(): ) res = pytester.runpytest() res.stdout.fnmatch_lines(["*Did you mean to use `assert` instead of `return`?*"]) + + +def test_doctest_and_normal_imports_with_importlib(pytester: Pytester) -> None: + """ + Regression test for #10811: previously import_path with ImportMode.importlib would + not return a module if already in sys.modules, resulting in modules being imported + multiple times, which causes problems with modules that have import side effects. + """ + # Uses the exact reproducer form #10811, given it is very minimal + # and illustrates the problem well. + pytester.makepyfile( + **{ + "pmxbot/commands.py": "from . import logging", + "pmxbot/logging.py": "", + "tests/__init__.py": "", + "tests/test_commands.py": """ + import importlib + from pmxbot import logging + + class TestCommands: + def test_boo(self): + assert importlib.import_module('pmxbot.logging') is logging + """, + } + ) + pytester.makeini( + """ + [pytest] + addopts= + --doctest-modules + --import-mode importlib + """ + ) + result = pytester.runpytest_subprocess() + result.stdout.fnmatch_lines("*1 passed*") + + +@pytest.mark.skip(reason="Test is not isolated") +def test_issue_9765(pytester: Pytester) -> None: + """Reproducer for issue #9765 on Windows + + https://github.com/pytest-dev/pytest/issues/9765 + """ + pytester.makepyprojecttoml( + """ + [tool.pytest.ini_options] + addopts = "-p my_package.plugin.my_plugin" + """ + ) + pytester.makepyfile( + **{ + "setup.py": ( + """ + from setuptools import setup + + if __name__ == '__main__': + setup(name='my_package', packages=['my_package', 'my_package.plugin']) + """ + ), + "my_package/__init__.py": "", + "my_package/conftest.py": "", + "my_package/test_foo.py": "def test(): pass", + "my_package/plugin/__init__.py": "", + "my_package/plugin/my_plugin.py": ( + """ + import pytest + + def pytest_configure(config): + + class SimplePlugin: + @pytest.fixture(params=[1, 2, 3]) + def my_fixture(self, request): + yield request.param + + config.pluginmanager.register(SimplePlugin()) + """ + ), + } + ) + + subprocess.run( + [sys.executable, "-Im", "pip", "install", "-e", "."], + check=True, + ) + try: + # We are using subprocess.run rather than pytester.run on purpose. + # pytester.run is adding the current directory to PYTHONPATH which avoids + # the bug. We also use pytest rather than python -m pytest for the same + # PYTHONPATH reason. + subprocess.run( + ["pytest", "my_package"], + capture_output=True, + check=True, + encoding="utf-8", + text=True, + ) + except subprocess.CalledProcessError as exc: + raise AssertionError( + f"pytest command failed:\n{exc.stdout=!s}\n{exc.stderr=!s}" + ) from exc + + +def test_no_terminal_plugin(pytester: Pytester) -> None: + """Smoke test to ensure pytest can execute without the terminal plugin (#9422).""" + pytester.makepyfile("def test(): assert 1 == 2") + result = pytester.runpytest("-pno:terminal", "-s") + assert result.ret == ExitCode.TESTS_FAILED + + +def test_stop_iteration_from_collect(pytester: Pytester) -> None: + pytester.makepyfile(test_it="raise StopIteration('hello')") + result = pytester.runpytest() + assert result.ret == ExitCode.INTERRUPTED + result.assert_outcomes(failed=0, passed=0, errors=1) + result.stdout.fnmatch_lines( + [ + "=* short test summary info =*", + "ERROR test_it.py - StopIteration: hello", + "!* Interrupted: 1 error during collection !*", + "=* 1 error in * =*", + ] + ) + + +def test_stop_iteration_runtest_protocol(pytester: Pytester) -> None: + pytester.makepyfile( + test_it=""" + import pytest + @pytest.fixture + def fail_setup(): + raise StopIteration(1) + def test_fail_setup(fail_setup): + pass + def test_fail_teardown(request): + def stop_iteration(): + raise StopIteration(2) + request.addfinalizer(stop_iteration) + def test_fail_call(): + raise StopIteration(3) + """ + ) + result = pytester.runpytest() + assert result.ret == ExitCode.TESTS_FAILED + result.assert_outcomes(failed=1, passed=1, errors=2) + result.stdout.fnmatch_lines( + [ + "=* short test summary info =*", + "FAILED test_it.py::test_fail_call - StopIteration: 3", + "ERROR test_it.py::test_fail_setup - StopIteration: 1", + "ERROR test_it.py::test_fail_teardown - StopIteration: 2", + "=* 1 failed, 1 passed, 2 errors in * =*", + ] + ) diff --git a/testing/code/test_code.py b/testing/code/test_code.py index 33809528a06..7ae5ad46100 100644 --- a/testing/code/test_code.py +++ b/testing/code/test_code.py @@ -1,15 +1,18 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + import re import sys from types import FrameType from unittest import mock -import pytest from _pytest._code import Code from _pytest._code import ExceptionInfo from _pytest._code import Frame from _pytest._code import Source from _pytest._code.code import ExceptionChainRepr from _pytest._code.code import ReprFuncArgs +import pytest def test_ne() -> None: diff --git a/testing/code/test_excinfo.py b/testing/code/test_excinfo.py index e5c030c4d66..555645030fc 100644 --- a/testing/code/test_excinfo.py +++ b/testing/code/test_excinfo.py @@ -1,18 +1,20 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + +import fnmatch import importlib import io import operator +from pathlib import Path import queue +import re import sys import textwrap -from pathlib import Path from typing import Any -from typing import Dict -from typing import Tuple +from typing import cast from typing import TYPE_CHECKING -from typing import Union import _pytest._code -import pytest from _pytest._code.code import ExceptionChainRepr from _pytest._code.code import ExceptionInfo from _pytest._code.code import FormattedExcinfo @@ -22,10 +24,15 @@ from _pytest.pathlib import import_path from _pytest.pytester import LineMatcher from _pytest.pytester import Pytester +import pytest if TYPE_CHECKING: - from _pytest._code.code import _TracebackStyle + from _pytest._code.code import TracebackStyle + +if sys.version_info < (3, 11): + from exceptiongroup import BaseExceptionGroup + from exceptiongroup import ExceptionGroup @pytest.fixture @@ -176,7 +183,7 @@ def test_traceback_cut(self) -> None: def test_traceback_cut_excludepath(self, pytester: Pytester) -> None: p = pytester.makepyfile("def f(): raise ValueError") with pytest.raises(ValueError) as excinfo: - import_path(p, root=pytester.path).f() # type: ignore[attr-defined] + import_path(p, root=pytester.path, consider_namespace_packages=False).f() basedir = Path(pytest.__file__).parent newtraceback = excinfo.traceback.cut(excludepath=basedir) for x in newtraceback: @@ -233,7 +240,7 @@ def f(n): n += 1 f(n) - excinfo = pytest.raises(RuntimeError, f, 8) + excinfo = pytest.raises(RecursionError, f, 8) traceback = excinfo.traceback recindex = traceback.recursionindex() assert recindex == 3 @@ -369,7 +376,10 @@ def test_excinfo_no_sourcecode(): except ValueError: excinfo = _pytest._code.ExceptionInfo.from_current() s = str(excinfo.traceback[-1]) - assert s == " File '':1 in \n ???\n" + # TODO: Since Python 3.13b1 under pytest-xdist, the * is `import + # sys;exec(eval(sys.stdin.readline()))` (execnet bootstrap code) + # instead of `???` like before. Is this OK? + fnmatch.fnmatch(s, " File '':1 in \n *\n") def test_excinfo_no_python_sourcecode(tmp_path: Path) -> None: @@ -383,7 +393,7 @@ def test_excinfo_no_python_sourcecode(tmp_path: Path) -> None: excinfo = pytest.raises(ValueError, template.render, h=h) for item in excinfo.traceback: print(item) # XXX: for some reason jinja.Template.render is printed in full - item.source # shouldn't fail + _ = item.source # shouldn't fail if isinstance(item.path, Path) and item.path.name == "test.txt": assert str(item.source) == "{{ h()}}:" @@ -414,7 +424,7 @@ def test_codepath_Queue_example() -> None: def test_match_succeeds(): with pytest.raises(ZeroDivisionError) as excinfo: - 0 // 0 + _ = 0 // 0 excinfo.match(r".*zero.*") @@ -444,6 +454,125 @@ def test_division_zero(): result.stdout.re_match_lines([r".*__tracebackhide__ = True.*", *match]) +def test_raises_accepts_generic_group() -> None: + with pytest.raises(ExceptionGroup[Exception]) as exc_info: + raise ExceptionGroup("", [RuntimeError()]) + assert exc_info.group_contains(RuntimeError) + + +def test_raises_accepts_generic_base_group() -> None: + with pytest.raises(BaseExceptionGroup[BaseException]) as exc_info: + raise ExceptionGroup("", [RuntimeError()]) + assert exc_info.group_contains(RuntimeError) + + +def test_raises_rejects_specific_generic_group() -> None: + with pytest.raises(ValueError): + pytest.raises(ExceptionGroup[RuntimeError]) + + +def test_raises_accepts_generic_group_in_tuple() -> None: + with pytest.raises((ValueError, ExceptionGroup[Exception])) as exc_info: + raise ExceptionGroup("", [RuntimeError()]) + assert exc_info.group_contains(RuntimeError) + + +def test_raises_exception_escapes_generic_group() -> None: + try: + with pytest.raises(ExceptionGroup[Exception]): + raise ValueError("my value error") + except ValueError as e: + assert str(e) == "my value error" + else: + pytest.fail("Expected ValueError to be raised") + + +class TestGroupContains: + def test_contains_exception_type(self) -> None: + exc_group = ExceptionGroup("", [RuntimeError()]) + with pytest.raises(ExceptionGroup) as exc_info: + raise exc_group + assert exc_info.group_contains(RuntimeError) + + def test_doesnt_contain_exception_type(self) -> None: + exc_group = ExceptionGroup("", [ValueError()]) + with pytest.raises(ExceptionGroup) as exc_info: + raise exc_group + assert not exc_info.group_contains(RuntimeError) + + def test_contains_exception_match(self) -> None: + exc_group = ExceptionGroup("", [RuntimeError("exception message")]) + with pytest.raises(ExceptionGroup) as exc_info: + raise exc_group + assert exc_info.group_contains(RuntimeError, match=r"^exception message$") + + def test_doesnt_contain_exception_match(self) -> None: + exc_group = ExceptionGroup("", [RuntimeError("message that will not match")]) + with pytest.raises(ExceptionGroup) as exc_info: + raise exc_group + assert not exc_info.group_contains(RuntimeError, match=r"^exception message$") + + def test_contains_exception_type_unlimited_depth(self) -> None: + exc_group = ExceptionGroup("", [ExceptionGroup("", [RuntimeError()])]) + with pytest.raises(ExceptionGroup) as exc_info: + raise exc_group + assert exc_info.group_contains(RuntimeError) + + def test_contains_exception_type_at_depth_1(self) -> None: + exc_group = ExceptionGroup("", [RuntimeError()]) + with pytest.raises(ExceptionGroup) as exc_info: + raise exc_group + assert exc_info.group_contains(RuntimeError, depth=1) + + def test_doesnt_contain_exception_type_past_depth(self) -> None: + exc_group = ExceptionGroup("", [ExceptionGroup("", [RuntimeError()])]) + with pytest.raises(ExceptionGroup) as exc_info: + raise exc_group + assert not exc_info.group_contains(RuntimeError, depth=1) + + def test_contains_exception_type_specific_depth(self) -> None: + exc_group = ExceptionGroup("", [ExceptionGroup("", [RuntimeError()])]) + with pytest.raises(ExceptionGroup) as exc_info: + raise exc_group + assert exc_info.group_contains(RuntimeError, depth=2) + + def test_contains_exception_match_unlimited_depth(self) -> None: + exc_group = ExceptionGroup( + "", [ExceptionGroup("", [RuntimeError("exception message")])] + ) + with pytest.raises(ExceptionGroup) as exc_info: + raise exc_group + assert exc_info.group_contains(RuntimeError, match=r"^exception message$") + + def test_contains_exception_match_at_depth_1(self) -> None: + exc_group = ExceptionGroup("", [RuntimeError("exception message")]) + with pytest.raises(ExceptionGroup) as exc_info: + raise exc_group + assert exc_info.group_contains( + RuntimeError, match=r"^exception message$", depth=1 + ) + + def test_doesnt_contain_exception_match_past_depth(self) -> None: + exc_group = ExceptionGroup( + "", [ExceptionGroup("", [RuntimeError("exception message")])] + ) + with pytest.raises(ExceptionGroup) as exc_info: + raise exc_group + assert not exc_info.group_contains( + RuntimeError, match=r"^exception message$", depth=1 + ) + + def test_contains_exception_match_specific_depth(self) -> None: + exc_group = ExceptionGroup( + "", [ExceptionGroup("", [RuntimeError("exception message")])] + ) + with pytest.raises(ExceptionGroup) as exc_info: + raise exc_group + assert exc_info.group_contains( + RuntimeError, match=r"^exception message$", depth=2 + ) + + class TestFormattedExcinfo: @pytest.fixture def importasmod(self, tmp_path: Path, _sys_snapshot): @@ -453,7 +582,9 @@ def importasmod(source): tmp_path.joinpath("__init__.py").touch() modpath.write_text(source, encoding="utf-8") importlib.invalidate_caches() - return import_path(modpath, root=tmp_path) + return import_path( + modpath, root=tmp_path, consider_namespace_packages=False + ) return importasmod @@ -494,7 +625,7 @@ def test_repr_source_excinfo(self) -> None: try: def f(): - 1 / 0 + _ = 1 / 0 f() @@ -511,7 +642,7 @@ def f(): print(line) assert lines == [ " def f():", - "> 1 / 0", + "> _ = 1 / 0", "E ZeroDivisionError: division by zero", ] @@ -548,7 +679,7 @@ def test_repr_source_failing_fullsource(self, monkeypatch) -> None: pr = FormattedExcinfo() try: - 1 / 0 + _ = 1 / 0 except ZeroDivisionError: excinfo = ExceptionInfo.from_current() @@ -616,6 +747,29 @@ def test_repr_local_truncated(self) -> None: assert full_reprlocals.lines assert full_reprlocals.lines[0] == "l = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]" + def test_repr_args_not_truncated(self, importasmod) -> None: + mod = importasmod( + """ + def func1(m): + raise ValueError("hello\\nworld") + """ + ) + excinfo = pytest.raises(ValueError, mod.func1, "m" * 500) + excinfo.traceback = excinfo.traceback.filter(excinfo) + entry = excinfo.traceback[-1] + p = FormattedExcinfo(funcargs=True, truncate_args=True) + reprfuncargs = p.repr_args(entry) + assert reprfuncargs is not None + arg1 = cast(str, reprfuncargs.args[0][1]) + assert len(arg1) < 500 + assert "..." in arg1 + # again without truncate + p = FormattedExcinfo(funcargs=True, truncate_args=False) + reprfuncargs = p.repr_args(entry) + assert reprfuncargs is not None + assert reprfuncargs.args[0] == ("m", repr("m" * 500)) + assert "..." not in cast(str, reprfuncargs.args[0][1]) + def test_repr_tracebackentry_lines(self, importasmod) -> None: mod = importasmod( """ @@ -729,6 +883,37 @@ def entry(): assert basename in str(reprtb.reprfileloc.path) assert reprtb.reprfileloc.lineno == 3 + @pytest.mark.skipif( + "sys.version_info < (3,11)", + reason="Column level traceback info added in python 3.11", + ) + def test_repr_traceback_entry_short_carets(self, importasmod) -> None: + mod = importasmod( + """ + def div_by_zero(): + return 1 / 0 + def func1(): + return 42 + div_by_zero() + def entry(): + func1() + """ + ) + excinfo = pytest.raises(ZeroDivisionError, mod.entry) + p = FormattedExcinfo(style="short") + reprtb = p.repr_traceback_entry(excinfo.traceback[-3]) + assert len(reprtb.lines) == 1 + assert reprtb.lines[0] == " func1()" + + reprtb = p.repr_traceback_entry(excinfo.traceback[-2]) + assert len(reprtb.lines) == 2 + assert reprtb.lines[0] == " return 42 + div_by_zero()" + assert reprtb.lines[1] == " ^^^^^^^^^^^^^" + + reprtb = p.repr_traceback_entry(excinfo.traceback[-1]) + assert len(reprtb.lines) == 2 + assert reprtb.lines[0] == " return 1 / 0" + assert reprtb.lines[1] == " ^^^^^" + def test_repr_tracebackentry_no(self, importasmod): mod = importasmod( """ @@ -765,7 +950,11 @@ def entry(): reprtb = p.repr_traceback(excinfo) assert len(reprtb.reprentries) == 3 - def test_traceback_short_no_source(self, importasmod, monkeypatch) -> None: + def test_traceback_short_no_source( + self, + importasmod, + monkeypatch: pytest.MonkeyPatch, + ) -> None: mod = importasmod( """ def func1(): @@ -777,14 +966,14 @@ def entry(): excinfo = pytest.raises(ValueError, mod.entry) from _pytest._code.code import Code - monkeypatch.setattr(Code, "path", "bogus") - p = FormattedExcinfo(style="short") - reprtb = p.repr_traceback_entry(excinfo.traceback[-2]) - lines = reprtb.lines - last_p = FormattedExcinfo(style="short") - last_reprtb = last_p.repr_traceback_entry(excinfo.traceback[-1], excinfo) - last_lines = last_reprtb.lines - monkeypatch.undo() + with monkeypatch.context() as mp: + mp.setattr(Code, "path", "bogus") + p = FormattedExcinfo(style="short") + reprtb = p.repr_traceback_entry(excinfo.traceback[-2]) + lines = reprtb.lines + last_p = FormattedExcinfo(style="short") + last_reprtb = last_p.repr_traceback_entry(excinfo.traceback[-1], excinfo) + last_lines = last_reprtb.lines assert lines[0] == " func1()" assert last_lines[0] == ' raise ValueError("hello")' @@ -801,7 +990,7 @@ def entry(): ) excinfo = pytest.raises(ValueError, mod.entry) - styles: Tuple[_TracebackStyle, ...] = ("long", "short") + styles: tuple[TracebackStyle, ...] = ("long", "short") for style in styles: p = FormattedExcinfo(style=style) reprtb = p.repr_traceback(excinfo) @@ -840,8 +1029,8 @@ def raiseos(): upframe = sys._getframe().f_back assert upframe is not None if upframe.f_code.co_name == "_makepath": - # Only raise with expected calls, but not via e.g. inspect for - # py38-windows. + # Only raise with expected calls, and not accidentally via 'inspect' + # See 79ae86cc3f76d69460e1c7beca4ce95e68ab80a6 raised += 1 raise OSError(2, "custom_oserror") return orig_path_cwd() @@ -928,7 +1117,7 @@ def entry(): ) excinfo = pytest.raises(ValueError, mod.entry) - styles: Tuple[_TracebackStyle, ...] = ("short", "long", "no") + styles: tuple[TracebackStyle, ...] = ("short", "long", "no") for style in styles: for showlocals in (True, False): repr = excinfo.getrepr(style=style, showlocals=showlocals) @@ -1070,6 +1259,23 @@ def f(): line = tw_mock.lines[-1] assert line == ":3: ValueError" + def test_toterminal_value(self, importasmod, tw_mock): + mod = importasmod( + """ + def g(x): + raise ValueError(x) + def f(): + g('some_value') + """ + ) + excinfo = pytest.raises(ValueError, mod.f) + excinfo.traceback = excinfo.traceback.filter(excinfo) + repr = excinfo.getrepr(style="value") + repr.toterminal(tw_mock) + + assert tw_mock.get_write_msg(0) == "some_value" + assert tw_mock.get_write_msg(1) == "\n" + @pytest.mark.parametrize( "reproptions", [ @@ -1080,9 +1286,7 @@ def f(): "funcargs": funcargs, "tbfilter": tbfilter, }, - id="style={},showlocals={},funcargs={},tbfilter={}".format( - style, showlocals, funcargs, tbfilter - ), + id=f"style={style},showlocals={showlocals},funcargs={funcargs},tbfilter={tbfilter}", ) for style in ["long", "short", "line", "no", "native", "value", "auto"] for showlocals in (True, False) @@ -1090,7 +1294,7 @@ def f(): for funcargs in (True, False) ], ) - def test_format_excinfo(self, reproptions: Dict[str, Any]) -> None: + def test_format_excinfo(self, reproptions: dict[str, Any]) -> None: def bar(): assert False, "some error" @@ -1170,7 +1374,7 @@ def g(): raise ValueError() def h(): - raise AttributeError() + if True: raise AttributeError() """ ) excinfo = pytest.raises(AttributeError, mod.f) @@ -1231,12 +1435,22 @@ def h(): assert tw_mock.lines[40] == ("_ ", None) assert tw_mock.lines[41] == "" assert tw_mock.lines[42] == " def h():" - assert tw_mock.lines[43] == "> raise AttributeError()" - assert tw_mock.lines[44] == "E AttributeError" - assert tw_mock.lines[45] == "" - line = tw_mock.get_write_msg(46) - assert line.endswith("mod.py") - assert tw_mock.lines[47] == ":15: AttributeError" + # On python 3.11 and greater, check for carets in the traceback. + if sys.version_info >= (3, 11): + assert tw_mock.lines[43] == "> if True: raise AttributeError()" + assert tw_mock.lines[44] == " ^^^^^^^^^^^^^^^^^^^^^^" + assert tw_mock.lines[45] == "E AttributeError" + assert tw_mock.lines[46] == "" + line = tw_mock.get_write_msg(47) + assert line.endswith("mod.py") + assert tw_mock.lines[48] == ":15: AttributeError" + else: + assert tw_mock.lines[43] == "> if True: raise AttributeError()" + assert tw_mock.lines[44] == "E AttributeError" + assert tw_mock.lines[45] == "" + line = tw_mock.get_write_msg(46) + assert line.endswith("mod.py") + assert tw_mock.lines[47] == ":15: AttributeError" @pytest.mark.parametrize("mode", ["from_none", "explicit_suppress"]) def test_exc_repr_chain_suppression(self, importasmod, mode, tw_mock): @@ -1246,7 +1460,7 @@ def test_exc_repr_chain_suppression(self, importasmod, mode, tw_mock): """ raise_suffix = " from None" if mode == "from_none" else "" mod = importasmod( - """ + f""" def f(): try: g() @@ -1254,9 +1468,7 @@ def f(): raise AttributeError(){raise_suffix} def g(): raise ValueError() - """.format( - raise_suffix=raise_suffix - ) + """ ) excinfo = pytest.raises(AttributeError, mod.f) r = excinfo.getrepr(style="long", chain=mode != "explicit_suppress") @@ -1268,9 +1480,7 @@ def g(): assert tw_mock.lines[2] == " try:" assert tw_mock.lines[3] == " g()" assert tw_mock.lines[4] == " except Exception:" - assert tw_mock.lines[5] == "> raise AttributeError(){}".format( - raise_suffix - ) + assert tw_mock.lines[5] == f"> raise AttributeError(){raise_suffix}" assert tw_mock.lines[6] == "E AttributeError" assert tw_mock.lines[7] == "" line = tw_mock.get_write_msg(8) @@ -1301,7 +1511,7 @@ def test_exc_chain_repr_without_traceback(self, importasmod, reason, description """ exc_handling_code = " from e" if reason == "cause" else "" mod = importasmod( - """ + f""" def f(): try: g() @@ -1309,16 +1519,14 @@ def f(): raise RuntimeError('runtime problem'){exc_handling_code} def g(): raise ValueError('invalid value') - """.format( - exc_handling_code=exc_handling_code - ) + """ ) with pytest.raises(RuntimeError) as excinfo: mod.f() # emulate the issue described in #1984 - attr = "__%s__" % reason + attr = f"__{reason}__" getattr(excinfo.value, attr).__traceback__ = None r = excinfo.getrepr() @@ -1361,23 +1569,44 @@ def unreraise(): r = excinfo.getrepr(style="short") r.toterminal(tw_mock) out = "\n".join(line for line in tw_mock.lines if isinstance(line, str)) - expected_out = textwrap.dedent( - """\ - :13: in unreraise - reraise() - :10: in reraise - raise Err() from e - E test_exc_chain_repr_cycle0.mod.Err - - During handling of the above exception, another exception occurred: - :15: in unreraise - raise e.__cause__ - :8: in reraise - fail() - :5: in fail - return 0 / 0 - E ZeroDivisionError: division by zero""" - ) + # Assert highlighting carets in python3.11+ + if sys.version_info >= (3, 11): + expected_out = textwrap.dedent( + """\ + :13: in unreraise + reraise() + :10: in reraise + raise Err() from e + E test_exc_chain_repr_cycle0.mod.Err + + During handling of the above exception, another exception occurred: + :15: in unreraise + raise e.__cause__ + :8: in reraise + fail() + :5: in fail + return 0 / 0 + ^^^^^ + E ZeroDivisionError: division by zero""" + ) + else: + expected_out = textwrap.dedent( + """\ + :13: in unreraise + reraise() + :10: in reraise + raise Err() from e + E test_exc_chain_repr_cycle0.mod.Err + + During handling of the above exception, another exception occurred: + :15: in unreraise + raise e.__cause__ + :8: in reraise + fail() + :5: in fail + return 0 / 0 + E ZeroDivisionError: division by zero""" + ) assert out == expected_out def test_exec_type_error_filter(self, importasmod): @@ -1398,7 +1627,7 @@ def f(): @pytest.mark.parametrize("encoding", [None, "utf8", "utf16"]) def test_repr_traceback_with_unicode(style, encoding): if encoding is None: - msg: Union[str, bytes] = "☹" + msg: str | bytes = "☹" else: msg = "☹".encode(encoding) try: @@ -1427,7 +1656,7 @@ def test(tmp_path): result.stderr.no_fnmatch_line("*INTERNALERROR*") -def test_regression_nagative_line_index(pytester: Pytester) -> None: +def test_regression_negative_line_index(pytester: Pytester) -> None: """ With Python 3.10 alphas, there was an INTERNALERROR reported in https://github.com/pytest-dev/pytest/pull/8227 @@ -1496,7 +1725,7 @@ def __getattr__(self, attr): return getattr(self, "_" + attr) with pytest.raises(RuntimeError) as excinfo: - RecursionDepthError().trigger + _ = RecursionDepthError().trigger assert "maximum recursion" in str(excinfo.getrepr()) @@ -1568,6 +1797,9 @@ def test(): rf"FAILED test_excgroup.py::test - {pre_catch}BaseExceptionGroup: Oops \(2.*" ) result.stdout.re_match_lines(match_lines) + # Check for traceback filtering of pytest internals. + result.stdout.no_fnmatch_line("*, line *, in pytest_pyfunc_call") + result.stdout.no_fnmatch_line("*, line *, in pytest_runtest_call") @pytest.mark.skipif( @@ -1587,6 +1819,83 @@ def test_exceptiongroup(pytester: Pytester, outer_chain, inner_chain) -> None: _exceptiongroup_common(pytester, outer_chain, inner_chain, native=False) +def test_exceptiongroup_short_summary_info(pytester: Pytester): + pytester.makepyfile( + """ + import sys + + if sys.version_info < (3, 11): + from exceptiongroup import BaseExceptionGroup, ExceptionGroup + + def test_base() -> None: + raise BaseExceptionGroup("NOT IN SUMMARY", [SystemExit("a" * 10)]) + + def test_nonbase() -> None: + raise ExceptionGroup("NOT IN SUMMARY", [ValueError("a" * 10)]) + + def test_nested() -> None: + raise ExceptionGroup( + "NOT DISPLAYED", [ + ExceptionGroup("NOT IN SUMMARY", [ValueError("a" * 10)]) + ] + ) + + def test_multiple() -> None: + raise ExceptionGroup( + "b" * 10, + [ + ValueError("NOT IN SUMMARY"), + TypeError("NOT IN SUMMARY"), + ] + ) + + def test_nested_multiple() -> None: + raise ExceptionGroup( + "b" * 10, + [ + ExceptionGroup( + "c" * 10, + [ + ValueError("NOT IN SUMMARY"), + TypeError("NOT IN SUMMARY"), + ] + ) + ] + ) + """ + ) + # run with -vv to not truncate summary info, default width in tests is very low + result = pytester.runpytest("-vv") + assert result.ret == 1 + backport_str = "exceptiongroup." if sys.version_info < (3, 11) else "" + result.stdout.fnmatch_lines( + [ + "*= short test summary info =*", + ( + "FAILED test_exceptiongroup_short_summary_info.py::test_base - " + "SystemExit('aaaaaaaaaa') [single exception in BaseExceptionGroup]" + ), + ( + "FAILED test_exceptiongroup_short_summary_info.py::test_nonbase - " + "ValueError('aaaaaaaaaa') [single exception in ExceptionGroup]" + ), + ( + "FAILED test_exceptiongroup_short_summary_info.py::test_nested - " + "ValueError('aaaaaaaaaa') [single exception in ExceptionGroup]" + ), + ( + "FAILED test_exceptiongroup_short_summary_info.py::test_multiple - " + f"{backport_str}ExceptionGroup: bbbbbbbbbb (2 sub-exceptions)" + ), + ( + "FAILED test_exceptiongroup_short_summary_info.py::test_nested_multiple - " + f"{backport_str}ExceptionGroup: bbbbbbbbbb (1 sub-exception)" + ), + "*= 5 failed in *", + ] + ) + + @pytest.mark.parametrize("tbstyle", ("long", "short", "auto", "line", "native")) def test_all_entries_hidden(pytester: Pytester, tbstyle: str) -> None: """Regression test for #10903.""" @@ -1648,3 +1957,51 @@ def test(): ], consecutive=True, ) + + +def add_note(err: BaseException, msg: str) -> None: + """Adds a note to an exception inplace.""" + if sys.version_info < (3, 11): + err.__notes__ = [*getattr(err, "__notes__", []), msg] # type: ignore[attr-defined] + else: + err.add_note(msg) + + +@pytest.mark.parametrize( + "error,notes,match", + [ + (Exception("test"), [], "test"), + (AssertionError("foo"), ["bar"], "bar"), + (AssertionError("foo"), ["bar", "baz"], "bar"), + (AssertionError("foo"), ["bar", "baz"], "baz"), + (ValueError("foo"), ["bar", "baz"], re.compile(r"bar\nbaz", re.MULTILINE)), + (ValueError("foo"), ["bar", "baz"], re.compile(r"BAZ", re.IGNORECASE)), + ], +) +def test_check_error_notes_success( + error: Exception, notes: list[str], match: str +) -> None: + for note in notes: + add_note(error, note) + + with pytest.raises(Exception, match=match): + raise error + + +@pytest.mark.parametrize( + "error, notes, match", + [ + (Exception("test"), [], "foo"), + (AssertionError("foo"), ["bar"], "baz"), + (AssertionError("foo"), ["bar"], "foo\nbaz"), + ], +) +def test_check_error_notes_failure( + error: Exception, notes: list[str], match: str +) -> None: + for note in notes: + add_note(error, note) + + with pytest.raises(AssertionError): + with pytest.raises(type(error), match=match): + raise error diff --git a/testing/code/test_source.py b/testing/code/test_source.py index dc35c9496ab..321372d4b59 100644 --- a/testing/code/test_source.py +++ b/testing/code/test_source.py @@ -1,20 +1,19 @@ -# flake8: noqa -# disable flake check on this file because some constructs are strange -# or redundant on purpose and can't be disable on a line-by-line basis +# mypy: allow-untyped-defs +from __future__ import annotations + import inspect import linecache +from pathlib import Path import sys import textwrap -from pathlib import Path from typing import Any -from typing import Dict -import pytest from _pytest._code import Code from _pytest._code import Frame from _pytest._code import getfslineno from _pytest._code import Source from _pytest.pathlib import import_path +import pytest def test_source_str_function() -> None: @@ -254,7 +253,7 @@ def g(): assert str(g_source).strip() == "def g():\n pass # pragma: no cover" -def test_getfuncsource_with_multine_string() -> None: +def test_getfuncsource_with_multiline_string() -> None: def f(): c = """while True: pass @@ -295,7 +294,7 @@ def method(self): ) path = tmp_path.joinpath("a.py") path.write_text(str(source), encoding="utf-8") - mod: Any = import_path(path, root=tmp_path) + mod: Any = import_path(path, root=tmp_path, consider_namespace_packages=False) s2 = Source(mod.A) assert str(source).strip() == str(s2).strip() @@ -335,7 +334,7 @@ def test_findsource(monkeypatch) -> None: assert src is not None assert "if 1:" in str(src) - d: Dict[str, Any] = {} + d: dict[str, Any] = {} eval(co, d) src, lineno = findsource(d["x"]) assert src is not None @@ -369,7 +368,11 @@ class B: pass B.__name__ = B.__qualname__ = "B2" - assert getfslineno(B)[1] == -1 + # Since Python 3.13 this started working. + if sys.version_info >= (3, 13): + assert getfslineno(B)[1] != -1 + else: + assert getfslineno(B)[1] == -1 def test_code_of_object_instance_with_call() -> None: @@ -439,14 +442,9 @@ def test_comments() -> None: ''' for line in range(2, 6): assert str(getstatement(line, source)) == " x = 1" - if sys.version_info >= (3, 8) or hasattr(sys, "pypy_version_info"): - tqs_start = 8 - else: - tqs_start = 10 - assert str(getstatement(10, source)) == '"""' - for line in range(6, tqs_start): + for line in range(6, 8): assert str(getstatement(line, source)) == " assert False" - for line in range(tqs_start, 10): + for line in range(8, 10): assert str(getstatement(line, source)) == '"""\ncomment 4\n"""' @@ -464,7 +462,6 @@ def test_comment_in_statement() -> None: def test_source_with_decorator() -> None: """Test behavior with Source / Code().source with regard to decorators.""" - from _pytest.compat import get_real_func @pytest.mark.foo def deco_mark(): @@ -478,14 +475,14 @@ def deco_mark(): def deco_fixture(): assert False - src = inspect.getsource(deco_fixture) + src = inspect.getsource(deco_fixture._get_wrapped_function()) assert src == " @pytest.fixture\n def deco_fixture():\n assert False\n" - # currently Source does not unwrap decorators, testing the - # existing behavior here for explicitness, but perhaps we should revisit/change this - # in the future - assert str(Source(deco_fixture)).startswith("@functools.wraps(function)") + # Make sure the decorator is not a wrapped function + assert not str(Source(deco_fixture)).startswith("@functools.wraps(function)") assert ( - textwrap.indent(str(Source(get_real_func(deco_fixture))), " ") + "\n" == src + textwrap.indent(str(Source(deco_fixture._get_wrapped_function())), " ") + + "\n" + == src ) diff --git a/testing/conftest.py b/testing/conftest.py index 8e77fcae5ab..25abce913ea 100644 --- a/testing/conftest.py +++ b/testing/conftest.py @@ -1,11 +1,17 @@ -import dataclasses +# mypy: allow-untyped-defs +from __future__ import annotations + +from collections.abc import Generator +import importlib.metadata import re import sys -from typing import List -import pytest +from packaging.version import Version + from _pytest.monkeypatch import MonkeyPatch from _pytest.pytester import Pytester +import pytest + if sys.gettrace(): @@ -21,11 +27,31 @@ def restore_tracing(): sys.settrace(orig_trace) -@pytest.hookimpl(hookwrapper=True, tryfirst=True) -def pytest_collection_modifyitems(items): +@pytest.fixture(autouse=True) +def set_column_width(monkeypatch: pytest.MonkeyPatch) -> None: + """ + Force terminal width to 80: some tests check the formatting of --help, which is sensible + to terminal width. + """ + monkeypatch.setenv("COLUMNS", "80") + + +@pytest.fixture(autouse=True) +def reset_colors(monkeypatch: pytest.MonkeyPatch) -> None: + """ + Reset all color-related variables to prevent them from affecting internal pytest output + in tests that depend on it. + """ + monkeypatch.delenv("PY_COLORS", raising=False) + monkeypatch.delenv("NO_COLOR", raising=False) + monkeypatch.delenv("FORCE_COLOR", raising=False) + + +@pytest.hookimpl(wrapper=True, tryfirst=True) +def pytest_collection_modifyitems(items) -> Generator[None]: """Prefer faster tests. - Use a hookwrapper to do this in the beginning, so e.g. --ff still works + Use a hook wrapper to do this in the beginning, so e.g. --ff still works correctly. """ fast_items = [] @@ -62,7 +88,7 @@ def pytest_collection_modifyitems(items): items[:] = fast_items + neutral_items + slow_items + slowest_items - yield + return (yield) @pytest.fixture @@ -95,8 +121,8 @@ def markup(self, text, **kw): return text def get_write_msg(self, idx): - flag, msg = self.lines[idx] - assert flag == TWMock.WRITE + assert self.lines[idx][0] == TWMock.WRITE + msg = self.lines[idx][1] return msg fullwidth = 80 @@ -144,15 +170,22 @@ def color_mapping(): Used by tests which check the actual colors output by pytest. """ + # https://github.com/pygments/pygments/commit/d24e272894a56a98b1b718d9ac5fabc20124882a + pygments_version = Version(importlib.metadata.version("pygments")) + pygments_has_kwspace_hl = pygments_version >= Version("2.19") class ColorMapping: COLORS = { "red": "\x1b[31m", "green": "\x1b[32m", "yellow": "\x1b[33m", + "light-gray": "\x1b[90m", + "light-red": "\x1b[91m", + "light-green": "\x1b[92m", "bold": "\x1b[1m", "reset": "\x1b[0m", "kw": "\x1b[94m", + "kwspace": "\x1b[90m \x1b[39;49;00m" if pygments_has_kwspace_hl else " ", "hl-reset": "\x1b[39;49;00m", "function": "\x1b[92m", "number": "\x1b[94m", @@ -161,22 +194,28 @@ class ColorMapping: "endline": "\x1b[90m\x1b[39;49;00m", } RE_COLORS = {k: re.escape(v) for k, v in COLORS.items()} + NO_COLORS = {k: "" for k in COLORS.keys()} @classmethod - def format(cls, lines: List[str]) -> List[str]: + def format(cls, lines: list[str]) -> list[str]: """Straightforward replacement of color names to their ASCII codes.""" return [line.format(**cls.COLORS) for line in lines] @classmethod - def format_for_fnmatch(cls, lines: List[str]) -> List[str]: + def format_for_fnmatch(cls, lines: list[str]) -> list[str]: """Replace color names for use with LineMatcher.fnmatch_lines""" return [line.format(**cls.COLORS).replace("[", "[[]") for line in lines] @classmethod - def format_for_rematch(cls, lines: List[str]) -> List[str]: + def format_for_rematch(cls, lines: list[str]) -> list[str]: """Replace color names for use with LineMatcher.re_match_lines""" return [line.format(**cls.RE_COLORS) for line in lines] + @classmethod + def strip_colors(cls, lines: list[str]) -> list[str]: + """Entirely remove every color code""" + return [line.format(**cls.NO_COLORS) for line in lines] + return ColorMapping @@ -193,24 +232,20 @@ def mock_timing(monkeypatch: MonkeyPatch): Time is static, and only advances through `sleep` calls, thus tests might sleep over large numbers and obtain accurate time() calls at the end, making tests reliable and instant. """ + from _pytest.timing import MockTiming - @dataclasses.dataclass - class MockTiming: - _current_time: float = 1590150050.0 - - def sleep(self, seconds: float) -> None: - self._current_time += seconds - - def time(self) -> float: - return self._current_time + result = MockTiming() + result.patch(monkeypatch) + return result - def patch(self) -> None: - from _pytest import timing - monkeypatch.setattr(timing, "sleep", self.sleep) - monkeypatch.setattr(timing, "time", self.time) - monkeypatch.setattr(timing, "perf_counter", self.time) +@pytest.fixture(autouse=True) +def remove_ci_env_var(monkeypatch: MonkeyPatch, request: pytest.FixtureRequest) -> None: + """Make the test insensitive if it is running in CI or not. - result = MockTiming() - result.patch() - return result + Use `@pytest.mark.keep_ci_var` in a test to avoid applying this fixture, letting the test + see the real `CI` variable (if present). + """ + has_keep_ci_mark = request.node.get_closest_marker("keep_ci_var") is not None + if not has_keep_ci_mark: + monkeypatch.delenv("CI", raising=False) diff --git a/testing/deprecated_test.py b/testing/deprecated_test.py index 3ceed7f5a2c..5d0e69c58c1 100644 --- a/testing/deprecated_test.py +++ b/testing/deprecated_test.py @@ -1,12 +1,14 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + +from pathlib import Path import re import sys -import warnings -from pathlib import Path -import pytest from _pytest import deprecated from _pytest.compat import legacy_path from _pytest.pytester import Pytester +import pytest from pytest import PytestDeprecationWarning @@ -68,50 +70,6 @@ def pytest_runtest_call(self): assert record.filename == __file__ -def test_fscollector_gethookproxy_isinitpath(pytester: Pytester) -> None: - module = pytester.getmodulecol( - """ - def test_foo(): pass - """, - withinit=True, - ) - assert isinstance(module, pytest.Module) - package = module.parent - assert isinstance(package, pytest.Package) - - with pytest.warns(pytest.PytestDeprecationWarning, match="gethookproxy"): - package.gethookproxy(pytester.path) - - with pytest.warns(pytest.PytestDeprecationWarning, match="isinitpath"): - package.isinitpath(pytester.path) - - # The methods on Session are *not* deprecated. - session = module.session - with warnings.catch_warnings(record=True) as rec: - session.gethookproxy(pytester.path) - session.isinitpath(pytester.path) - assert len(rec) == 0 - - -def test_strict_option_is_deprecated(pytester: Pytester) -> None: - """--strict is a deprecated alias to --strict-markers (#7530).""" - pytester.makepyfile( - """ - import pytest - - @pytest.mark.unknown - def test_foo(): pass - """ - ) - result = pytester.runpytest("--strict") - result.stdout.fnmatch_lines( - [ - "'unknown' not found in `markers` configuration option", - "*PytestRemovedIn8Warning: The --strict option is deprecated, use --strict-markers instead.", - ] - ) - - def test_yield_fixture_is_deprecated() -> None: with pytest.warns(DeprecationWarning, match=r"yield_fixture is deprecated"): @@ -165,176 +123,99 @@ def test_hookproxy_warnings_for_pathlib(tmp_path, hooktype, request): ) -def test_warns_none_is_deprecated(): - with pytest.warns( - PytestDeprecationWarning, - match=re.escape( - "Passing None has been deprecated.\n" - "See https://docs.pytest.org/en/latest/how-to/capture-warnings.html" - "#additional-use-cases-of-warnings-in-tests" - " for alternatives in common use cases." - ), - ): - with pytest.warns(None): # type: ignore[call-overload] - pass - - -class TestSkipMsgArgumentDeprecated: - def test_skip_with_msg_is_deprecated(self, pytester: Pytester) -> None: - p = pytester.makepyfile( - """ - import pytest - - def test_skipping_msg(): - pytest.skip(msg="skippedmsg") - """ - ) - result = pytester.runpytest(p) - result.stdout.fnmatch_lines( - [ - "*PytestRemovedIn8Warning: pytest.skip(msg=...) is now deprecated, " - "use pytest.skip(reason=...) instead", - '*pytest.skip(msg="skippedmsg")*', - ] - ) - result.assert_outcomes(skipped=1, warnings=1) - - def test_fail_with_msg_is_deprecated(self, pytester: Pytester) -> None: - p = pytester.makepyfile( - """ - import pytest +def test_hookimpl_warnings_for_pathlib() -> None: + class Plugin: + def pytest_ignore_collect(self, path: object) -> None: + raise NotImplementedError() - def test_failing_msg(): - pytest.fail(msg="failedmsg") - """ - ) - result = pytester.runpytest(p) - result.stdout.fnmatch_lines( - [ - "*PytestRemovedIn8Warning: pytest.fail(msg=...) is now deprecated, " - "use pytest.fail(reason=...) instead", - '*pytest.fail(msg="failedmsg")', - ] - ) - result.assert_outcomes(failed=1, warnings=1) + def pytest_collect_file(self, path: object) -> None: + raise NotImplementedError() - def test_exit_with_msg_is_deprecated(self, pytester: Pytester) -> None: - p = pytester.makepyfile( - """ - import pytest - - def test_exit_msg(): - pytest.exit(msg="exitmsg") - """ - ) - result = pytester.runpytest(p) - result.stdout.fnmatch_lines( - [ - "*PytestRemovedIn8Warning: pytest.exit(msg=...) is now deprecated, " - "use pytest.exit(reason=...) instead", - ] - ) - result.assert_outcomes(warnings=1) + def pytest_pycollect_makemodule(self, path: object) -> None: + raise NotImplementedError() + def pytest_report_header(self, startdir: object) -> str: + raise NotImplementedError() -def test_deprecation_of_cmdline_preparse(pytester: Pytester) -> None: - pytester.makeconftest( - """ - def pytest_cmdline_preparse(config, args): - ... + def pytest_report_collectionfinish(self, startdir: object) -> str: + raise NotImplementedError() - """ - ) - result = pytester.runpytest() - result.stdout.fnmatch_lines( - [ - "*PytestRemovedIn8Warning: The pytest_cmdline_preparse hook is deprecated*", - "*Please use pytest_load_initial_conftests hook instead.*", - ] - ) + pm = pytest.PytestPluginManager() + with pytest.warns( + pytest.PytestRemovedIn9Warning, + match=r"py\.path\.local.* argument is deprecated", + ) as wc: + pm.register(Plugin()) + assert len(wc.list) == 5 def test_node_ctor_fspath_argument_is_deprecated(pytester: Pytester) -> None: mod = pytester.getmodulecol("") + class MyFile(pytest.File): + def collect(self): + raise NotImplementedError() + with pytest.warns( pytest.PytestDeprecationWarning, - match=re.escape("The (fspath: py.path.local) argument to File is deprecated."), + match=re.escape( + "The (fspath: py.path.local) argument to MyFile is deprecated." + ), ): - pytest.File.from_parent( + MyFile.from_parent( parent=mod.parent, fspath=legacy_path("bla"), ) -def test_importing_instance_is_deprecated(pytester: Pytester) -> None: +def test_fixture_disallow_on_marked_functions(): + """Test that applying @pytest.fixture to a marked function warns (#3364).""" with pytest.warns( - pytest.PytestDeprecationWarning, - match=re.escape("The pytest.Instance collector type is deprecated"), - ): - pytest.Instance + pytest.PytestRemovedIn9Warning, + match=r"Marks applied to fixtures have no effect", + ) as record: - with pytest.warns( - pytest.PytestDeprecationWarning, - match=re.escape("The pytest.Instance collector type is deprecated"), - ): - from _pytest.python import Instance # noqa: F401 + @pytest.fixture + @pytest.mark.parametrize("example", ["hello"]) + @pytest.mark.usefixtures("tmp_path") + def foo(): + raise NotImplementedError() + # it's only possible to get one warning here because you're already prevented + # from applying @fixture twice + # ValueError("fixture is being applied more than once to the same function") + assert len(record) == 1 -@pytest.mark.filterwarnings("default") -def test_nose_deprecated_with_setup(pytester: Pytester) -> None: - pytest.importorskip("nose") - pytester.makepyfile( - """ - from nose.tools import with_setup - - def setup_fn_no_op(): - ... - - def teardown_fn_no_op(): - ... - - @with_setup(setup_fn_no_op, teardown_fn_no_op) - def test_omits_warnings(): - ... - """ - ) - output = pytester.runpytest() - message = [ - "*PytestRemovedIn8Warning: Support for nose tests is deprecated and will be removed in a future release.", - "*test_nose_deprecated_with_setup.py::test_omits_warnings is using nose method: `setup_fn_no_op` (setup)", - "*PytestRemovedIn8Warning: Support for nose tests is deprecated and will be removed in a future release.", - "*test_nose_deprecated_with_setup.py::test_omits_warnings is using nose method: `teardown_fn_no_op` (teardown)", - ] - output.stdout.fnmatch_lines(message) - output.assert_outcomes(passed=1) +def test_fixture_disallow_marks_on_fixtures(): + """Test that applying a mark to a fixture warns (#3364).""" + with pytest.warns( + pytest.PytestRemovedIn9Warning, + match=r"Marks applied to fixtures have no effect", + ) as record: -@pytest.mark.filterwarnings("default") -def test_nose_deprecated_setup_teardown(pytester: Pytester) -> None: - pytest.importorskip("nose") - pytester.makepyfile( - """ - class Test: + @pytest.mark.parametrize("example", ["hello"]) + @pytest.mark.usefixtures("tmp_path") + @pytest.fixture + def foo(): + raise NotImplementedError() - def setup(self): - ... + assert len(record) == 2 # one for each mark decorator + # should point to this file + assert all(rec.filename == __file__ for rec in record) - def teardown(self): - ... - def test(self): - ... - """ - ) - output = pytester.runpytest() - message = [ - "*PytestRemovedIn8Warning: Support for nose tests is deprecated and will be removed in a future release.", - "*test_nose_deprecated_setup_teardown.py::Test::test is using nose-specific method: `setup(self)`", - "*To remove this warning, rename it to `setup_method(self)`", - "*PytestRemovedIn8Warning: Support for nose tests is deprecated and will be removed in a future release.", - "*test_nose_deprecated_setup_teardown.py::Test::test is using nose-specific method: `teardown(self)`", - "*To remove this warning, rename it to `teardown_method(self)`", - ] - output.stdout.fnmatch_lines(message) - output.assert_outcomes(passed=1) +def test_fixture_disallowed_between_marks(): + """Test that applying a mark to a fixture warns (#3364).""" + with pytest.warns( + pytest.PytestRemovedIn9Warning, + match=r"Marks applied to fixtures have no effect", + ) as record: + + @pytest.mark.parametrize("example", ["hello"]) + @pytest.fixture + @pytest.mark.usefixtures("tmp_path") + def foo(): + raise NotImplementedError() + + assert len(record) == 2 # one for each mark decorator diff --git a/testing/example_scripts/acceptance/fixture_mock_integration.py b/testing/example_scripts/acceptance/fixture_mock_integration.py index 5b00ac90e1b..e612ae01e66 100644 --- a/testing/example_scripts/acceptance/fixture_mock_integration.py +++ b/testing/example_scripts/acceptance/fixture_mock_integration.py @@ -1,8 +1,13 @@ +# mypy: allow-untyped-defs """Reproduces issue #3774""" + +from __future__ import annotations + from unittest import mock import pytest + config = {"mykey": "ORIGINAL"} diff --git a/testing/example_scripts/collect/collect_init_tests/tests/__init__.py b/testing/example_scripts/collect/collect_init_tests/tests/__init__.py index 9cd366295e7..5e30bb15883 100644 --- a/testing/example_scripts/collect/collect_init_tests/tests/__init__.py +++ b/testing/example_scripts/collect/collect_init_tests/tests/__init__.py @@ -1,2 +1,6 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + + def test_init(): pass diff --git a/testing/example_scripts/collect/collect_init_tests/tests/test_foo.py b/testing/example_scripts/collect/collect_init_tests/tests/test_foo.py index 8f2d73cfa4f..3cb8f1be095 100644 --- a/testing/example_scripts/collect/collect_init_tests/tests/test_foo.py +++ b/testing/example_scripts/collect/collect_init_tests/tests/test_foo.py @@ -1,2 +1,6 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + + def test_foo(): pass diff --git a/testing/example_scripts/collect/package_infinite_recursion/conftest.py b/testing/example_scripts/collect/package_infinite_recursion/conftest.py index 973ccc0c030..c2d2b918874 100644 --- a/testing/example_scripts/collect/package_infinite_recursion/conftest.py +++ b/testing/example_scripts/collect/package_infinite_recursion/conftest.py @@ -1,2 +1,6 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + + def pytest_ignore_collect(collection_path): return False diff --git a/testing/example_scripts/collect/package_infinite_recursion/tests/test_basic.py b/testing/example_scripts/collect/package_infinite_recursion/tests/test_basic.py index f174823854e..38c51e586fc 100644 --- a/testing/example_scripts/collect/package_infinite_recursion/tests/test_basic.py +++ b/testing/example_scripts/collect/package_infinite_recursion/tests/test_basic.py @@ -1,2 +1,6 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + + def test(): pass diff --git a/testing/example_scripts/collect/package_init_given_as_arg/pkg/__init__.py b/testing/example_scripts/collect/package_init_given_as_arg/pkg/__init__.py index e69de29bb2d..5e30bb15883 100644 --- a/testing/example_scripts/collect/package_init_given_as_arg/pkg/__init__.py +++ b/testing/example_scripts/collect/package_init_given_as_arg/pkg/__init__.py @@ -0,0 +1,6 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + + +def test_init(): + pass diff --git a/testing/example_scripts/collect/package_init_given_as_arg/pkg/test_foo.py b/testing/example_scripts/collect/package_init_given_as_arg/pkg/test_foo.py index f174823854e..3cb8f1be095 100644 --- a/testing/example_scripts/collect/package_init_given_as_arg/pkg/test_foo.py +++ b/testing/example_scripts/collect/package_init_given_as_arg/pkg/test_foo.py @@ -1,2 +1,6 @@ -def test(): +# mypy: allow-untyped-defs +from __future__ import annotations + + +def test_foo(): pass diff --git a/testing/example_scripts/config/collect_pytest_prefix/conftest.py b/testing/example_scripts/config/collect_pytest_prefix/conftest.py index 2da4ffe2fed..5e0ab54411b 100644 --- a/testing/example_scripts/config/collect_pytest_prefix/conftest.py +++ b/testing/example_scripts/config/collect_pytest_prefix/conftest.py @@ -1,2 +1,5 @@ +from __future__ import annotations + + class pytest_something: pass diff --git a/testing/example_scripts/config/collect_pytest_prefix/test_foo.py b/testing/example_scripts/config/collect_pytest_prefix/test_foo.py index 8f2d73cfa4f..3cb8f1be095 100644 --- a/testing/example_scripts/config/collect_pytest_prefix/test_foo.py +++ b/testing/example_scripts/config/collect_pytest_prefix/test_foo.py @@ -1,2 +1,6 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + + def test_foo(): pass diff --git a/testing/example_scripts/conftest_usageerror/conftest.py b/testing/example_scripts/conftest_usageerror/conftest.py index 8973e4252d3..a6690bdc303 100644 --- a/testing/example_scripts/conftest_usageerror/conftest.py +++ b/testing/example_scripts/conftest_usageerror/conftest.py @@ -1,3 +1,7 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + + def pytest_configure(config): import pytest diff --git a/testing/example_scripts/customdirectory/conftest.py b/testing/example_scripts/customdirectory/conftest.py new file mode 100644 index 00000000000..4718d7d5be3 --- /dev/null +++ b/testing/example_scripts/customdirectory/conftest.py @@ -0,0 +1,25 @@ +# mypy: allow-untyped-defs +# content of conftest.py +from __future__ import annotations + +import json + +import pytest + + +class ManifestDirectory(pytest.Directory): + def collect(self): + manifest_path = self.path / "manifest.json" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + ihook = self.ihook + for file in manifest["files"]: + yield from ihook.pytest_collect_file( + file_path=self.path / file, parent=self + ) + + +@pytest.hookimpl +def pytest_collect_directory(path, parent): + if path.joinpath("manifest.json").is_file(): + return ManifestDirectory.from_parent(parent=parent, path=path) + return None diff --git a/testing/example_scripts/customdirectory/pytest.ini b/testing/example_scripts/customdirectory/pytest.ini new file mode 100644 index 00000000000..e69de29bb2d diff --git a/testing/example_scripts/customdirectory/tests/manifest.json b/testing/example_scripts/customdirectory/tests/manifest.json new file mode 100644 index 00000000000..6ab6d0a5222 --- /dev/null +++ b/testing/example_scripts/customdirectory/tests/manifest.json @@ -0,0 +1,6 @@ +{ + "files": [ + "test_first.py", + "test_second.py" + ] +} diff --git a/testing/example_scripts/customdirectory/tests/test_first.py b/testing/example_scripts/customdirectory/tests/test_first.py new file mode 100644 index 00000000000..06f40ca4733 --- /dev/null +++ b/testing/example_scripts/customdirectory/tests/test_first.py @@ -0,0 +1,7 @@ +# mypy: allow-untyped-defs +# content of test_first.py +from __future__ import annotations + + +def test_1(): + pass diff --git a/testing/example_scripts/customdirectory/tests/test_second.py b/testing/example_scripts/customdirectory/tests/test_second.py new file mode 100644 index 00000000000..79bcc099e65 --- /dev/null +++ b/testing/example_scripts/customdirectory/tests/test_second.py @@ -0,0 +1,7 @@ +# mypy: allow-untyped-defs +# content of test_second.py +from __future__ import annotations + + +def test_2(): + pass diff --git a/testing/example_scripts/customdirectory/tests/test_third.py b/testing/example_scripts/customdirectory/tests/test_third.py new file mode 100644 index 00000000000..5af476ad44d --- /dev/null +++ b/testing/example_scripts/customdirectory/tests/test_third.py @@ -0,0 +1,7 @@ +# mypy: allow-untyped-defs +# content of test_third.py +from __future__ import annotations + + +def test_3(): + pass diff --git a/testing/example_scripts/dataclasses/test_compare_dataclasses.py b/testing/example_scripts/dataclasses/test_compare_dataclasses.py index d96c90a91bd..18180b99f2d 100644 --- a/testing/example_scripts/dataclasses/test_compare_dataclasses.py +++ b/testing/example_scripts/dataclasses/test_compare_dataclasses.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from dataclasses import dataclass from dataclasses import field diff --git a/testing/example_scripts/dataclasses/test_compare_dataclasses_field_comparison_off.py b/testing/example_scripts/dataclasses/test_compare_dataclasses_field_comparison_off.py index 7479c66c1be..0dcc7ab2802 100644 --- a/testing/example_scripts/dataclasses/test_compare_dataclasses_field_comparison_off.py +++ b/testing/example_scripts/dataclasses/test_compare_dataclasses_field_comparison_off.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from dataclasses import dataclass from dataclasses import field diff --git a/testing/example_scripts/dataclasses/test_compare_dataclasses_verbose.py b/testing/example_scripts/dataclasses/test_compare_dataclasses_verbose.py index 4737ef904e0..4985c69ff30 100644 --- a/testing/example_scripts/dataclasses/test_compare_dataclasses_verbose.py +++ b/testing/example_scripts/dataclasses/test_compare_dataclasses_verbose.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from dataclasses import dataclass from dataclasses import field diff --git a/testing/example_scripts/dataclasses/test_compare_dataclasses_with_custom_eq.py b/testing/example_scripts/dataclasses/test_compare_dataclasses_with_custom_eq.py index e026fe3d192..5ae9a02f99b 100644 --- a/testing/example_scripts/dataclasses/test_compare_dataclasses_with_custom_eq.py +++ b/testing/example_scripts/dataclasses/test_compare_dataclasses_with_custom_eq.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from dataclasses import dataclass from dataclasses import field @@ -8,8 +10,8 @@ class SimpleDataObject: field_a: int = field() field_b: str = field() - def __eq__(self, __o: object) -> bool: - return super().__eq__(__o) + def __eq__(self, o: object, /) -> bool: + return super().__eq__(o) left = SimpleDataObject(1, "b") right = SimpleDataObject(1, "c") diff --git a/testing/example_scripts/dataclasses/test_compare_initvar.py b/testing/example_scripts/dataclasses/test_compare_initvar.py index d859634ddd5..fc589e1fde4 100644 --- a/testing/example_scripts/dataclasses/test_compare_initvar.py +++ b/testing/example_scripts/dataclasses/test_compare_initvar.py @@ -1,3 +1,6 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + from dataclasses import dataclass from dataclasses import InitVar diff --git a/testing/example_scripts/dataclasses/test_compare_recursive_dataclasses.py b/testing/example_scripts/dataclasses/test_compare_recursive_dataclasses.py index 0945790f004..885edd7d9d7 100644 --- a/testing/example_scripts/dataclasses/test_compare_recursive_dataclasses.py +++ b/testing/example_scripts/dataclasses/test_compare_recursive_dataclasses.py @@ -1,3 +1,6 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + from dataclasses import dataclass diff --git a/testing/example_scripts/dataclasses/test_compare_two_different_dataclasses.py b/testing/example_scripts/dataclasses/test_compare_two_different_dataclasses.py index 0a4820c69ba..b45a6772c59 100644 --- a/testing/example_scripts/dataclasses/test_compare_two_different_dataclasses.py +++ b/testing/example_scripts/dataclasses/test_compare_two_different_dataclasses.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from dataclasses import dataclass from dataclasses import field diff --git a/testing/example_scripts/doctest/main_py/__main__.py b/testing/example_scripts/doctest/main_py/__main__.py index e471d06d643..3a0f6bed1d6 100644 --- a/testing/example_scripts/doctest/main_py/__main__.py +++ b/testing/example_scripts/doctest/main_py/__main__.py @@ -1,2 +1,6 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + + def test_this_is_ignored(): assert True diff --git a/testing/example_scripts/doctest/main_py/test_normal_module.py b/testing/example_scripts/doctest/main_py/test_normal_module.py index 700cc9750cf..8c150da5c02 100644 --- a/testing/example_scripts/doctest/main_py/test_normal_module.py +++ b/testing/example_scripts/doctest/main_py/test_normal_module.py @@ -1,3 +1,7 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + + def test_doc(): """ >>> 10 > 5 diff --git a/testing/example_scripts/fixtures/custom_item/conftest.py b/testing/example_scripts/fixtures/custom_item/conftest.py index a7a5e9db80a..274ab97d01b 100644 --- a/testing/example_scripts/fixtures/custom_item/conftest.py +++ b/testing/example_scripts/fixtures/custom_item/conftest.py @@ -1,3 +1,6 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + import pytest diff --git a/testing/example_scripts/fixtures/custom_item/foo/test_foo.py b/testing/example_scripts/fixtures/custom_item/foo/test_foo.py index f174823854e..38c51e586fc 100644 --- a/testing/example_scripts/fixtures/custom_item/foo/test_foo.py +++ b/testing/example_scripts/fixtures/custom_item/foo/test_foo.py @@ -1,2 +1,6 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + + def test(): pass diff --git a/testing/example_scripts/fixtures/fill_fixtures/test_conftest_funcargs_only_available_in_subdir/sub1/conftest.py b/testing/example_scripts/fixtures/fill_fixtures/test_conftest_funcargs_only_available_in_subdir/sub1/conftest.py index be5adbeb6e5..94eaa3e0796 100644 --- a/testing/example_scripts/fixtures/fill_fixtures/test_conftest_funcargs_only_available_in_subdir/sub1/conftest.py +++ b/testing/example_scripts/fixtures/fill_fixtures/test_conftest_funcargs_only_available_in_subdir/sub1/conftest.py @@ -1,3 +1,6 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + import pytest diff --git a/testing/example_scripts/fixtures/fill_fixtures/test_conftest_funcargs_only_available_in_subdir/sub1/test_in_sub1.py b/testing/example_scripts/fixtures/fill_fixtures/test_conftest_funcargs_only_available_in_subdir/sub1/test_in_sub1.py index df36da1369b..cb3f9fbf469 100644 --- a/testing/example_scripts/fixtures/fill_fixtures/test_conftest_funcargs_only_available_in_subdir/sub1/test_in_sub1.py +++ b/testing/example_scripts/fixtures/fill_fixtures/test_conftest_funcargs_only_available_in_subdir/sub1/test_in_sub1.py @@ -1,2 +1,6 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + + def test_1(arg1): pass diff --git a/testing/example_scripts/fixtures/fill_fixtures/test_conftest_funcargs_only_available_in_subdir/sub2/conftest.py b/testing/example_scripts/fixtures/fill_fixtures/test_conftest_funcargs_only_available_in_subdir/sub2/conftest.py index 00981c5dc12..112d1e05f27 100644 --- a/testing/example_scripts/fixtures/fill_fixtures/test_conftest_funcargs_only_available_in_subdir/sub2/conftest.py +++ b/testing/example_scripts/fixtures/fill_fixtures/test_conftest_funcargs_only_available_in_subdir/sub2/conftest.py @@ -1,3 +1,6 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + import pytest diff --git a/testing/example_scripts/fixtures/fill_fixtures/test_conftest_funcargs_only_available_in_subdir/sub2/test_in_sub2.py b/testing/example_scripts/fixtures/fill_fixtures/test_conftest_funcargs_only_available_in_subdir/sub2/test_in_sub2.py index 1c34f94acc4..3dea97f544c 100644 --- a/testing/example_scripts/fixtures/fill_fixtures/test_conftest_funcargs_only_available_in_subdir/sub2/test_in_sub2.py +++ b/testing/example_scripts/fixtures/fill_fixtures/test_conftest_funcargs_only_available_in_subdir/sub2/test_in_sub2.py @@ -1,2 +1,6 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + + def test_2(arg2): pass diff --git a/testing/example_scripts/fixtures/fill_fixtures/test_detect_recursive_dependency_error.py b/testing/example_scripts/fixtures/fill_fixtures/test_detect_recursive_dependency_error.py index d1efcbb338c..d90961ae3c4 100644 --- a/testing/example_scripts/fixtures/fill_fixtures/test_detect_recursive_dependency_error.py +++ b/testing/example_scripts/fixtures/fill_fixtures/test_detect_recursive_dependency_error.py @@ -1,3 +1,6 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + import pytest diff --git a/testing/example_scripts/fixtures/fill_fixtures/test_extend_fixture_conftest_conftest/conftest.py b/testing/example_scripts/fixtures/fill_fixtures/test_extend_fixture_conftest_conftest/conftest.py index 5dfd2f77957..b4fcc17bfc7 100644 --- a/testing/example_scripts/fixtures/fill_fixtures/test_extend_fixture_conftest_conftest/conftest.py +++ b/testing/example_scripts/fixtures/fill_fixtures/test_extend_fixture_conftest_conftest/conftest.py @@ -1,3 +1,6 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + import pytest diff --git a/testing/example_scripts/fixtures/fill_fixtures/test_extend_fixture_conftest_conftest/pkg/conftest.py b/testing/example_scripts/fixtures/fill_fixtures/test_extend_fixture_conftest_conftest/pkg/conftest.py index 4e22ce5a137..b933b70edf3 100644 --- a/testing/example_scripts/fixtures/fill_fixtures/test_extend_fixture_conftest_conftest/pkg/conftest.py +++ b/testing/example_scripts/fixtures/fill_fixtures/test_extend_fixture_conftest_conftest/pkg/conftest.py @@ -1,3 +1,6 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + import pytest diff --git a/testing/example_scripts/fixtures/fill_fixtures/test_extend_fixture_conftest_conftest/pkg/test_spam.py b/testing/example_scripts/fixtures/fill_fixtures/test_extend_fixture_conftest_conftest/pkg/test_spam.py index 0d891fbb503..d31ab971f2b 100644 --- a/testing/example_scripts/fixtures/fill_fixtures/test_extend_fixture_conftest_conftest/pkg/test_spam.py +++ b/testing/example_scripts/fixtures/fill_fixtures/test_extend_fixture_conftest_conftest/pkg/test_spam.py @@ -1,2 +1,6 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + + def test_spam(spam): assert spam == "spamspam" diff --git a/testing/example_scripts/fixtures/fill_fixtures/test_extend_fixture_conftest_module/conftest.py b/testing/example_scripts/fixtures/fill_fixtures/test_extend_fixture_conftest_module/conftest.py index 5dfd2f77957..b4fcc17bfc7 100644 --- a/testing/example_scripts/fixtures/fill_fixtures/test_extend_fixture_conftest_module/conftest.py +++ b/testing/example_scripts/fixtures/fill_fixtures/test_extend_fixture_conftest_module/conftest.py @@ -1,3 +1,6 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + import pytest diff --git a/testing/example_scripts/fixtures/fill_fixtures/test_extend_fixture_conftest_module/test_extend_fixture_conftest_module.py b/testing/example_scripts/fixtures/fill_fixtures/test_extend_fixture_conftest_module/test_extend_fixture_conftest_module.py index 46d1446f470..2d6d7faef61 100644 --- a/testing/example_scripts/fixtures/fill_fixtures/test_extend_fixture_conftest_module/test_extend_fixture_conftest_module.py +++ b/testing/example_scripts/fixtures/fill_fixtures/test_extend_fixture_conftest_module/test_extend_fixture_conftest_module.py @@ -1,3 +1,6 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + import pytest diff --git a/testing/example_scripts/fixtures/fill_fixtures/test_extend_fixture_module_class.py b/testing/example_scripts/fixtures/fill_fixtures/test_extend_fixture_module_class.py index 87a0c894111..45e5deaafea 100644 --- a/testing/example_scripts/fixtures/fill_fixtures/test_extend_fixture_module_class.py +++ b/testing/example_scripts/fixtures/fill_fixtures/test_extend_fixture_module_class.py @@ -1,3 +1,6 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + import pytest diff --git a/testing/example_scripts/fixtures/fill_fixtures/test_funcarg_basic.py b/testing/example_scripts/fixtures/fill_fixtures/test_funcarg_basic.py index 0661cb301fc..1c7a710cd0c 100644 --- a/testing/example_scripts/fixtures/fill_fixtures/test_funcarg_basic.py +++ b/testing/example_scripts/fixtures/fill_fixtures/test_funcarg_basic.py @@ -1,3 +1,6 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + import pytest diff --git a/testing/example_scripts/fixtures/fill_fixtures/test_funcarg_lookup_classlevel.py b/testing/example_scripts/fixtures/fill_fixtures/test_funcarg_lookup_classlevel.py index 256b92a17dd..96f0cacfafd 100644 --- a/testing/example_scripts/fixtures/fill_fixtures/test_funcarg_lookup_classlevel.py +++ b/testing/example_scripts/fixtures/fill_fixtures/test_funcarg_lookup_classlevel.py @@ -1,3 +1,6 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + import pytest diff --git a/testing/example_scripts/fixtures/fill_fixtures/test_funcarg_lookup_modulelevel.py b/testing/example_scripts/fixtures/fill_fixtures/test_funcarg_lookup_modulelevel.py index e15dbd2ca45..b78ca04b3ab 100644 --- a/testing/example_scripts/fixtures/fill_fixtures/test_funcarg_lookup_modulelevel.py +++ b/testing/example_scripts/fixtures/fill_fixtures/test_funcarg_lookup_modulelevel.py @@ -1,3 +1,6 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + import pytest diff --git a/testing/example_scripts/fixtures/fill_fixtures/test_funcarg_lookupfails.py b/testing/example_scripts/fixtures/fill_fixtures/test_funcarg_lookupfails.py index b775203231f..0dd782e4285 100644 --- a/testing/example_scripts/fixtures/fill_fixtures/test_funcarg_lookupfails.py +++ b/testing/example_scripts/fixtures/fill_fixtures/test_funcarg_lookupfails.py @@ -1,3 +1,6 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + import pytest diff --git a/testing/example_scripts/fixtures/test_fixture_named_request.py b/testing/example_scripts/fixtures/test_fixture_named_request.py index 75514bf8b8c..db88bcdabb9 100644 --- a/testing/example_scripts/fixtures/test_fixture_named_request.py +++ b/testing/example_scripts/fixtures/test_fixture_named_request.py @@ -1,3 +1,6 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + import pytest diff --git a/testing/example_scripts/fixtures/test_getfixturevalue_dynamic.py b/testing/example_scripts/fixtures/test_getfixturevalue_dynamic.py index 055a1220b1c..0559905cea4 100644 --- a/testing/example_scripts/fixtures/test_getfixturevalue_dynamic.py +++ b/testing/example_scripts/fixtures/test_getfixturevalue_dynamic.py @@ -1,3 +1,6 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + import pytest diff --git a/testing/example_scripts/issue88_initial_file_multinodes/conftest.py b/testing/example_scripts/issue88_initial_file_multinodes/conftest.py index cb8f5d671ea..2e88c5ad5a9 100644 --- a/testing/example_scripts/issue88_initial_file_multinodes/conftest.py +++ b/testing/example_scripts/issue88_initial_file_multinodes/conftest.py @@ -1,3 +1,6 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + import pytest @@ -11,4 +14,5 @@ def pytest_collect_file(file_path, parent): class MyItem(pytest.Item): - pass + def runtest(self): + raise NotImplementedError() diff --git a/testing/example_scripts/issue88_initial_file_multinodes/test_hello.py b/testing/example_scripts/issue88_initial_file_multinodes/test_hello.py index 56444d14748..b10f874e78d 100644 --- a/testing/example_scripts/issue88_initial_file_multinodes/test_hello.py +++ b/testing/example_scripts/issue88_initial_file_multinodes/test_hello.py @@ -1,2 +1,6 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + + def test_hello(): pass diff --git a/testing/example_scripts/issue_519.py b/testing/example_scripts/issue_519.py index e44367fca04..da5f5ad6aa9 100644 --- a/testing/example_scripts/issue_519.py +++ b/testing/example_scripts/issue_519.py @@ -1,6 +1,7 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + import pprint -from typing import List -from typing import Tuple import pytest @@ -15,7 +16,7 @@ def pytest_generate_tests(metafunc): @pytest.fixture(scope="session") def checked_order(): - order: List[Tuple[str, str, str]] = [] + order: list[tuple[str, str, str]] = [] yield order pprint.pprint(order) diff --git a/testing/example_scripts/marks/marks_considered_keywords/test_marks_as_keywords.py b/testing/example_scripts/marks/marks_considered_keywords/test_marks_as_keywords.py index 35a2c7b7628..c98e58316eb 100644 --- a/testing/example_scripts/marks/marks_considered_keywords/test_marks_as_keywords.py +++ b/testing/example_scripts/marks/marks_considered_keywords/test_marks_as_keywords.py @@ -1,3 +1,6 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + import pytest diff --git a/testing/example_scripts/perf_examples/collect_stats/generate_folders.py b/testing/example_scripts/perf_examples/collect_stats/generate_folders.py index ff1eaf7d6bb..3b580aa341a 100644 --- a/testing/example_scripts/perf_examples/collect_stats/generate_folders.py +++ b/testing/example_scripts/perf_examples/collect_stats/generate_folders.py @@ -1,6 +1,10 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + import argparse import pathlib + HERE = pathlib.Path(__file__).parent TEST_CONTENT = (HERE / "template_test.py").read_bytes() diff --git a/testing/example_scripts/perf_examples/collect_stats/template_test.py b/testing/example_scripts/perf_examples/collect_stats/template_test.py index 064ade190a1..d9449485db6 100644 --- a/testing/example_scripts/perf_examples/collect_stats/template_test.py +++ b/testing/example_scripts/perf_examples/collect_stats/template_test.py @@ -1,2 +1,6 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + + def test_x(): pass diff --git a/testing/example_scripts/tmpdir/tmp_path_fixture.py b/testing/example_scripts/tmpdir/tmp_path_fixture.py index 8675eb2fa62..503ead473e7 100644 --- a/testing/example_scripts/tmpdir/tmp_path_fixture.py +++ b/testing/example_scripts/tmpdir/tmp_path_fixture.py @@ -1,3 +1,6 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + import pytest diff --git a/testing/example_scripts/unittest/test_parametrized_fixture_error_message.py b/testing/example_scripts/unittest/test_parametrized_fixture_error_message.py index d421ce927c9..733202915e4 100644 --- a/testing/example_scripts/unittest/test_parametrized_fixture_error_message.py +++ b/testing/example_scripts/unittest/test_parametrized_fixture_error_message.py @@ -1,3 +1,6 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + import unittest import pytest diff --git a/testing/example_scripts/unittest/test_setup_skip.py b/testing/example_scripts/unittest/test_setup_skip.py index 93f79bb3b2e..52ff96ea8be 100644 --- a/testing/example_scripts/unittest/test_setup_skip.py +++ b/testing/example_scripts/unittest/test_setup_skip.py @@ -1,4 +1,8 @@ +# mypy: allow-untyped-defs """Skipping an entire subclass with unittest.skip() should *not* call setUp from a base class.""" + +from __future__ import annotations + import unittest diff --git a/testing/example_scripts/unittest/test_setup_skip_class.py b/testing/example_scripts/unittest/test_setup_skip_class.py index 4f251dcba17..fe431d8e794 100644 --- a/testing/example_scripts/unittest/test_setup_skip_class.py +++ b/testing/example_scripts/unittest/test_setup_skip_class.py @@ -1,4 +1,8 @@ +# mypy: allow-untyped-defs """Skipping an entire subclass with unittest.skip() should *not* call setUpClass from a base class.""" + +from __future__ import annotations + import unittest diff --git a/testing/example_scripts/unittest/test_setup_skip_module.py b/testing/example_scripts/unittest/test_setup_skip_module.py index 98befbe510f..07fd96c9cef 100644 --- a/testing/example_scripts/unittest/test_setup_skip_module.py +++ b/testing/example_scripts/unittest/test_setup_skip_module.py @@ -1,4 +1,8 @@ +# mypy: allow-untyped-defs """setUpModule is always called, even if all tests in the module are skipped""" + +from __future__ import annotations + import unittest diff --git a/testing/example_scripts/unittest/test_unittest_asyncio.py b/testing/example_scripts/unittest/test_unittest_asyncio.py index 1cd2168604c..8792492b38d 100644 --- a/testing/example_scripts/unittest/test_unittest_asyncio.py +++ b/testing/example_scripts/unittest/test_unittest_asyncio.py @@ -1,8 +1,10 @@ -from typing import List +# mypy: allow-untyped-defs +from __future__ import annotations + from unittest import IsolatedAsyncioTestCase -teardowns: List[None] = [] +teardowns: list[None] = [] class AsyncArguments(IsolatedAsyncioTestCase): diff --git a/testing/example_scripts/unittest/test_unittest_asynctest.py b/testing/example_scripts/unittest/test_unittest_asynctest.py index fb26617067c..8a93366b9a3 100644 --- a/testing/example_scripts/unittest/test_unittest_asynctest.py +++ b/testing/example_scripts/unittest/test_unittest_asynctest.py @@ -1,11 +1,14 @@ +# mypy: allow-untyped-defs """Issue #7110""" + +from __future__ import annotations + import asyncio -from typing import List import asynctest -teardowns: List[None] = [] +teardowns: list[None] = [] class Test(asynctest.TestCase): diff --git a/testing/example_scripts/unittest/test_unittest_plain_async.py b/testing/example_scripts/unittest/test_unittest_plain_async.py index 78dfece684e..ea1ae371551 100644 --- a/testing/example_scripts/unittest/test_unittest_plain_async.py +++ b/testing/example_scripts/unittest/test_unittest_plain_async.py @@ -1,3 +1,6 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + import unittest diff --git a/testing/example_scripts/warnings/test_group_warnings_by_message.py b/testing/example_scripts/warnings/test_group_warnings_by_message.py index 6985caa4407..ee3bc2bbee4 100644 --- a/testing/example_scripts/warnings/test_group_warnings_by_message.py +++ b/testing/example_scripts/warnings/test_group_warnings_by_message.py @@ -1,3 +1,6 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + import warnings import pytest diff --git a/testing/example_scripts/warnings/test_group_warnings_by_message_summary/test_1.py b/testing/example_scripts/warnings/test_group_warnings_by_message_summary/test_1.py index b8c11cb71c9..cc514bafbe9 100644 --- a/testing/example_scripts/warnings/test_group_warnings_by_message_summary/test_1.py +++ b/testing/example_scripts/warnings/test_group_warnings_by_message_summary/test_1.py @@ -1,3 +1,6 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + import warnings import pytest diff --git a/testing/example_scripts/warnings/test_group_warnings_by_message_summary/test_2.py b/testing/example_scripts/warnings/test_group_warnings_by_message_summary/test_2.py index 636d04a5505..33d5ce8ce34 100644 --- a/testing/example_scripts/warnings/test_group_warnings_by_message_summary/test_2.py +++ b/testing/example_scripts/warnings/test_group_warnings_by_message_summary/test_2.py @@ -1,3 +1,6 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + from test_1 import func diff --git a/testing/examples/test_issue519.py b/testing/examples/test_issue519.py index 7b9c109889e..80f78d843a2 100644 --- a/testing/examples/test_issue519.py +++ b/testing/examples/test_issue519.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from _pytest.pytester import Pytester diff --git a/testing/freeze/create_executable.py b/testing/freeze/create_executable.py index 998df7b1ca7..2015d22c7c0 100644 --- a/testing/freeze/create_executable.py +++ b/testing/freeze/create_executable.py @@ -1,11 +1,16 @@ """Generate an executable with pytest runner embedded using PyInstaller.""" + +from __future__ import annotations + + if __name__ == "__main__": - import pytest import subprocess + import pytest + hidden = [] for x in pytest.freeze_includes(): hidden.extend(["--hidden-import", x]) hidden.extend(["--hidden-import", "distutils"]) - args = ["pyinstaller", "--noconfirm"] + hidden + ["runtests_script.py"] + args = ["pyinstaller", "--noconfirm", *hidden, "runtests_script.py"] subprocess.check_call(" ".join(args), shell=True) diff --git a/testing/freeze/runtests_script.py b/testing/freeze/runtests_script.py index 591863016ac..286c98ac539 100644 --- a/testing/freeze/runtests_script.py +++ b/testing/freeze/runtests_script.py @@ -3,8 +3,12 @@ pytest main(). """ +from __future__ import annotations + + if __name__ == "__main__": import sys + import pytest sys.exit(pytest.main()) diff --git a/testing/freeze/tests/test_trivial.py b/testing/freeze/tests/test_trivial.py index 08a55552abb..000ca97310c 100644 --- a/testing/freeze/tests/test_trivial.py +++ b/testing/freeze/tests/test_trivial.py @@ -1,3 +1,7 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + + def test_upper(): assert "foo".upper() == "FOO" diff --git a/testing/freeze/tox_run.py b/testing/freeze/tox_run.py index 678a69c858a..38c1e75cf10 100644 --- a/testing/freeze/tox_run.py +++ b/testing/freeze/tox_run.py @@ -2,6 +2,10 @@ Called by tox.ini: uses the generated executable to run the tests in ./tests/ directory. """ + +from __future__ import annotations + + if __name__ == "__main__": import os import sys @@ -9,4 +13,4 @@ executable = os.path.join(os.getcwd(), "dist", "runtests_script", "runtests_script") if sys.platform.startswith("win"): executable += ".exe" - sys.exit(os.system("%s tests" % executable)) + sys.exit(os.system(f"{executable} tests")) diff --git a/testing/io/test_pprint.py b/testing/io/test_pprint.py new file mode 100644 index 00000000000..1326ef34b2e --- /dev/null +++ b/testing/io/test_pprint.py @@ -0,0 +1,408 @@ +from __future__ import annotations + +from collections import ChainMap +from collections import Counter +from collections import defaultdict +from collections import deque +from collections import OrderedDict +from dataclasses import dataclass +import textwrap +from types import MappingProxyType +from types import SimpleNamespace +from typing import Any + +from _pytest._io.pprint import PrettyPrinter +import pytest + + +@dataclass +class EmptyDataclass: + pass + + +@dataclass +class DataclassWithOneItem: + foo: str + + +@dataclass +class DataclassWithTwoItems: + foo: str + bar: str + + +@pytest.mark.parametrize( + ("data", "expected"), + ( + pytest.param( + EmptyDataclass(), + "EmptyDataclass()", + id="dataclass-empty", + ), + pytest.param( + DataclassWithOneItem(foo="bar"), + """ + DataclassWithOneItem( + foo='bar', + ) + """, + id="dataclass-one-item", + ), + pytest.param( + DataclassWithTwoItems(foo="foo", bar="bar"), + """ + DataclassWithTwoItems( + foo='foo', + bar='bar', + ) + """, + id="dataclass-two-items", + ), + pytest.param( + {}, + "{}", + id="dict-empty", + ), + pytest.param( + {"one": 1}, + """ + { + 'one': 1, + } + """, + id="dict-one-item", + ), + pytest.param( + {"one": 1, "two": 2}, + """ + { + 'one': 1, + 'two': 2, + } + """, + id="dict-two-items", + ), + pytest.param(OrderedDict(), "OrderedDict()", id="ordereddict-empty"), + pytest.param( + OrderedDict({"one": 1}), + """ + OrderedDict({ + 'one': 1, + }) + """, + id="ordereddict-one-item", + ), + pytest.param( + OrderedDict({"one": 1, "two": 2}), + """ + OrderedDict({ + 'one': 1, + 'two': 2, + }) + """, + id="ordereddict-two-items", + ), + pytest.param( + [], + "[]", + id="list-empty", + ), + pytest.param( + [1], + """ + [ + 1, + ] + """, + id="list-one-item", + ), + pytest.param( + [1, 2], + """ + [ + 1, + 2, + ] + """, + id="list-two-items", + ), + pytest.param( + tuple(), + "()", + id="tuple-empty", + ), + pytest.param( + (1,), + """ + ( + 1, + ) + """, + id="tuple-one-item", + ), + pytest.param( + (1, 2), + """ + ( + 1, + 2, + ) + """, + id="tuple-two-items", + ), + pytest.param( + set(), + "set()", + id="set-empty", + ), + pytest.param( + {1}, + """ + { + 1, + } + """, + id="set-one-item", + ), + pytest.param( + {1, 2}, + """ + { + 1, + 2, + } + """, + id="set-two-items", + ), + pytest.param( + MappingProxyType({}), + "mappingproxy({})", + id="mappingproxy-empty", + ), + pytest.param( + MappingProxyType({"one": 1}), + """ + mappingproxy({ + 'one': 1, + }) + """, + id="mappingproxy-one-item", + ), + pytest.param( + MappingProxyType({"one": 1, "two": 2}), + """ + mappingproxy({ + 'one': 1, + 'two': 2, + }) + """, + id="mappingproxy-two-items", + ), + pytest.param( + SimpleNamespace(), + "namespace()", + id="simplenamespace-empty", + ), + pytest.param( + SimpleNamespace(one=1), + """ + namespace( + one=1, + ) + """, + id="simplenamespace-one-item", + ), + pytest.param( + SimpleNamespace(one=1, two=2), + """ + namespace( + one=1, + two=2, + ) + """, + id="simplenamespace-two-items", + ), + pytest.param( + defaultdict(str), "defaultdict(, {})", id="defaultdict-empty" + ), + pytest.param( + defaultdict(str, {"one": "1"}), + """ + defaultdict(, { + 'one': '1', + }) + """, + id="defaultdict-one-item", + ), + pytest.param( + defaultdict(str, {"one": "1", "two": "2"}), + """ + defaultdict(, { + 'one': '1', + 'two': '2', + }) + """, + id="defaultdict-two-items", + ), + pytest.param( + Counter(), + "Counter()", + id="counter-empty", + ), + pytest.param( + Counter("1"), + """ + Counter({ + '1': 1, + }) + """, + id="counter-one-item", + ), + pytest.param( + Counter("121"), + """ + Counter({ + '1': 2, + '2': 1, + }) + """, + id="counter-two-items", + ), + pytest.param(ChainMap(), "ChainMap({})", id="chainmap-empty"), + pytest.param( + ChainMap({"one": 1, "two": 2}), + """ + ChainMap( + { + 'one': 1, + 'two': 2, + }, + ) + """, + id="chainmap-one-item", + ), + pytest.param( + ChainMap({"one": 1}, {"two": 2}), + """ + ChainMap( + { + 'one': 1, + }, + { + 'two': 2, + }, + ) + """, + id="chainmap-two-items", + ), + pytest.param( + deque(), + "deque([])", + id="deque-empty", + ), + pytest.param( + deque([1]), + """ + deque([ + 1, + ]) + """, + id="deque-one-item", + ), + pytest.param( + deque([1, 2]), + """ + deque([ + 1, + 2, + ]) + """, + id="deque-two-items", + ), + pytest.param( + deque([1, 2], maxlen=3), + """ + deque(maxlen=3, [ + 1, + 2, + ]) + """, + id="deque-maxlen", + ), + pytest.param( + { + "chainmap": ChainMap({"one": 1}, {"two": 2}), + "counter": Counter("122"), + "dataclass": DataclassWithTwoItems(foo="foo", bar="bar"), + "defaultdict": defaultdict(str, {"one": "1", "two": "2"}), + "deque": deque([1, 2], maxlen=3), + "dict": {"one": 1, "two": 2}, + "list": [1, 2], + "mappingproxy": MappingProxyType({"one": 1, "two": 2}), + "ordereddict": OrderedDict({"one": 1, "two": 2}), + "set": {1, 2}, + "simplenamespace": SimpleNamespace(one=1, two=2), + "tuple": (1, 2), + }, + """ + { + 'chainmap': ChainMap( + { + 'one': 1, + }, + { + 'two': 2, + }, + ), + 'counter': Counter({ + '2': 2, + '1': 1, + }), + 'dataclass': DataclassWithTwoItems( + foo='foo', + bar='bar', + ), + 'defaultdict': defaultdict(, { + 'one': '1', + 'two': '2', + }), + 'deque': deque(maxlen=3, [ + 1, + 2, + ]), + 'dict': { + 'one': 1, + 'two': 2, + }, + 'list': [ + 1, + 2, + ], + 'mappingproxy': mappingproxy({ + 'one': 1, + 'two': 2, + }), + 'ordereddict': OrderedDict({ + 'one': 1, + 'two': 2, + }), + 'set': { + 1, + 2, + }, + 'simplenamespace': namespace( + one=1, + two=2, + ), + 'tuple': ( + 1, + 2, + ), + } + """, + id="deep-example", + ), + ), +) +def test_consistent_pretty_printer(data: Any, expected: str) -> None: + assert PrettyPrinter().pformat(data) == textwrap.dedent(expected).strip() diff --git a/testing/io/test_saferepr.py b/testing/io/test_saferepr.py index 24746bc2235..075d40cdf44 100644 --- a/testing/io/test_saferepr.py +++ b/testing/io/test_saferepr.py @@ -1,8 +1,10 @@ -import pytest -from _pytest._io.saferepr import _pformat_dispatch +# mypy: allow-untyped-defs +from __future__ import annotations + from _pytest._io.saferepr import DEFAULT_REPR_MAX_SIZE from _pytest._io.saferepr import saferepr from _pytest._io.saferepr import saferepr_unlimited +import pytest def test_simple_repr(): @@ -59,9 +61,7 @@ class BrokenReprException(Exception): obj = BrokenRepr(BrokenReprException("omg even worse")) s2 = saferepr(obj) assert s2 == ( - "<[unpresentable exception ({!s}) raised in repr()] BrokenRepr object at 0x{:x}>".format( - exp_exc, id(obj) - ) + f"<[unpresentable exception ({exp_exc!s}) raised in repr()] BrokenRepr object at 0x{id(obj):x}>" ) @@ -81,7 +81,7 @@ def raise_exc(self, *args): raise self.exc_type(*args) raise self.exc_type - def __str__(self): + def __str__(self): # noqa: PLE0307 self.raise_exc("__str__") def __repr__(self): @@ -99,14 +99,12 @@ def __repr__(self): baseexc_str = BaseException("__str__") obj = BrokenObj(RaisingOnStrRepr([BaseException])) assert saferepr(obj) == ( - "<[unpresentable exception ({!r}) " - "raised in repr()] BrokenObj object at 0x{:x}>".format(baseexc_str, id(obj)) + f"<[unpresentable exception ({baseexc_str!r}) " + f"raised in repr()] BrokenObj object at 0x{id(obj):x}>" ) obj = BrokenObj(RaisingOnStrRepr([RaisingOnStrRepr([BaseException])])) assert saferepr(obj) == ( - "<[{!r} raised in repr()] BrokenObj object at 0x{:x}>".format( - baseexc_str, id(obj) - ) + f"<[{baseexc_str!r} raised in repr()] BrokenObj object at 0x{id(obj):x}>" ) with pytest.raises(KeyboardInterrupt): @@ -148,7 +146,7 @@ def test_big_repr(): def test_repr_on_newstyle() -> None: class Function: def __repr__(self): - return "<%s>" % (self.name) # type: ignore[attr-defined] + return f"<{self.name}>" # type: ignore[attr-defined] assert saferepr(Function()) @@ -159,12 +157,6 @@ def test_unicode(): assert saferepr(val) == reprval -def test_pformat_dispatch(): - assert _pformat_dispatch("a") == "'a'" - assert _pformat_dispatch("a" * 10, width=5) == "'aaaaaaaaaa'" - assert _pformat_dispatch("foo bar", width=5) == "('foo '\n 'bar')" - - def test_broken_getattribute(): """saferepr() can create proper representations of classes with broken __getattribute__ (#7145) diff --git a/testing/io/test_terminalwriter.py b/testing/io/test_terminalwriter.py index b5a04a99f18..1f38d6f15d9 100644 --- a/testing/io/test_terminalwriter.py +++ b/testing/io/test_terminalwriter.py @@ -1,15 +1,19 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + +from collections.abc import Generator import io +from io import StringIO import os +from pathlib import Path import re import shutil import sys -from pathlib import Path -from typing import Generator from unittest import mock -import pytest from _pytest._io import terminalwriter from _pytest.monkeypatch import MonkeyPatch +import pytest # These tests were initially copied from py 1.8.1. @@ -64,9 +68,8 @@ def test_terminalwriter_not_unicode() -> None: class TestTerminalWriter: @pytest.fixture(params=["path", "stringio"]) - def tw( - self, request, tmp_path: Path - ) -> Generator[terminalwriter.TerminalWriter, None, None]: + def tw(self, request, tmp_path: Path) -> Generator[terminalwriter.TerminalWriter]: + f: io.TextIOWrapper | StringIO if request.param == "path": p = tmp_path.joinpath("tmpfile") f = open(str(p), "w+", encoding="utf8") @@ -164,53 +167,67 @@ def test_attr_hasmarkup() -> None: assert "\x1b[0m" in s -def assert_color_set(): +def assert_color(expected: bool, default: bool | None = None) -> None: file = io.StringIO() - tw = terminalwriter.TerminalWriter(file) - assert tw.hasmarkup + if default is None: + default = not expected + file.isatty = lambda: default # type: ignore + tw = terminalwriter.TerminalWriter(file=file) + assert tw.hasmarkup is expected tw.line("hello", bold=True) s = file.getvalue() - assert len(s) > len("hello\n") - assert "\x1b[1m" in s - assert "\x1b[0m" in s - - -def assert_color_not_set(): - f = io.StringIO() - f.isatty = lambda: True # type: ignore - tw = terminalwriter.TerminalWriter(file=f) - assert not tw.hasmarkup - tw.line("hello", bold=True) - s = f.getvalue() - assert s == "hello\n" + if expected: + assert len(s) > len("hello\n") + assert "\x1b[1m" in s + assert "\x1b[0m" in s + else: + assert s == "hello\n" def test_should_do_markup_PY_COLORS_eq_1(monkeypatch: MonkeyPatch) -> None: monkeypatch.setitem(os.environ, "PY_COLORS", "1") - assert_color_set() + assert_color(True) def test_should_not_do_markup_PY_COLORS_eq_0(monkeypatch: MonkeyPatch) -> None: monkeypatch.setitem(os.environ, "PY_COLORS", "0") - assert_color_not_set() + assert_color(False) def test_should_not_do_markup_NO_COLOR(monkeypatch: MonkeyPatch) -> None: monkeypatch.setitem(os.environ, "NO_COLOR", "1") - assert_color_not_set() + assert_color(False) def test_should_do_markup_FORCE_COLOR(monkeypatch: MonkeyPatch) -> None: monkeypatch.setitem(os.environ, "FORCE_COLOR", "1") - assert_color_set() + assert_color(True) -def test_should_not_do_markup_NO_COLOR_and_FORCE_COLOR( +@pytest.mark.parametrize( + ["NO_COLOR", "FORCE_COLOR", "expected"], + [ + ("1", "1", False), + ("", "1", True), + ("1", "", False), + ], +) +def test_NO_COLOR_and_FORCE_COLOR( monkeypatch: MonkeyPatch, + NO_COLOR: str, + FORCE_COLOR: str, + expected: bool, ) -> None: - monkeypatch.setitem(os.environ, "NO_COLOR", "1") - monkeypatch.setitem(os.environ, "FORCE_COLOR", "1") - assert_color_not_set() + monkeypatch.setitem(os.environ, "NO_COLOR", NO_COLOR) + monkeypatch.setitem(os.environ, "FORCE_COLOR", FORCE_COLOR) + assert_color(expected) + + +def test_empty_NO_COLOR_and_FORCE_COLOR_ignored(monkeypatch: MonkeyPatch) -> None: + monkeypatch.setitem(os.environ, "NO_COLOR", "") + monkeypatch.setitem(os.environ, "FORCE_COLOR", "") + assert_color(True, True) + assert_color(False, False) class TestTerminalWriterLineWidth: @@ -254,7 +271,7 @@ def test_combining(self) -> None: pytest.param( True, True, - "{kw}assert{hl-reset} {number}0{hl-reset}{endline}\n", + "{reset}{kw}assert{hl-reset} {number}0{hl-reset}{endline}\n", id="with markup and code_highlight", ), pytest.param( @@ -291,3 +308,17 @@ def test_code_highlight(has_markup, code_highlight, expected, color_mapping): match=re.escape("indents size (2) should have same size as lines (1)"), ): tw._write_source(["assert 0"], [" ", " "]) + + +def test_highlight_empty_source() -> None: + """Don't crash trying to highlight empty source code. + + Issue #11758. + """ + f = io.StringIO() + tw = terminalwriter.TerminalWriter(f) + tw.hasmarkup = True + tw.code_highlight = True + tw._write_source([]) + + assert f.getvalue() == "" diff --git a/testing/io/test_wcwidth.py b/testing/io/test_wcwidth.py index 7cc74df5d07..9ff1ad06e60 100644 --- a/testing/io/test_wcwidth.py +++ b/testing/io/test_wcwidth.py @@ -1,6 +1,8 @@ -import pytest +from __future__ import annotations + from _pytest._io.wcwidth import wcswidth from _pytest._io.wcwidth import wcwidth +import pytest @pytest.mark.parametrize( @@ -11,11 +13,11 @@ ("a", 1), ("1", 1), ("א", 1), - ("\u200B", 0), - ("\u1ABE", 0), + ("\u200b", 0), + ("\u1abe", 0), ("\u0591", 0), ("🉐", 2), - ("$", 2), + ("$", 2), # noqa: RUF001 ], ) def test_wcwidth(c: str, expected: int) -> None: diff --git a/testing/logging/test_fixture.py b/testing/logging/test_fixture.py index 8eaa2de96a8..5f94cb8508a 100644 --- a/testing/logging/test_fixture.py +++ b/testing/logging/test_fixture.py @@ -1,16 +1,21 @@ # mypy: disable-error-code="attr-defined" +# mypy: disallow-untyped-defs +from __future__ import annotations + +from collections.abc import Iterator import logging -import pytest from _pytest.logging import caplog_records_key from _pytest.pytester import Pytester +import pytest + logger = logging.getLogger(__name__) sublogger = logging.getLogger(__name__ + ".baz") -@pytest.fixture -def cleanup_disabled_logging(): +@pytest.fixture(autouse=True) +def cleanup_disabled_logging() -> Iterator[None]: """Simple fixture that ensures that a test doesn't disable logging. This is necessary because ``logging.disable()`` is global, so a test disabling logging @@ -27,7 +32,7 @@ def test_fixture_help(pytester: Pytester) -> None: result.stdout.fnmatch_lines(["*caplog*"]) -def test_change_level(caplog): +def test_change_level(caplog: pytest.LogCaptureFixture) -> None: caplog.set_level(logging.INFO) logger.debug("handler DEBUG level") logger.info("handler INFO level") @@ -42,7 +47,7 @@ def test_change_level(caplog): assert "CRITICAL" in caplog.text -def test_change_level_logging_disabled(caplog, cleanup_disabled_logging): +def test_change_level_logging_disabled(caplog: pytest.LogCaptureFixture) -> None: logging.disable(logging.CRITICAL) assert logging.root.manager.disable == logging.CRITICAL caplog.set_level(logging.WARNING) @@ -85,9 +90,7 @@ def test2(caplog): result.stdout.no_fnmatch_line("*log from test2*") -def test_change_disabled_level_undo( - pytester: Pytester, cleanup_disabled_logging -) -> None: +def test_change_disabled_level_undo(pytester: Pytester) -> None: """Ensure that '_force_enable_logging' in 'set_level' is undone after the end of the test. Tests the logging output themselves (affected by disabled logging level). @@ -116,7 +119,7 @@ def test2(caplog): result.stdout.no_fnmatch_line("*log from test2*") -def test_change_level_undos_handler_level(pytester: Pytester) -> None: +def test_change_level_undoes_handler_level(pytester: Pytester) -> None: """Ensure that 'set_level' is undone after the end of the test (handler). Issue #7569. Tests the handler level specifically. @@ -144,7 +147,7 @@ def test3(caplog): result.assert_outcomes(passed=3) -def test_with_statement(caplog): +def test_with_statement_at_level(caplog: pytest.LogCaptureFixture) -> None: with caplog.at_level(logging.INFO): logger.debug("handler DEBUG level") logger.info("handler INFO level") @@ -159,7 +162,9 @@ def test_with_statement(caplog): assert "CRITICAL" in caplog.text -def test_with_statement_logging_disabled(caplog, cleanup_disabled_logging): +def test_with_statement_at_level_logging_disabled( + caplog: pytest.LogCaptureFixture, +) -> None: logging.disable(logging.CRITICAL) assert logging.root.manager.disable == logging.CRITICAL with caplog.at_level(logging.WARNING): @@ -185,6 +190,22 @@ def test_with_statement_logging_disabled(caplog, cleanup_disabled_logging): assert logging.root.manager.disable == logging.CRITICAL +def test_with_statement_filtering(caplog: pytest.LogCaptureFixture) -> None: + class TestFilter(logging.Filter): + def filter(self, record: logging.LogRecord) -> bool: + record.msg = "filtered handler call" + return True + + with caplog.at_level(logging.INFO): + with caplog.filtering(TestFilter()): + logger.info("handler call") + logger.info("handler call") + + filtered_tuple, unfiltered_tuple = caplog.record_tuples + assert filtered_tuple == ("test_fixture", 20, "filtered handler call") + assert unfiltered_tuple == ("test_fixture", 20, "handler call") + + @pytest.mark.parametrize( "level_str,expected_disable_level", [ @@ -198,8 +219,8 @@ def test_with_statement_logging_disabled(caplog, cleanup_disabled_logging): ], ) def test_force_enable_logging_level_string( - caplog, cleanup_disabled_logging, level_str, expected_disable_level -): + caplog: pytest.LogCaptureFixture, level_str: str, expected_disable_level: int +) -> None: """Test _force_enable_logging using a level string. ``expected_disable_level`` is one level below ``level_str`` because the disabled log level @@ -218,7 +239,7 @@ def test_force_enable_logging_level_string( assert test_logger.manager.disable == expected_disable_level -def test_log_access(caplog): +def test_log_access(caplog: pytest.LogCaptureFixture) -> None: caplog.set_level(logging.INFO) logger.info("boo %s", "arg") assert caplog.records[0].levelname == "INFO" @@ -226,7 +247,7 @@ def test_log_access(caplog): assert "boo arg" in caplog.text -def test_messages(caplog): +def test_messages(caplog: pytest.LogCaptureFixture) -> None: caplog.set_level(logging.INFO) logger.info("boo %s", "arg") logger.info("bar %s\nbaz %s", "arg1", "arg2") @@ -247,14 +268,14 @@ def test_messages(caplog): assert "Exception" not in caplog.messages[-1] -def test_record_tuples(caplog): +def test_record_tuples(caplog: pytest.LogCaptureFixture) -> None: caplog.set_level(logging.INFO) logger.info("boo %s", "arg") assert caplog.record_tuples == [(__name__, logging.INFO, "boo arg")] -def test_unicode(caplog): +def test_unicode(caplog: pytest.LogCaptureFixture) -> None: caplog.set_level(logging.INFO) logger.info("bū") assert caplog.records[0].levelname == "INFO" @@ -262,7 +283,7 @@ def test_unicode(caplog): assert "bū" in caplog.text -def test_clear(caplog): +def test_clear(caplog: pytest.LogCaptureFixture) -> None: caplog.set_level(logging.INFO) logger.info("bū") assert len(caplog.records) @@ -273,7 +294,9 @@ def test_clear(caplog): @pytest.fixture -def logging_during_setup_and_teardown(caplog): +def logging_during_setup_and_teardown( + caplog: pytest.LogCaptureFixture, +) -> Iterator[None]: caplog.set_level("INFO") logger.info("a_setup_log") yield @@ -281,7 +304,17 @@ def logging_during_setup_and_teardown(caplog): assert [x.message for x in caplog.get_records("teardown")] == ["a_teardown_log"] -def test_caplog_captures_for_all_stages(caplog, logging_during_setup_and_teardown): +def private_assert_caplog_records_is_setup_call( + caplog: pytest.LogCaptureFixture, +) -> None: + # This reaches into private API, don't use this type of thing in real tests! + caplog_records = caplog._item.stash[caplog_records_key] + assert set(caplog_records) == {"setup", "call"} + + +def test_captures_for_all_stages( + caplog: pytest.LogCaptureFixture, logging_during_setup_and_teardown: None +) -> None: assert not caplog.records assert not caplog.get_records("call") logger.info("a_call_log") @@ -289,26 +322,27 @@ def test_caplog_captures_for_all_stages(caplog, logging_during_setup_and_teardow assert [x.message for x in caplog.get_records("setup")] == ["a_setup_log"] - # This reaches into private API, don't use this type of thing in real tests! - assert set(caplog._item.stash[caplog_records_key]) == {"setup", "call"} + private_assert_caplog_records_is_setup_call(caplog) -def test_clear_for_call_stage(caplog, logging_during_setup_and_teardown): +def test_clear_for_call_stage( + caplog: pytest.LogCaptureFixture, logging_during_setup_and_teardown: None +) -> None: logger.info("a_call_log") assert [x.message for x in caplog.get_records("call")] == ["a_call_log"] assert [x.message for x in caplog.get_records("setup")] == ["a_setup_log"] - assert set(caplog._item.stash[caplog_records_key]) == {"setup", "call"} + private_assert_caplog_records_is_setup_call(caplog) caplog.clear() assert caplog.get_records("call") == [] assert [x.message for x in caplog.get_records("setup")] == ["a_setup_log"] - assert set(caplog._item.stash[caplog_records_key]) == {"setup", "call"} + private_assert_caplog_records_is_setup_call(caplog) logging.info("a_call_log_after_clear") assert [x.message for x in caplog.get_records("call")] == ["a_call_log_after_clear"] assert [x.message for x in caplog.get_records("setup")] == ["a_setup_log"] - assert set(caplog._item.stash[caplog_records_key]) == {"setup", "call"} + private_assert_caplog_records_is_setup_call(caplog) def test_ini_controls_global_log_level(pytester: Pytester) -> None: @@ -334,11 +368,11 @@ def test_log_level_override(request, caplog): ) result = pytester.runpytest() - # make sure that that we get a '0' exit code for the testsuite + # make sure that we get a '0' exit code for the testsuite assert result.ret == 0 -def test_caplog_can_override_global_log_level(pytester: Pytester) -> None: +def test_can_override_global_log_level(pytester: Pytester) -> None: pytester.makepyfile( """ import pytest @@ -377,7 +411,7 @@ def test_log_level_override(request, caplog): assert result.ret == 0 -def test_caplog_captures_despite_exception(pytester: Pytester) -> None: +def test_captures_despite_exception(pytester: Pytester) -> None: pytester.makepyfile( """ import pytest diff --git a/testing/logging/test_formatter.py b/testing/logging/test_formatter.py index 37971293726..cfe3bee68c4 100644 --- a/testing/logging/test_formatter.py +++ b/testing/logging/test_formatter.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import logging from typing import Any diff --git a/testing/logging/test_reporting.py b/testing/logging/test_reporting.py index 0c8e3fd08a9..cf54788e246 100644 --- a/testing/logging/test_reporting.py +++ b/testing/logging/test_reporting.py @@ -1,14 +1,17 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + import io import os import re from typing import cast -import pytest from _pytest.capture import CaptureManager from _pytest.config import ExitCode from _pytest.fixtures import FixtureRequest from _pytest.pytester import Pytester from _pytest.terminal import TerminalReporter +import pytest def test_nothing_logged(pytester: Pytester) -> None: @@ -77,14 +80,14 @@ def test_foo(): assert "warning text going to logger" not in stdout assert "info text going to logger" not in stdout - # The log file should contain the warning and the error log messages and - # not the info one, because the default level of the root logger is - # WARNING. + # The log file should only contain the error log messages and + # not the warning or info ones, because the root logger is set to + # ERROR using --log-level=ERROR. assert os.path.isfile(log_file) with open(log_file, encoding="utf-8") as rfh: contents = rfh.read() assert "info text going to logger" not in contents - assert "warning text going to logger" in contents + assert "warning text going to logger" not in contents assert "error text going to logger" in contents @@ -176,13 +179,11 @@ def teardown_function(function): def test_log_cli_enabled_disabled(pytester: Pytester, enabled: bool) -> None: msg = "critical message logged by test" pytester.makepyfile( - """ + f""" import logging def test_log_cli(): - logging.critical("{}") - """.format( - msg - ) + logging.critical("{msg}") + """ ) if enabled: pytester.makeini( @@ -662,6 +663,73 @@ def test_log_file(request): assert "This log message won't be shown" not in contents +def test_log_file_mode_cli(pytester: Pytester) -> None: + # Default log file level + pytester.makepyfile( + """ + import pytest + import logging + def test_log_file(request): + plugin = request.config.pluginmanager.getplugin('logging-plugin') + assert plugin.log_file_handler.level == logging.WARNING + logging.getLogger('catchlog').info("This log message won't be shown") + logging.getLogger('catchlog').warning("This log message will be shown") + print('PASSED') + """ + ) + + log_file = str(pytester.path.joinpath("pytest.log")) + + with open(log_file, mode="w", encoding="utf-8") as wfh: + wfh.write("A custom header\n") + + result = pytester.runpytest( + "-s", + f"--log-file={log_file}", + "--log-file-mode=a", + "--log-file-level=WARNING", + ) + + # fnmatch_lines does an assertion internally + result.stdout.fnmatch_lines(["test_log_file_mode_cli.py PASSED"]) + + # make sure that we get a '0' exit code for the testsuite + assert result.ret == 0 + assert os.path.isfile(log_file) + with open(log_file, encoding="utf-8") as rfh: + contents = rfh.read() + assert "A custom header" in contents + assert "This log message will be shown" in contents + assert "This log message won't be shown" not in contents + + +def test_log_file_mode_cli_invalid(pytester: Pytester) -> None: + # Default log file level + pytester.makepyfile( + """ + import pytest + import logging + def test_log_file(request): + plugin = request.config.pluginmanager.getplugin('logging-plugin') + assert plugin.log_file_handler.level == logging.WARNING + logging.getLogger('catchlog').info("This log message won't be shown") + logging.getLogger('catchlog').warning("This log message will be shown") + """ + ) + + log_file = str(pytester.path.joinpath("pytest.log")) + + result = pytester.runpytest( + "-s", + f"--log-file={log_file}", + "--log-file-mode=b", + "--log-file-level=WARNING", + ) + + # make sure that we get a '4' exit code for the testsuite + assert result.ret == ExitCode.USAGE_ERROR + + def test_log_file_cli_level(pytester: Pytester) -> None: # Default log file level pytester.makepyfile( @@ -709,13 +777,11 @@ def test_log_file_ini(pytester: Pytester) -> None: log_file = str(pytester.path.joinpath("pytest.log")) pytester.makeini( - """ + f""" [pytest] - log_file={} + log_file={log_file} log_file_level=WARNING - """.format( - log_file - ) + """ ) pytester.makepyfile( """ @@ -744,17 +810,56 @@ def test_log_file(request): assert "This log message won't be shown" not in contents -def test_log_file_ini_level(pytester: Pytester) -> None: +def test_log_file_mode_ini(pytester: Pytester) -> None: log_file = str(pytester.path.joinpath("pytest.log")) pytester.makeini( + f""" + [pytest] + log_file={log_file} + log_file_mode=a + log_file_level=WARNING + """ + ) + pytester.makepyfile( """ + import pytest + import logging + def test_log_file(request): + plugin = request.config.pluginmanager.getplugin('logging-plugin') + assert plugin.log_file_handler.level == logging.WARNING + logging.getLogger('catchlog').info("This log message won't be shown") + logging.getLogger('catchlog').warning("This log message will be shown") + print('PASSED') + """ + ) + + with open(log_file, mode="w", encoding="utf-8") as wfh: + wfh.write("A custom header\n") + + result = pytester.runpytest("-s") + + # fnmatch_lines does an assertion internally + result.stdout.fnmatch_lines(["test_log_file_mode_ini.py PASSED"]) + + assert result.ret == ExitCode.OK + assert os.path.isfile(log_file) + with open(log_file, encoding="utf-8") as rfh: + contents = rfh.read() + assert "A custom header" in contents + assert "This log message will be shown" in contents + assert "This log message won't be shown" not in contents + + +def test_log_file_ini_level(pytester: Pytester) -> None: + log_file = str(pytester.path.joinpath("pytest.log")) + + pytester.makeini( + f""" [pytest] - log_file={} + log_file={log_file} log_file_level = INFO - """.format( - log_file - ) + """ ) pytester.makepyfile( """ @@ -787,13 +892,11 @@ def test_log_file_unicode(pytester: Pytester) -> None: log_file = str(pytester.path.joinpath("pytest.log")) pytester.makeini( - """ + f""" [pytest] - log_file={} + log_file={log_file} log_file_level = INFO - """.format( - log_file - ) + """ ) pytester.makepyfile( """\ @@ -830,9 +933,10 @@ def test_live_logging_suspends_capture( We parametrize the test to also make sure _LiveLoggingStreamHandler works correctly if no capture manager plugin is installed. """ - import logging import contextlib from functools import partial + import logging + from _pytest.logging import _LiveLoggingStreamHandler class MockCaptureManager: @@ -922,13 +1026,11 @@ def test_collection_logging_to_file(pytester: Pytester) -> None: log_file = str(pytester.path.joinpath("pytest.log")) pytester.makeini( - """ + f""" [pytest] - log_file={} + log_file={log_file} log_file_level = INFO - """.format( - log_file - ) + """ ) pytester.makepyfile( @@ -960,14 +1062,12 @@ def test_log_in_hooks(pytester: Pytester) -> None: log_file = str(pytester.path.joinpath("pytest.log")) pytester.makeini( - """ + f""" [pytest] - log_file={} + log_file={log_file} log_file_level = INFO log_cli=true - """.format( - log_file - ) + """ ) pytester.makeconftest( """ @@ -996,14 +1096,12 @@ def test_log_in_runtest_logreport(pytester: Pytester) -> None: log_file = str(pytester.path.joinpath("pytest.log")) pytester.makeini( - """ + f""" [pytest] - log_file={} + log_file={log_file} log_file_level = INFO log_cli=true - """.format( - log_file - ) + """ ) pytester.makeconftest( """ @@ -1037,19 +1135,17 @@ def test_log_set_path(pytester: Pytester) -> None: """ ) pytester.makeconftest( - """ + f""" import os import pytest - @pytest.hookimpl(hookwrapper=True, tryfirst=True) + @pytest.hookimpl(wrapper=True, tryfirst=True) def pytest_runtest_setup(item): config = item.config logging_plugin = config.pluginmanager.get_plugin("logging-plugin") - report_file = os.path.join({}, item._request.node.name) + report_file = os.path.join({report_dir_base!r}, item._request.node.name) logging_plugin.set_log_path(report_file) - yield - """.format( - repr(report_dir_base) - ) + return (yield) + """ ) pytester.makepyfile( """ @@ -1074,6 +1170,66 @@ def test_second(): assert "message from test 2" in content +def test_log_set_path_with_log_file_mode(pytester: Pytester) -> None: + report_dir_base = str(pytester.path) + + pytester.makeini( + """ + [pytest] + log_file_level = DEBUG + log_cli=true + log_file_mode=a + """ + ) + pytester.makeconftest( + f""" + import os + import pytest + @pytest.hookimpl(wrapper=True, tryfirst=True) + def pytest_runtest_setup(item): + config = item.config + logging_plugin = config.pluginmanager.get_plugin("logging-plugin") + report_file = os.path.join({report_dir_base!r}, item._request.node.name) + logging_plugin.set_log_path(report_file) + return (yield) + """ + ) + pytester.makepyfile( + """ + import logging + logger = logging.getLogger("testcase-logger") + def test_first(): + logger.info("message from test 1") + assert True + + def test_second(): + logger.debug("message from test 2") + assert True + """ + ) + + test_first_log_file = os.path.join(report_dir_base, "test_first") + test_second_log_file = os.path.join(report_dir_base, "test_second") + with open(test_first_log_file, mode="w", encoding="utf-8") as wfh: + wfh.write("A custom header for test 1\n") + + with open(test_second_log_file, mode="w", encoding="utf-8") as wfh: + wfh.write("A custom header for test 2\n") + + result = pytester.runpytest() + assert result.ret == ExitCode.OK + + with open(test_first_log_file, encoding="utf-8") as rfh: + content = rfh.read() + assert "A custom header for test 1" in content + assert "message from test 1" in content + + with open(test_second_log_file, encoding="utf-8") as rfh: + content = rfh.read() + assert "A custom header for test 2" in content + assert "message from test 2" in content + + def test_colored_captured_log(pytester: Pytester) -> None: """Test that the level names of captured log messages of a failing test are colored.""" @@ -1331,3 +1487,62 @@ def test_foo(): result.stdout.re_match_lines( [r"^[0-9-]{10} [0-9:]{8}.[0-9]{6}[+-][0-9\.]+; WARNING; text"] ) + + +def test_log_file_cli_fallback_options(pytester: Pytester) -> None: + """Make sure that fallback values for log-file formats and level works.""" + pytester.makepyfile( + """ + import logging + logger = logging.getLogger() + + def test_foo(): + logger.info('info text going to logger') + logger.warning('warning text going to logger') + logger.error('error text going to logger') + + assert 0 + """ + ) + log_file = str(pytester.path.joinpath("pytest.log")) + result = pytester.runpytest( + "--log-level=ERROR", + "--log-format=%(asctime)s %(message)s", + "--log-date-format=%H:%M", + "--log-file=pytest.log", + ) + assert result.ret == 1 + + # The log file should only contain the error log messages + # not the warning or info ones and the format and date format + # should match the formats provided using --log-format and --log-date-format + assert os.path.isfile(log_file) + with open(log_file, encoding="utf-8") as rfh: + contents = rfh.read() + assert re.match(r"[0-9]{2}:[0-9]{2} error text going to logger\s*", contents) + assert "info text going to logger" not in contents + assert "warning text going to logger" not in contents + assert "error text going to logger" in contents + + # Try with a different format and date format to make sure that the formats + # are being used + result = pytester.runpytest( + "--log-level=ERROR", + "--log-format=%(asctime)s : %(message)s", + "--log-date-format=%H:%M:%S", + "--log-file=pytest.log", + ) + assert result.ret == 1 + + # The log file should only contain the error log messages + # not the warning or info ones and the format and date format + # should match the formats provided using --log-format and --log-date-format + assert os.path.isfile(log_file) + with open(log_file, encoding="utf-8") as rfh: + contents = rfh.read() + assert re.match( + r"[0-9]{2}:[0-9]{2}:[0-9]{2} : error text going to logger\s*", contents + ) + assert "info text going to logger" not in contents + assert "warning text going to logger" not in contents + assert "error text going to logger" in contents diff --git a/testing/plugins_integration/bdd_wallet.py b/testing/plugins_integration/bdd_wallet.py index 35927ea5875..d748028842a 100644 --- a/testing/plugins_integration/bdd_wallet.py +++ b/testing/plugins_integration/bdd_wallet.py @@ -1,3 +1,6 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + from pytest_bdd import given from pytest_bdd import scenario from pytest_bdd import then diff --git a/testing/plugins_integration/django_settings.py b/testing/plugins_integration/django_settings.py index 0715f476531..e36e554db9a 100644 --- a/testing/plugins_integration/django_settings.py +++ b/testing/plugins_integration/django_settings.py @@ -1 +1,4 @@ +from __future__ import annotations + + SECRET_KEY = "mysecret" diff --git a/testing/plugins_integration/pytest.ini b/testing/plugins_integration/pytest.ini index 3bacdef62ab..86058fbbac8 100644 --- a/testing/plugins_integration/pytest.ini +++ b/testing/plugins_integration/pytest.ini @@ -3,4 +3,5 @@ addopts = --strict-markers asyncio_mode = strict filterwarnings = error::pytest.PytestWarning + ignore:usefixtures.* without arguments has no effect:pytest.PytestWarning ignore:.*.fspath is deprecated and will be replaced by .*.path.*:pytest.PytestDeprecationWarning diff --git a/testing/plugins_integration/pytest_anyio_integration.py b/testing/plugins_integration/pytest_anyio_integration.py index 65c2f593663..41ffad18a6e 100644 --- a/testing/plugins_integration/pytest_anyio_integration.py +++ b/testing/plugins_integration/pytest_anyio_integration.py @@ -1,3 +1,6 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + import anyio import pytest diff --git a/testing/plugins_integration/pytest_asyncio_integration.py b/testing/plugins_integration/pytest_asyncio_integration.py index 5d2a3faccfc..cef67f83ea6 100644 --- a/testing/plugins_integration/pytest_asyncio_integration.py +++ b/testing/plugins_integration/pytest_asyncio_integration.py @@ -1,3 +1,6 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + import asyncio import pytest diff --git a/testing/plugins_integration/pytest_mock_integration.py b/testing/plugins_integration/pytest_mock_integration.py index 740469d00fb..a49129cf0c9 100644 --- a/testing/plugins_integration/pytest_mock_integration.py +++ b/testing/plugins_integration/pytest_mock_integration.py @@ -1,2 +1,6 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + + def test_mocker(mocker): mocker.MagicMock() diff --git a/testing/plugins_integration/pytest_rerunfailures_integration.py b/testing/plugins_integration/pytest_rerunfailures_integration.py new file mode 100644 index 00000000000..449661f7294 --- /dev/null +++ b/testing/plugins_integration/pytest_rerunfailures_integration.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +import unittest + + +class MyTestCase(unittest.TestCase): + first_time = True + + def test_fail_the_first_time(self) -> None: + """Regression test for issue #12424.""" + if self.first_time: + type(self).first_time = False + self.fail() diff --git a/testing/plugins_integration/pytest_trio_integration.py b/testing/plugins_integration/pytest_trio_integration.py index 199f7850bc4..eceac5076a9 100644 --- a/testing/plugins_integration/pytest_trio_integration.py +++ b/testing/plugins_integration/pytest_trio_integration.py @@ -1,3 +1,6 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + import trio import pytest diff --git a/testing/plugins_integration/pytest_twisted_integration.py b/testing/plugins_integration/pytest_twisted_integration.py index 94748d036e5..4f386bf1b9f 100644 --- a/testing/plugins_integration/pytest_twisted_integration.py +++ b/testing/plugins_integration/pytest_twisted_integration.py @@ -1,3 +1,6 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + import pytest_twisted from twisted.internet.task import deferLater diff --git a/testing/plugins_integration/requirements.txt b/testing/plugins_integration/requirements.txt index d56720c0282..e8010b23113 100644 --- a/testing/plugins_integration/requirements.txt +++ b/testing/plugins_integration/requirements.txt @@ -1,15 +1,15 @@ -anyio[curio,trio]==3.7.0 -django==4.2.2 -pytest-asyncio==0.21.0 -pytest-bdd==6.1.1 -pytest-cov==4.1.0 -pytest-django==4.5.2 +anyio[trio]==4.9.0 +django==5.2.1 +pytest-asyncio==0.26.0 +pytest-bdd==8.1.0 +pytest-cov==6.1.1 +pytest-django==4.11.1 pytest-flakes==4.0.5 -pytest-html==3.2.0 -pytest-mock==3.11.1 -pytest-rerunfailures==11.1.2 -pytest-sugar==0.9.7 -pytest-trio==0.7.0 -pytest-twisted==1.14.0 -twisted==22.8.0 -pytest-xvfb==3.0.0 +pytest-html==4.1.1 +pytest-mock==3.14.1 +pytest-rerunfailures==15.1 +pytest-sugar==1.0.0 +pytest-trio==0.8.0 +pytest-twisted==1.14.3 +twisted==24.11.0 +pytest-xvfb==3.1.1 diff --git a/testing/plugins_integration/simple_integration.py b/testing/plugins_integration/simple_integration.py index 20b2fc4b5bb..ed504ae4bf1 100644 --- a/testing/plugins_integration/simple_integration.py +++ b/testing/plugins_integration/simple_integration.py @@ -1,3 +1,6 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + import pytest diff --git a/testing/python/approx.py b/testing/python/approx.py index 631e52b56ac..06633b544ec 100644 --- a/testing/python/approx.py +++ b/testing/python/approx.py @@ -1,17 +1,21 @@ -import operator +# mypy: allow-untyped-defs +from __future__ import annotations + from contextlib import contextmanager +import decimal from decimal import Decimal from fractions import Fraction from math import sqrt +import operator from operator import eq from operator import ne -from typing import Optional -import pytest from _pytest.pytester import Pytester from _pytest.python_api import _recursive_sequence_map +import pytest from pytest import approx + inf, nan = float("inf"), float("nan") @@ -37,9 +41,7 @@ def set_continue(self): class MyDocTestRunner(doctest.DocTestRunner): def report_failure(self, out, test, example, got): raise AssertionError( - "'{}' evaluates to '{}', not '{}'".format( - example.source.strip(), got.strip(), example.want.strip() - ) + f"'{example.source.strip()}' evaluates to '{got.strip()}', not '{example.want.strip()}'" ) return MyDocTestRunner() @@ -89,19 +91,34 @@ def do_assert(lhs, rhs, expected_message, verbosity_level=0): return do_assert -SOME_FLOAT = r"[+-]?([0-9]*[.])?[0-9]+\s*" +SOME_FLOAT = r"[+-]?((?:([0-9]*[.])?[0-9]+(e-?[0-9]+)?)|inf|nan)\s*" SOME_INT = r"[0-9]+\s*" +SOME_TOLERANCE = rf"({SOME_FLOAT}|[+-]?[0-9]+(\.[0-9]+)?[eE][+-]?[0-9]+\s*)" class TestApprox: def test_error_messages_native_dtypes(self, assert_approx_raises_regex): + # Treat bool exactly. + assert_approx_raises_regex( + {"a": 1.0, "b": True}, + {"a": 1.0, "b": False}, + [ + "", + " comparison failed. Mismatched elements: 1 / 2:", + f" Max absolute difference: {SOME_FLOAT}", + f" Max relative difference: {SOME_FLOAT}", + r" Index\s+\| Obtained\s+\| Expected", + r".*(True|False)\s+", + ], + ) assert_approx_raises_regex( 2.0, 1.0, [ + "", " comparison failed", f" Obtained: {SOME_FLOAT}", - f" Expected: {SOME_FLOAT} ± {SOME_FLOAT}", + f" Expected: {SOME_FLOAT} ± {SOME_TOLERANCE}", ], ) @@ -113,12 +130,31 @@ def test_error_messages_native_dtypes(self, assert_approx_raises_regex): "c": 3000000.0, }, [ + r"", r" comparison failed. Mismatched elements: 2 / 3:", rf" Max absolute difference: {SOME_FLOAT}", rf" Max relative difference: {SOME_FLOAT}", - r" Index \| Obtained\s+\| Expected ", - rf" a \| {SOME_FLOAT} \| {SOME_FLOAT} ± {SOME_FLOAT}", - rf" c \| {SOME_FLOAT} \| {SOME_FLOAT} ± {SOME_FLOAT}", + r" Index \| Obtained\s+\| Expected\s+", + rf" a \| {SOME_FLOAT} \| {SOME_FLOAT} ± {SOME_TOLERANCE}", + rf" c \| {SOME_FLOAT} \| {SOME_FLOAT} ± {SOME_TOLERANCE}", + ], + ) + + assert_approx_raises_regex( + {"a": 1.0, "b": None, "c": None}, + { + "a": None, + "b": 1000.0, + "c": None, + }, + [ + r"", + r" comparison failed. Mismatched elements: 2 / 3:", + r" Max absolute difference: -inf", + r" Max relative difference: -inf", + r" Index \| Obtained\s+\| Expected\s+", + rf" a \| {SOME_FLOAT} \| None", + rf" b \| None\s+\| {SOME_FLOAT} ± {SOME_FLOAT}", ], ) @@ -126,6 +162,7 @@ def test_error_messages_native_dtypes(self, assert_approx_raises_regex): [1.0, 2.0, 3.0, 4.0], [1.0, 3.0, 3.0, 5.0], [ + r"", r" comparison failed. Mismatched elements: 2 / 4:", rf" Max absolute difference: {SOME_FLOAT}", rf" Max relative difference: {SOME_FLOAT}", @@ -139,6 +176,7 @@ def test_error_messages_native_dtypes(self, assert_approx_raises_regex): (1, 2.2, 4), (1, 3.2, 4), [ + r"", r" comparison failed. Mismatched elements: 1 / 3:", rf" Max absolute difference: {SOME_FLOAT}", rf" Max relative difference: {SOME_FLOAT}", @@ -152,6 +190,7 @@ def test_error_messages_native_dtypes(self, assert_approx_raises_regex): [0.0], [1.0], [ + r"", r" comparison failed. Mismatched elements: 1 / 1:", rf" Max absolute difference: {SOME_FLOAT}", r" Max relative difference: inf", @@ -170,6 +209,7 @@ def test_error_messages_numpy_dtypes(self, assert_approx_raises_regex): a, b, [ + r"", r" comparison failed. Mismatched elements: 1 / 20:", rf" Max absolute difference: {SOME_FLOAT}", rf" Max relative difference: {SOME_FLOAT}", @@ -192,6 +232,7 @@ def test_error_messages_numpy_dtypes(self, assert_approx_raises_regex): ] ), [ + r"", r" comparison failed. Mismatched elements: 3 / 8:", rf" Max absolute difference: {SOME_FLOAT}", rf" Max relative difference: {SOME_FLOAT}", @@ -207,6 +248,7 @@ def test_error_messages_numpy_dtypes(self, assert_approx_raises_regex): np.array([0.0]), np.array([1.0]), [ + r"", r" comparison failed. Mismatched elements: 1 / 1:", rf" Max absolute difference: {SOME_FLOAT}", r" Max relative difference: inf", @@ -224,6 +266,7 @@ def test_error_messages_invalid_args(self, assert_approx_raises_regex): message = "\n".join(str(e.value).split("\n")[1:]) assert message == "\n".join( [ + " ", " Impossible to compare arrays with different shapes.", " Shapes: (2, 1) and (2, 2)", ] @@ -234,6 +277,7 @@ def test_error_messages_invalid_args(self, assert_approx_raises_regex): message = "\n".join(str(e.value).split("\n")[1:]) assert message == "\n".join( [ + " ", " Impossible to compare lists with different sizes.", " Lengths: 2 and 3", ] @@ -247,6 +291,7 @@ def test_error_messages_with_different_verbosity(self, assert_approx_raises_rege 2.0, 1.0, [ + "", " comparison failed", f" Obtained: {SOME_FLOAT}", f" Expected: {SOME_FLOAT} ± {SOME_FLOAT}", @@ -260,15 +305,15 @@ def test_error_messages_with_different_verbosity(self, assert_approx_raises_rege a, b, [ - r" comparison failed. Mismatched elements: 20 / 20:", - rf" Max absolute difference: {SOME_FLOAT}", - rf" Max relative difference: {SOME_FLOAT}", - r" Index \| Obtained\s+\| Expected", - rf" \(0,\)\s+\| {SOME_FLOAT} \| {SOME_FLOAT} ± {SOME_FLOAT}", - rf" \(1,\)\s+\| {SOME_FLOAT} \| {SOME_FLOAT} ± {SOME_FLOAT}", - rf" \(2,\)\s+\| {SOME_FLOAT} \| {SOME_FLOAT} ± {SOME_FLOAT}...", - "", - rf"\s*...Full output truncated \({SOME_INT} lines hidden\), use '-vv' to show", + r"^ $", + r"^ comparison failed. Mismatched elements: 20 / 20:$", + rf"^ Max absolute difference: {SOME_FLOAT}$", + rf"^ Max relative difference: {SOME_FLOAT}$", + r"^ Index \| Obtained\s+\| Expected\s+$", + rf"^ \(0,\)\s+\| {SOME_FLOAT} \| {SOME_FLOAT} ± {SOME_FLOAT}e-{SOME_INT}$", + rf"^ \(1,\)\s+\| {SOME_FLOAT} \| {SOME_FLOAT} ± {SOME_FLOAT}e-{SOME_INT}\.\.\.$", + "^ $", + rf"^ ...Full output truncated \({SOME_INT} lines hidden\), use '-vv' to show$", ], verbosity_level=0, ) @@ -277,6 +322,7 @@ def test_error_messages_with_different_verbosity(self, assert_approx_raises_rege a, b, [ + r" ", r" comparison failed. Mismatched elements: 20 / 20:", rf" Max absolute difference: {SOME_FLOAT}", rf" Max relative difference: {SOME_FLOAT}", @@ -303,6 +349,11 @@ def test_repr_string(self): "approx({'b': 2.0 ± 2.0e-06, 'a': 1.0 ± 1.0e-06})", ) + assert repr(approx(42, abs=1)) == "42 ± 1" + assert repr(approx(5, rel=0.01)) == "5 ± 0.05" + assert repr(approx(24000, abs=500)) == "24000 ± 500" + assert repr(approx(1500, abs=555)) == "1500 ± 555" + def test_repr_complex_numbers(self): assert repr(approx(inf + 1j)) == "(inf+1j)" assert repr(approx(1.0j, rel=inf)) == "1j ± inf" @@ -316,7 +367,7 @@ def test_repr_complex_numbers(self): assert repr(approx(3 + 4 * 1j)) == "(3+4j) ± 5.0e-06 ∠ ±180°" # absolute tolerance is not scaled - assert repr(approx(3.3 + 4.4 * 1j, abs=0.02)) == "(3.3+4.4j) ± 2.0e-02 ∠ ±180°" + assert repr(approx(3.3 + 4.4 * 1j, abs=0.02)) == "(3.3+4.4j) ± 0.02 ∠ ±180°" @pytest.mark.parametrize( "value, expected_repr_string", @@ -340,6 +391,37 @@ def test_bool(self): assert err.match(r"approx\(\) is not supported in a boolean context") + def test_mixed_sequence(self, assert_approx_raises_regex) -> None: + """Approx should work on sequences that also contain non-numbers (#13010).""" + assert_approx_raises_regex( + [1.1, 2, "word"], + [1.0, 2, "different"], + [ + "", + r" comparison failed. Mismatched elements: 2 / 3:", + rf" Max absolute difference: {SOME_FLOAT}", + rf" Max relative difference: {SOME_FLOAT}", + r" Index \| Obtained\s+\| Expected\s+", + r"\s*0\s*\|\s*1\.1\s*\|\s*1\.0\s*±\s*1\.0e\-06\s*", + r"\s*2\s*\|\s*word\s*\|\s*different\s*", + ], + verbosity_level=2, + ) + assert_approx_raises_regex( + [1.1, 2, "word"], + [1.0, 2, "word"], + [ + "", + r" comparison failed. Mismatched elements: 1 / 3:", + rf" Max absolute difference: {SOME_FLOAT}", + rf" Max relative difference: {SOME_FLOAT}", + r" Index \| Obtained\s+\| Expected\s+", + r"\s*0\s*\|\s*1\.1\s*\|\s*1\.0\s*±\s*1\.0e\-06\s*", + ], + verbosity_level=2, + ) + assert [1.1, 2, "word"] == pytest.approx([1.1, 2, "word"]) + def test_operator_overloading(self): assert 1 == approx(1, rel=1e-6, abs=1e-12) assert not (1 != approx(1, rel=1e-6, abs=1e-12)) @@ -385,9 +467,7 @@ def test_zero_tolerance(self): (-1e100, -1e100), ], ) - def test_negative_tolerance( - self, rel: Optional[float], abs: Optional[float] - ) -> None: + def test_negative_tolerance(self, rel: float | None, abs: float | None) -> None: # Negative tolerances are not allowed. with pytest.raises(ValueError): 1.1 == approx(1, rel, abs) @@ -561,6 +641,22 @@ def test_complex(self): assert approx(x, rel=5e-6, abs=0) == a assert approx(x, rel=5e-7, abs=0) != a + def test_expecting_bool(self) -> None: + assert True == approx(True) # noqa: E712 + assert False == approx(False) # noqa: E712 + assert True != approx(False) # noqa: E712 + assert True != approx(False, abs=2) # noqa: E712 + assert 1 != approx(True) + + def test_expecting_bool_numpy(self) -> None: + """Check approx comparing with numpy.bool (#13047).""" + np = pytest.importorskip("numpy") + assert np.False_ != approx(True) + assert np.True_ != approx(False) + assert np.True_ == approx(True) + assert np.False_ == approx(False) + assert np.True_ != approx(False, abs=2) + def test_list(self): actual = [1 + 1e-7, 2 + 1e-8] expected = [1, 2] @@ -626,6 +722,7 @@ def test_dict_wrong_len(self): def test_dict_nonnumeric(self): assert {"a": 1.0, "b": None} == pytest.approx({"a": 1.0, "b": None}) assert {"a": 1.0, "b": 1} != pytest.approx({"a": 1.0, "b": None}) + assert {"a": 1.0, "b": True} != pytest.approx({"a": 1.0, "b": False}, abs=2) def test_dict_vs_other(self): assert 1 != approx({"a": 0}) @@ -635,6 +732,7 @@ def test_dict_for_div_by_zero(self, assert_approx_raises_regex): {"foo": 42.0}, {"foo": 0.0}, [ + r"", r" comparison failed. Mismatched elements: 1 / 1:", rf" Max absolute difference: {SOME_FLOAT}", r" Max relative difference: inf", @@ -732,6 +830,23 @@ def test_numpy_array_wrong_shape(self): assert a12 != approx(a21) assert a21 != approx(a12) + def test_numpy_array_implicit_conversion(self): + np = pytest.importorskip("numpy") + + class ImplicitArray: + """Type which is implicitly convertible to a numpy array.""" + + def __init__(self, vals): + self.vals = vals + + def __array__(self, dtype=None, copy=None): + return np.array(self.vals) + + vec1 = ImplicitArray([1.0, 2.0, 3.0]) + vec2 = ImplicitArray([1.0, 2.0, 4.0]) + # see issue #12114 for test case + assert vec1 != approx(vec2) + def test_numpy_array_protocol(self): """ array-like objects such as tensorflow's DeviceArray are handled like ndarray. @@ -825,7 +940,7 @@ def test_nonnumeric_okay_if_equal(self, x): ], ) def test_nonnumeric_false_if_unequal(self, x): - """For nonnumeric types, x != pytest.approx(y) reduces to x != y""" + """For non-numeric types, x != pytest.approx(y) reduces to x != y""" assert "ab" != approx("abc") assert ["ab"] != approx(["abc"]) # in particular, both of these should return False @@ -901,11 +1016,53 @@ def __len__(self): expected_repr = "approx([1 ± 1.0e-06, 2 ± 2.0e-06, 3 ± 3.0e-06, 4 ± 4.0e-06])" assert repr(approx(expected)) == expected_repr + def test_decimal_approx_repr(self, monkeypatch) -> None: + monkeypatch.setitem(decimal.getcontext().traps, decimal.FloatOperation, True) + approx_obj = pytest.approx(decimal.Decimal("2.60")) + assert decimal.Decimal("2.600001") == approx_obj + def test_allow_ordered_sequences_only(self) -> None: """pytest.approx() should raise an error on unordered sequences (#9692).""" with pytest.raises(TypeError, match="only supports ordered sequences"): assert {1, 2, 3} == approx({1, 2, 3}) + def test_strange_sequence(self): + """https://github.com/pytest-dev/pytest/issues/11797""" + a = MyVec3(1, 2, 3) + b = MyVec3(0, 1, 2) + + # this would trigger the error inside the test + pytest.approx(a, abs=0.5)._repr_compare(b) + + assert b == pytest.approx(a, abs=2) + assert b != pytest.approx(a, abs=0.5) + + +class MyVec3: # incomplete + """sequence like""" + + _x: int + _y: int + _z: int + + def __init__(self, x: int, y: int, z: int): + self._x, self._y, self._z = x, y, z + + def __repr__(self) -> str: + return f"" + + def __len__(self) -> int: + return 3 + + def __getitem__(self, key: int) -> int: + if key == 0: + return self._x + if key == 1: + return self._y + if key == 2: + return self._z + raise IndexError(key) + class TestRecursiveSequenceMap: def test_map_over_scalar(self): @@ -928,8 +1085,11 @@ def test_map_over_nested_lists(self): ] def test_map_over_mixed_sequence(self): - assert _recursive_sequence_map(sqrt, [4, (25, 64), [(49)]]) == [ + assert _recursive_sequence_map(sqrt, [4, (25, 64), [49]]) == [ 2, (5, 8), - [(7)], + [7], ] + + def test_map_over_sequence_like(self): + assert _recursive_sequence_map(int, MyVec3(1, 2, 3)) == [1, 2, 3] diff --git a/testing/python/collect.py b/testing/python/collect.py index 9bf6e00d1f0..530f1c340ff 100644 --- a/testing/python/collect.py +++ b/testing/python/collect.py @@ -1,11 +1,12 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + import os import sys import textwrap from typing import Any -from typing import Dict import _pytest._code -import pytest from _pytest.config import ExitCode from _pytest.main import Session from _pytest.monkeypatch import MonkeyPatch @@ -13,6 +14,7 @@ from _pytest.pytester import Pytester from _pytest.python import Class from _pytest.python import Function +import pytest class TestModule: @@ -35,9 +37,9 @@ def test_import_duplicate(self, pytester: Pytester) -> None: [ "*import*mismatch*", "*imported*test_whatever*", - "*%s*" % p1, + f"*{p1}*", "*not the same*", - "*%s*" % p2, + f"*{p2}*", "*HINT*", ] ) @@ -53,13 +55,11 @@ def test_import_prepend_append( monkeypatch.syspath_prepend(str(root1)) p.write_text( textwrap.dedent( - """\ + f"""\ import x456 def test(): - assert x456.__file__.startswith({!r}) - """.format( - str(root2) - ) + assert x456.__file__.startswith({str(root2)!r}) + """ ), encoding="utf-8", ) @@ -263,6 +263,69 @@ def prop(self): result = pytester.runpytest() assert result.ret == ExitCode.NO_TESTS_COLLECTED + def test_does_not_discover_properties(self, pytester: Pytester) -> None: + """Regression test for #12446.""" + pytester.makepyfile( + """\ + class TestCase: + @property + def oops(self): + raise SystemExit('do not call me!') + """ + ) + result = pytester.runpytest() + assert result.ret == ExitCode.NO_TESTS_COLLECTED + + def test_does_not_discover_instance_descriptors(self, pytester: Pytester) -> None: + """Regression test for #12446.""" + pytester.makepyfile( + """\ + # not `@property`, but it acts like one + # this should cover the case of things like `@cached_property` / etc. + class MyProperty: + def __init__(self, func): + self._func = func + def __get__(self, inst, owner): + if inst is None: + return self + else: + return self._func.__get__(inst, owner)() + + class TestCase: + @MyProperty + def oops(self): + raise SystemExit('do not call me!') + """ + ) + result = pytester.runpytest() + assert result.ret == ExitCode.NO_TESTS_COLLECTED + + def test_abstract_class_is_not_collected(self, pytester: Pytester) -> None: + """Regression test for #12275 (non-unittest version).""" + pytester.makepyfile( + """ + import abc + + class TestBase(abc.ABC): + @abc.abstractmethod + def abstract1(self): pass + + @abc.abstractmethod + def abstract2(self): pass + + def test_it(self): pass + + class TestPartial(TestBase): + def abstract1(self): pass + + class TestConcrete(TestPartial): + def abstract2(self): pass + """ + ) + result = pytester.runpytest() + assert result.ret == ExitCode.OK + result.assert_outcomes(passed=1) + class TestFunction: def test_getmodulecollector(self, pytester: Pytester) -> None: @@ -776,13 +839,13 @@ def test_ordered_by_definition_order(self, pytester: Pytester) -> None: pytester.makepyfile( """\ class Test1: - def test_foo(): pass - def test_bar(): pass + def test_foo(self): pass + def test_bar(self): pass class Test2: - def test_foo(): pass + def test_foo(self): pass test_bar = Test1.test_bar class Test3(Test2): - def test_baz(): pass + def test_baz(self): pass """ ) result = pytester.runpytest("--collect-only") @@ -827,11 +890,11 @@ def test_customized_pymakemodule_issue205_subdir(self, pytester: Pytester) -> No textwrap.dedent( """\ import pytest - @pytest.hookimpl(hookwrapper=True) + @pytest.hookimpl(wrapper=True) def pytest_pycollect_makemodule(): - outcome = yield - mod = outcome.get_result() + mod = yield mod.obj.hello = "world" + return mod """ ), encoding="utf-8", @@ -855,14 +918,13 @@ def test_customized_pymakeitem(self, pytester: Pytester) -> None: textwrap.dedent( """\ import pytest - @pytest.hookimpl(hookwrapper=True) + @pytest.hookimpl(wrapper=True) def pytest_pycollect_makeitem(): - outcome = yield - if outcome.excinfo is None: - result = outcome.get_result() - if result: - for func in result: - func._some123 = "world" + result = yield + if result: + for func in result: + func._some123 = "world" + return result """ ), encoding="utf-8", @@ -1105,7 +1167,7 @@ def test_filter_traceback_generated_code(self) -> None: tb = None try: - ns: Dict[str, Any] = {} + ns: dict[str, Any] = {} exec("def foo(): raise ValueError", ns) ns["foo"]() except ValueError: @@ -1211,7 +1273,7 @@ def test_bar(self): classcol = pytester.collect_by_name(modcol, "TestClass") assert isinstance(classcol, Class) path, lineno, msg = classcol.reportinfo() - func = list(classcol.collect())[0] + func = next(iter(classcol.collect())) assert isinstance(func, Function) path, lineno, msg = func.reportinfo() @@ -1420,10 +1482,15 @@ def test_package_collection_infinite_recursion(pytester: Pytester) -> None: def test_package_collection_init_given_as_argument(pytester: Pytester) -> None: - """Regression test for #3749""" + """Regression test for #3749, #8976, #9263, #9313. + + Specifying an __init__.py file directly should collect only the __init__.py + Module, not the entire package. + """ p = pytester.copy_example("collect/package_init_given_as_arg") - result = pytester.runpytest(p / "pkg" / "__init__.py") - result.stdout.fnmatch_lines(["*1 passed*"]) + items, hookrecorder = pytester.inline_genitems(p / "pkg" / "__init__.py") + assert len(items) == 1 + assert items[0].name == "test_init" def test_package_with_modules(pytester: Pytester) -> None: @@ -1510,3 +1577,108 @@ def test_package_ordering(pytester: Pytester) -> None: # Execute from . result = pytester.runpytest("-v", "-s") result.assert_outcomes(passed=3) + + +def test_collection_hierarchy(pytester: Pytester) -> None: + """A general test checking that a filesystem hierarchy is collected as + expected in various scenarios. + + top/ + ├── aaa + │ ├── pkg + │ │ ├── __init__.py + │ │ └── test_pkg.py + │ └── test_aaa.py + ├── test_a.py + ├── test_b + │ ├── __init__.py + │ └── test_b.py + ├── test_c.py + └── zzz + ├── dir + │ └── test_dir.py + ├── __init__.py + └── test_zzz.py + """ + pytester.makepyfile( + **{ + "top/aaa/test_aaa.py": "def test_it(): pass", + "top/aaa/pkg/__init__.py": "", + "top/aaa/pkg/test_pkg.py": "def test_it(): pass", + "top/test_a.py": "def test_it(): pass", + "top/test_b/__init__.py": "", + "top/test_b/test_b.py": "def test_it(): pass", + "top/test_c.py": "def test_it(): pass", + "top/zzz/__init__.py": "", + "top/zzz/test_zzz.py": "def test_it(): pass", + "top/zzz/dir/test_dir.py": "def test_it(): pass", + } + ) + + full = [ + "", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + ] + result = pytester.runpytest("--collect-only") + result.stdout.fnmatch_lines(full, consecutive=True) + result = pytester.runpytest("top", "--collect-only") + result.stdout.fnmatch_lines(full, consecutive=True) + result = pytester.runpytest("top", "top", "--collect-only") + result.stdout.fnmatch_lines(full, consecutive=True) + + result = pytester.runpytest( + "top/aaa", "top/aaa/pkg", "--collect-only", "--keep-duplicates" + ) + result.stdout.fnmatch_lines( + [ + "", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + ], + consecutive=True, + ) + + result = pytester.runpytest( + "top/aaa/pkg", "top/aaa", "--collect-only", "--keep-duplicates" + ) + result.stdout.fnmatch_lines( + [ + "", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + ], + consecutive=True, + ) diff --git a/testing/python/fixtures.py b/testing/python/fixtures.py index e62db8c2653..fb76fe6cf96 100644 --- a/testing/python/fixtures.py +++ b/testing/python/fixtures.py @@ -1,17 +1,20 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + import os +from pathlib import Path import sys import textwrap -from pathlib import Path -import pytest -from _pytest import fixtures from _pytest.compat import getfuncargnames from _pytest.config import ExitCode -from _pytest.fixtures import FixtureRequest +from _pytest.fixtures import deduplicate_names +from _pytest.fixtures import TopRequest from _pytest.monkeypatch import MonkeyPatch from _pytest.pytester import get_public_names from _pytest.pytester import Pytester from _pytest.python import Function +import pytest def test_getfuncargnames_functions(): @@ -45,7 +48,23 @@ class A: def f(self, arg1, arg2="hello"): raise NotImplementedError() + def g(self, /, arg1, arg2="hello"): + raise NotImplementedError() + + def h(self, *, arg1, arg2="hello"): + raise NotImplementedError() + + def j(self, arg1, *, arg2, arg3="hello"): + raise NotImplementedError() + + def k(self, /, arg1, *, arg2, arg3="hello"): + raise NotImplementedError() + assert getfuncargnames(A().f) == ("arg1",) + assert getfuncargnames(A().g) == ("arg1",) + assert getfuncargnames(A().h) == ("arg1",) + assert getfuncargnames(A().j) == ("arg1", "arg2") + assert getfuncargnames(A().k) == ("arg1", "arg2") def test_getfuncargnames_staticmethod(): @@ -73,6 +92,16 @@ class B(A): assert getfuncargnames(B.static, cls=B) == ("arg1", "arg2") +@pytest.mark.skipif( + sys.version_info >= (3, 13), + reason="""\ +In python 3.13, this will raise FutureWarning: +functools.partial will be a method descriptor in future Python versions; +wrap it in staticmethod() if you want to preserve the old behavior + +But the wrapped 'functools.partial' is tested by 'test_getfuncargnames_staticmethod_partial' below. +""", +) def test_getfuncargnames_partial(): """Check getfuncargnames for methods defined with functools.partial (#5701)""" import functools @@ -659,7 +688,7 @@ def test_func(something): pass """ ) assert isinstance(item, Function) - req = fixtures.FixtureRequest(item, _ispytest=True) + req = TopRequest(item, _ispytest=True) assert req.function == item.obj assert req.keywords == item.keywords assert hasattr(req.module, "test_func") @@ -699,10 +728,9 @@ def test_method(self, something): """ ) (item1,) = pytester.genitems([modcol]) + assert isinstance(item1, Function) assert item1.name == "test_method" - arg2fixturedefs = fixtures.FixtureRequest( - item1, _ispytest=True - )._arg2fixturedefs + arg2fixturedefs = TopRequest(item1, _ispytest=True)._arg2fixturedefs assert len(arg2fixturedefs) == 1 assert arg2fixturedefs["something"][0].argname == "something" @@ -712,7 +740,7 @@ def test_method(self, something): ) def test_request_garbage(self, pytester: Pytester) -> None: try: - import xdist # noqa + import xdist # noqa: F401 except ImportError: pass else: @@ -932,8 +960,9 @@ def test_request_subrequest_addfinalizer_exceptions( self, pytester: Pytester ) -> None: """ - Ensure exceptions raised during teardown by a finalizer are suppressed - until all finalizers are called, re-raising the first exception (#2440) + Ensure exceptions raised during teardown by finalizers are suppressed + until all finalizers are called, then re-raised together in an + exception group (#2440) """ pytester.makepyfile( """ @@ -960,14 +989,23 @@ def test_second(): """ ) result = pytester.runpytest() + result.assert_outcomes(passed=2, errors=1) result.stdout.fnmatch_lines( - ["*Exception: Error in excepts fixture", "* 2 passed, 1 error in *"] + [ + ' | *ExceptionGroup: errors while tearing down fixture "subrequest" of (2 sub-exceptions)', # noqa: E501 + " +-+---------------- 1 ----------------", + " | Exception: Error in something fixture", + " +---------------- 2 ----------------", + " | Exception: Error in excepts fixture", + " +------------------------------------", + ], ) def test_request_getmodulepath(self, pytester: Pytester) -> None: modcol = pytester.getmodulecol("def test_somefunc(): pass") (item,) = pytester.genitems([modcol]) - req = fixtures.FixtureRequest(item, _ispytest=True) + assert isinstance(item, Function) + req = TopRequest(item, _ispytest=True) assert req.path == modcol.path def test_request_fixturenames(self, pytester: Pytester) -> None: @@ -1125,7 +1163,8 @@ def test_func2(self, something): pass """ ) - req1 = fixtures.FixtureRequest(item1, _ispytest=True) + assert isinstance(item1, Function) + req1 = TopRequest(item1, _ispytest=True) assert "xfail" not in item1.keywords req1.applymarker(pytest.mark.xfail) assert "xfail" in item1.keywords @@ -1236,8 +1275,9 @@ def test_add(arg2): result = pytester.runpytest() result.stdout.fnmatch_lines( [ - "*ScopeMismatch*involved factories*", + "*ScopeMismatch*Requesting fixture stack*", "test_receives_funcargs_scope_mismatch.py:6: def arg2(arg1)", + "Requested fixture:", "test_receives_funcargs_scope_mismatch.py:2: def arg1()", "*1 error*", ] @@ -1263,7 +1303,13 @@ def test_add(arg1, arg2): ) result = pytester.runpytest() result.stdout.fnmatch_lines( - ["*ScopeMismatch*involved factories*", "* def arg2*", "*1 error*"] + [ + "*ScopeMismatch*Requesting fixture stack*", + "* def arg2(arg1)", + "Requested fixture:", + "* def arg1()", + "*1 error*", + ], ) def test_invalid_scope(self, pytester: Pytester) -> None: @@ -1286,7 +1332,7 @@ def test_nothing(badscope): @pytest.mark.parametrize("scope", ["function", "session"]) def test_parameters_without_eq_semantics(self, scope, pytester: Pytester) -> None: pytester.makepyfile( - """ + f""" class NoEq1: # fails on `a == b` statement def __eq__(self, _): raise RuntimeError @@ -1308,9 +1354,7 @@ def test1(no_eq): def test2(no_eq): pass - """.format( - scope=scope - ) + """ ) result = pytester.runpytest() result.stdout.fnmatch_lines(["*4 passed*"]) @@ -1407,6 +1451,23 @@ def test_two(self): reprec = pytester.inline_run() reprec.assertoutcome(passed=2) + def test_empty_usefixtures_marker(self, pytester: Pytester) -> None: + """Empty usefixtures() marker issues a warning (#12439).""" + pytester.makepyfile( + """ + import pytest + + @pytest.mark.usefixtures() + def test_one(): + assert 1 == 1 + """ + ) + result = pytester.runpytest() + result.stdout.fnmatch_lines( + "*PytestWarning: usefixtures() in test_empty_usefixtures_marker.py::test_one" + " without arguments has no effect" + ) + def test_usefixtures_ini(self, pytester: Pytester) -> None: pytester.makeini( """ @@ -1529,6 +1590,95 @@ def test_printer_2(self): result = pytester.runpytest() result.stdout.fnmatch_lines(["* 2 passed in *"]) + def test_parameterized_fixture_caching(self, pytester: Pytester) -> None: + """Regression test for #12600.""" + pytester.makepyfile( + """ + import pytest + from itertools import count + + CACHE_MISSES = count(0) + + def pytest_generate_tests(metafunc): + if "my_fixture" in metafunc.fixturenames: + # Use unique objects for parametrization (as opposed to small strings + # and small integers which are singletons). + metafunc.parametrize("my_fixture", [[1], [2]], indirect=True) + + @pytest.fixture(scope='session') + def my_fixture(request): + next(CACHE_MISSES) + + def test1(my_fixture): + pass + + def test2(my_fixture): + pass + + def teardown_module(): + assert next(CACHE_MISSES) == 2 + """ + ) + result = pytester.runpytest() + result.stdout.no_fnmatch_line("* ERROR at teardown *") + + def test_unwrapping_pytest_fixture(self, pytester: Pytester) -> None: + """Ensure the unwrap method on `FixtureFunctionDefinition` correctly wraps and unwraps methods and functions""" + pytester.makepyfile( + """ + import pytest + import inspect + + class FixtureFunctionDefTestClass: + def __init__(self) -> None: + self.i = 10 + + @pytest.fixture + def fixture_function_def_test_method(self): + return self.i + + + @pytest.fixture + def fixture_function_def_test_func(): + return 9 + + + def test_get_wrapped_func_returns_method(): + obj = FixtureFunctionDefTestClass() + wrapped_function_result = ( + obj.fixture_function_def_test_method._get_wrapped_function() + ) + assert inspect.ismethod(wrapped_function_result) + assert wrapped_function_result() == 10 + + + def test_get_wrapped_func_returns_function(): + assert fixture_function_def_test_func._get_wrapped_function()() == 9 + """ + ) + result = pytester.runpytest() + result.assert_outcomes(passed=2) + + def test_fixture_wrapped_looks_liked_wrapped_function( + self, pytester: Pytester + ) -> None: + """Ensure that `FixtureFunctionDefinition` behaves like the function it wrapped.""" + pytester.makepyfile( + """ + import pytest + + @pytest.fixture + def fixture_function_def_test_func(): + return 9 + fixture_function_def_test_func.__doc__ = "documentation" + + def test_fixture_has_same_doc(): + assert fixture_function_def_test_func.__doc__ == "documentation" + """ + ) + result = pytester.runpytest() + result.assert_outcomes(passed=1) + class TestFixtureManagerParseFactories: @pytest.fixture @@ -1573,7 +1723,7 @@ def test_parsefactories_conftest(self, pytester: Pytester) -> None: """ def test_hello(item, fm): for name in ("fm", "hello", "item"): - faclist = fm.getfixturedefs(name, item.nodeid) + faclist = fm.getfixturedefs(name, item) assert len(faclist) == 1 fac = faclist[0] assert fac.func.__name__ == name @@ -1597,7 +1747,7 @@ class TestClass(object): def hello(self, request): return "class" def test_hello(self, item, fm): - faclist = fm.getfixturedefs("hello", item.nodeid) + faclist = fm.getfixturedefs("hello", item) print(faclist) assert len(faclist) == 3 @@ -1803,7 +1953,7 @@ def test_parsefactories_conftest(self, pytester: Pytester) -> None: """ from _pytest.pytester import get_public_names def test_check_setup(item, fm): - autousenames = list(fm._getautousenames(item.nodeid)) + autousenames = list(fm._getautousenames(item)) assert len(get_public_names(autousenames)) == 2 assert "perfunction2" in autousenames assert "perfunction" in autousenames @@ -2100,9 +2250,7 @@ def test_2(self): reprec = pytester.inline_run("-v", "-s", "--confcutdir", pytester.path) reprec.assertoutcome(passed=8) config = reprec.getcalls("pytest_unconfigure")[0].config - values = config.pluginmanager._getconftestmodules( - p, importmode="prepend", rootpath=pytester.path - )[0].values + values = config.pluginmanager._getconftestmodules(p)[0].values assert values == ["fin_a1", "fin_a2", "fin_b1", "fin_b2"] * 2 def test_scope_ordering(self, pytester: Pytester) -> None: @@ -2183,14 +2331,14 @@ def test_ordering_dependencies_torndown_first( ) -> None: """#226""" pytester.makepyfile( - """ + f""" import pytest values = [] - @pytest.fixture(%(param1)s) + @pytest.fixture({param1}) def arg1(request): request.addfinalizer(lambda: values.append("fin1")) values.append("new1") - @pytest.fixture(%(param2)s) + @pytest.fixture({param2}) def arg2(request, arg1): request.addfinalizer(lambda: values.append("fin2")) values.append("new2") @@ -2200,11 +2348,29 @@ def test_arg(arg2): def test_check(): assert values == ["new1", "new2", "fin2", "fin1"] """ - % locals() ) reprec = pytester.inline_run("-s") reprec.assertoutcome(passed=2) + def test_reordering_catastrophic_performance(self, pytester: Pytester) -> None: + """Check that a certain high-scope parametrization pattern doesn't cause + a catasrophic slowdown. + + Regression test for #12355. + """ + pytester.makepyfile(""" + import pytest + + params = tuple("abcdefghijklmnopqrstuvwxyz") + @pytest.mark.parametrize(params, [range(len(params))] * 3, scope="module") + def test_parametrize(a, b, c, d, e, f, g, h, i, j, k, l, m, n, o, p, q, r, s, t, u, v, w, x, y, z): + pass + """) + + result = pytester.runpytest() + + result.assert_outcomes(passed=3) + class TestFixtureMarker: def test_parametrize(self, pytester: Pytester) -> None: @@ -2254,18 +2420,17 @@ def test_override_parametrized_fixture_issue_979( This was a regression introduced in the fix for #736. """ pytester.makepyfile( - """ + f""" import pytest @pytest.fixture(params=[1, 2]) def fixt(request): return request.param - @pytest.mark.parametrize(%s, [(3, 'x'), (4, 'x')]) + @pytest.mark.parametrize({param_args}, [(3, 'x'), (4, 'x')]) def test_foo(fixt, val): pass """ - % param_args ) reprec = pytester.inline_run() reprec.assertoutcome(passed=2) @@ -2461,6 +2626,33 @@ def test_1(arg): ["*ScopeMismatch*You tried*function*session*request*"] ) + def test_scope_mismatch_already_computed_dynamic(self, pytester: Pytester) -> None: + pytester.makepyfile( + test_it=""" + import pytest + + @pytest.fixture(scope="function") + def fixfunc(): pass + + @pytest.fixture(scope="module") + def fixmod(fixfunc): pass + + def test_it(request, fixfunc): + request.getfixturevalue("fixmod") + """, + ) + + result = pytester.runpytest() + assert result.ret == ExitCode.TESTS_FAILED + result.stdout.fnmatch_lines( + [ + "*ScopeMismatch*Requesting fixture stack*", + "test_it.py:6: def fixmod(fixfunc)", + "Requested fixture:", + "test_it.py:3: def fixfunc()", + ] + ) + def test_dynamic_scope(self, pytester: Pytester) -> None: pytester.makeconftest( """ @@ -2707,12 +2899,12 @@ def test2(reprovision): """ test_dynamic_parametrized_ordering.py::test[flavor1-vxlan] PASSED test_dynamic_parametrized_ordering.py::test2[flavor1-vxlan] PASSED - test_dynamic_parametrized_ordering.py::test[flavor2-vxlan] PASSED - test_dynamic_parametrized_ordering.py::test2[flavor2-vxlan] PASSED - test_dynamic_parametrized_ordering.py::test[flavor2-vlan] PASSED - test_dynamic_parametrized_ordering.py::test2[flavor2-vlan] PASSED test_dynamic_parametrized_ordering.py::test[flavor1-vlan] PASSED test_dynamic_parametrized_ordering.py::test2[flavor1-vlan] PASSED + test_dynamic_parametrized_ordering.py::test[flavor2-vlan] PASSED + test_dynamic_parametrized_ordering.py::test2[flavor2-vlan] PASSED + test_dynamic_parametrized_ordering.py::test[flavor2-vxlan] PASSED + test_dynamic_parametrized_ordering.py::test2[flavor2-vxlan] PASSED """ ) @@ -2893,7 +3085,7 @@ def test_finish(): *3 passed* """ ) - result.stdout.no_fnmatch_line("*error*") + assert result.ret == 0 def test_fixture_finalizer(self, pytester: Pytester) -> None: pytester.makeconftest( @@ -3035,21 +3227,21 @@ def test_finalizer_order_on_parametrization( ) -> None: """#246""" pytester.makepyfile( - """ + f""" import pytest values = [] - @pytest.fixture(scope=%(scope)r, params=["1"]) + @pytest.fixture(scope={scope!r}, params=["1"]) def fix1(request): return request.param - @pytest.fixture(scope=%(scope)r) + @pytest.fixture(scope={scope!r}) def fix2(request, base): def cleanup_fix2(): assert not values, "base should not have been finalized" request.addfinalizer(cleanup_fix2) - @pytest.fixture(scope=%(scope)r) + @pytest.fixture(scope={scope!r}) def base(request, fix1): def cleanup_base(): values.append("fin_base") @@ -3063,7 +3255,6 @@ def test_baz(base, fix2): def test_other(): pass """ - % {"scope": scope} ) reprec = pytester.inline_run("-lvs") reprec.assertoutcome(passed=3) @@ -3249,13 +3440,13 @@ class TestRequestScopeAccess: def test_setup(self, pytester: Pytester, scope, ok, error) -> None: pytester.makepyfile( - """ + f""" import pytest - @pytest.fixture(scope=%r, autouse=True) + @pytest.fixture(scope={scope!r}, autouse=True) def myscoped(request): - for x in %r: + for x in {ok.split()}: assert hasattr(request, x) - for x in %r: + for x in {error.split()}: pytest.raises(AttributeError, lambda: getattr(request, x)) assert request.session @@ -3263,20 +3454,19 @@ def myscoped(request): def test_func(): pass """ - % (scope, ok.split(), error.split()) ) reprec = pytester.inline_run("-l") reprec.assertoutcome(passed=1) def test_funcarg(self, pytester: Pytester, scope, ok, error) -> None: pytester.makepyfile( - """ + f""" import pytest - @pytest.fixture(scope=%r) + @pytest.fixture(scope={scope!r}) def arg(request): - for x in %r: + for x in {ok.split()!r}: assert hasattr(request, x) - for x in %r: + for x in {error.split()!r}: pytest.raises(AttributeError, lambda: getattr(request, x)) assert request.session @@ -3284,7 +3474,6 @@ def arg(request): def test_func(arg): pass """ - % (scope, ok.split(), error.split()) ) reprec = pytester.inline_run() reprec.assertoutcome(passed=1) @@ -3356,6 +3545,28 @@ def test_something(): ["*def gen(qwe123):*", "*fixture*qwe123*not found*", "*1 error*"] ) + def test_cached_exception_doesnt_get_longer(self, pytester: Pytester) -> None: + """Regression test for #12204.""" + pytester.makepyfile( + """ + import pytest + @pytest.fixture(scope="session") + def bad(): 1 / 0 + + def test_1(bad): pass + def test_2(bad): pass + def test_3(bad): pass + """ + ) + + result = pytester.runpytest_inprocess("--tb=native") + assert result.ret == ExitCode.TESTS_FAILED + failures = result.reprec.getfailures() # type: ignore[attr-defined] + assert len(failures) == 3 + lines1 = failures[1].longrepr.reprtraceback.reprentries[0].lines + lines2 = failures[2].longrepr.reprtraceback.reprentries[0].lines + assert len(lines1) == len(lines2) + class TestShowFixtures: def test_funcarg_compat(self, pytester: Pytester) -> None: @@ -4009,7 +4220,8 @@ def test_func(m1): """ ) items, _ = pytester.inline_genitems() - request = FixtureRequest(items[0], _ispytest=True) + assert isinstance(items[0], Function) + request = TopRequest(items[0], _ispytest=True) assert request.fixturenames == "m1 f1".split() def test_func_closure_with_native_fixtures( @@ -4057,7 +4269,8 @@ def test_foo(f1, p1, m1, f2, s1): pass """ ) items, _ = pytester.inline_genitems() - request = FixtureRequest(items[0], _ispytest=True) + assert isinstance(items[0], Function) + request = TopRequest(items[0], _ispytest=True) # order of fixtures based on their scope and position in the parameter list assert ( request.fixturenames @@ -4084,7 +4297,8 @@ def test_func(f1, m1): """ ) items, _ = pytester.inline_genitems() - request = FixtureRequest(items[0], _ispytest=True) + assert isinstance(items[0], Function) + request = TopRequest(items[0], _ispytest=True) assert request.fixturenames == "m1 f1".split() def test_func_closure_scopes_reordered(self, pytester: Pytester) -> None: @@ -4117,7 +4331,8 @@ def test_func(self, f2, f1, c1, m1, s1): """ ) items, _ = pytester.inline_genitems() - request = FixtureRequest(items[0], _ispytest=True) + assert isinstance(items[0], Function) + request = TopRequest(items[0], _ispytest=True) assert request.fixturenames == "s1 m1 c1 f2 f1".split() def test_func_closure_same_scope_closer_root_first( @@ -4159,7 +4374,8 @@ def test_func(m_test, f1): } ) items, _ = pytester.inline_genitems() - request = FixtureRequest(items[0], _ispytest=True) + assert isinstance(items[0], Function) + request = TopRequest(items[0], _ispytest=True) assert request.fixturenames == "p_sub m_conf m_sub m_test f1".split() def test_func_closure_all_scopes_complex(self, pytester: Pytester) -> None: @@ -4203,9 +4419,43 @@ def test_func(self, f2, f1, m2): """ ) items, _ = pytester.inline_genitems() - request = FixtureRequest(items[0], _ispytest=True) + assert isinstance(items[0], Function) + request = TopRequest(items[0], _ispytest=True) assert request.fixturenames == "s1 p1 m1 m2 c1 f2 f1".split() + def test_parametrized_package_scope_reordering(self, pytester: Pytester) -> None: + """A parameterized package-scoped fixture correctly reorders items to + minimize setups & teardowns. + + Regression test for #12328. + """ + pytester.makepyfile( + __init__="", + conftest=""" + import pytest + @pytest.fixture(scope="package", params=["a", "b"]) + def fix(request): + return request.param + """, + test_1="def test1(fix): pass", + test_2="def test2(fix): pass", + ) + + result = pytester.runpytest("--setup-plan") + assert result.ret == ExitCode.OK + result.stdout.fnmatch_lines( + [ + " SETUP P fix['a']", + " test_1.py::test1[a] (fixtures used: fix, request)", + " test_2.py::test2[a] (fixtures used: fix, request)", + " TEARDOWN P fix['a']", + " SETUP P fix['b']", + " test_1.py::test1[b] (fixtures used: fix, request)", + " test_2.py::test2[b] (fixtures used: fix, request)", + " TEARDOWN P fix['b']", + ], + ) + def test_multiple_packages(self, pytester: Pytester) -> None: """Complex test involving multiple package fixtures. Make sure teardowns are executed in order. @@ -4324,6 +4574,42 @@ def fix(): assert fix() == 1 +def test_fixture_double_decorator(pytester: Pytester) -> None: + """Check if an error is raised when using @pytest.fixture twice.""" + pytester.makepyfile( + """ + import pytest + + @pytest.fixture + @pytest.fixture + def fixt(): + pass + """ + ) + result = pytester.runpytest() + result.assert_outcomes(errors=1) + result.stdout.fnmatch_lines( + [ + "E * ValueError: @pytest.fixture is being applied more than once to the same function 'fixt'" + ] + ) + + +def test_fixture_class(pytester: Pytester) -> None: + """Check if an error is raised when using @pytest.fixture on a class.""" + pytester.makepyfile( + """ + import pytest + + @pytest.fixture + class A: + pass + """ + ) + result = pytester.runpytest() + result.assert_outcomes(errors=1) + + def test_fixture_param_shadowing(pytester: Pytester) -> None: """Parametrized arguments would be shadowed if a fixture with the same name also exists (#5036)""" pytester.makepyfile( @@ -4373,7 +4659,7 @@ def test_fixture_named_request(pytester: Pytester) -> None: result.stdout.fnmatch_lines( [ "*'request' is a reserved word for fixtures, use another name:", - " *test_fixture_named_request.py:5", + " *test_fixture_named_request.py:8", ] ) @@ -4502,3 +4788,283 @@ def test_fixt(custom): result.assert_outcomes(errors=1) result.stdout.fnmatch_lines([expected]) assert result.ret == ExitCode.TESTS_FAILED + + +def test_deduplicate_names() -> None: + items = deduplicate_names("abacd") + assert items == ("a", "b", "c", "d") + items = deduplicate_names((*items, "g", "f", "g", "e", "b")) + assert items == ("a", "b", "c", "d", "g", "f", "e") + + +def test_staticmethod_classmethod_fixture_instance(pytester: Pytester) -> None: + """Ensure that static and class methods get and have access to a fresh + instance. + + This also ensures `setup_method` works well with static and class methods. + + Regression test for #12065. + """ + pytester.makepyfile( + """ + import pytest + + class Test: + ran_setup_method = False + ran_fixture = False + + def setup_method(self): + assert not self.ran_setup_method + self.ran_setup_method = True + + @pytest.fixture(autouse=True) + def fixture(self): + assert not self.ran_fixture + self.ran_fixture = True + + def test_method(self): + assert self.ran_setup_method + assert self.ran_fixture + + @staticmethod + def test_1(request): + assert request.instance.ran_setup_method + assert request.instance.ran_fixture + + @classmethod + def test_2(cls, request): + assert request.instance.ran_setup_method + assert request.instance.ran_fixture + """ + ) + result = pytester.runpytest() + assert result.ret == ExitCode.OK + result.assert_outcomes(passed=3) + + +def test_scoped_fixture_caching(pytester: Pytester) -> None: + """Make sure setup and finalization is only run once when using scoped fixture + multiple times.""" + pytester.makepyfile( + """ + from __future__ import annotations + + from typing import Generator + + import pytest + executed: list[str] = [] + @pytest.fixture(scope="class") + def fixture_1() -> Generator[None, None, None]: + executed.append("fix setup") + yield + executed.append("fix teardown") + + + class TestFixtureCaching: + def test_1(self, fixture_1: None) -> None: + assert executed == ["fix setup"] + + def test_2(self, fixture_1: None) -> None: + assert executed == ["fix setup"] + + + def test_expected_setup_and_teardown() -> None: + assert executed == ["fix setup", "fix teardown"] + """ + ) + result = pytester.runpytest() + assert result.ret == 0 + + +def test_scoped_fixture_caching_exception(pytester: Pytester) -> None: + """Make sure setup & finalization is only run once for scoped fixture, with a cached exception.""" + pytester.makepyfile( + """ + from __future__ import annotations + + import pytest + executed_crash: list[str] = [] + + + @pytest.fixture(scope="class") + def fixture_crash(request: pytest.FixtureRequest) -> None: + executed_crash.append("fix_crash setup") + + def my_finalizer() -> None: + executed_crash.append("fix_crash teardown") + + request.addfinalizer(my_finalizer) + + raise Exception("foo") + + + class TestFixtureCachingException: + @pytest.mark.xfail + def test_crash_1(self, fixture_crash: None) -> None: + ... + + @pytest.mark.xfail + def test_crash_2(self, fixture_crash: None) -> None: + ... + + + def test_crash_expected_setup_and_teardown() -> None: + assert executed_crash == ["fix_crash setup", "fix_crash teardown"] + """ + ) + result = pytester.runpytest() + assert result.ret == 0 + + +def test_scoped_fixture_teardown_order(pytester: Pytester) -> None: + """ + Make sure teardowns happen in reverse order of setup with scoped fixtures, when + a later test only depends on a subset of scoped fixtures. + + Regression test for https://github.com/pytest-dev/pytest/issues/1489 + """ + pytester.makepyfile( + """ + from typing import Generator + + import pytest + + + last_executed = "" + + + @pytest.fixture(scope="module") + def fixture_1() -> Generator[None, None, None]: + global last_executed + assert last_executed == "" + last_executed = "fixture_1_setup" + yield + assert last_executed == "fixture_2_teardown" + last_executed = "fixture_1_teardown" + + + @pytest.fixture(scope="module") + def fixture_2() -> Generator[None, None, None]: + global last_executed + assert last_executed == "fixture_1_setup" + last_executed = "fixture_2_setup" + yield + assert last_executed == "run_test" + last_executed = "fixture_2_teardown" + + + def test_fixture_teardown_order(fixture_1: None, fixture_2: None) -> None: + global last_executed + assert last_executed == "fixture_2_setup" + last_executed = "run_test" + + + def test_2(fixture_1: None) -> None: + # This would previously queue an additional teardown of fixture_1, + # despite fixture_1's value being cached, which caused fixture_1 to be + # torn down before fixture_2 - violating the rule that teardowns should + # happen in reverse order of setup. + pass + """ + ) + result = pytester.runpytest() + assert result.ret == 0 + + +def test_subfixture_teardown_order(pytester: Pytester) -> None: + """ + Make sure fixtures don't re-register their finalization in parent fixtures multiple + times, causing ordering failure in their teardowns. + + Regression test for #12135 + """ + pytester.makepyfile( + """ + import pytest + + execution_order = [] + + @pytest.fixture(scope="class") + def fixture_1(): + ... + + @pytest.fixture(scope="class") + def fixture_2(fixture_1): + execution_order.append("setup 2") + yield + execution_order.append("teardown 2") + + @pytest.fixture(scope="class") + def fixture_3(fixture_1): + execution_order.append("setup 3") + yield + execution_order.append("teardown 3") + + class TestFoo: + def test_initialize_fixtures(self, fixture_2, fixture_3): + ... + + # This would previously reschedule fixture_2's finalizer in the parent fixture, + # causing it to be torn down before fixture 3. + def test_reschedule_fixture_2(self, fixture_2): + ... + + # Force finalization directly on fixture_1 + # Otherwise the cleanup would sequence 3&2 before 1 as normal. + @pytest.mark.parametrize("fixture_1", [None], indirect=["fixture_1"]) + def test_finalize_fixture_1(self, fixture_1): + ... + + def test_result(): + assert execution_order == ["setup 2", "setup 3", "teardown 3", "teardown 2"] + """ + ) + result = pytester.runpytest() + assert result.ret == 0 + + +def test_parametrized_fixture_scope_allowed(pytester: Pytester) -> None: + """ + Make sure scope from parametrize does not affect fixture's ability to be + depended upon. + + Regression test for #13248 + """ + pytester.makepyfile( + """ + import pytest + + @pytest.fixture(scope="session") + def my_fixture(request): + return getattr(request, "param", None) + + @pytest.fixture(scope="session") + def another_fixture(my_fixture): + return my_fixture + + @pytest.mark.parametrize("my_fixture", ["a value"], indirect=True, scope="function") + def test_foo(another_fixture): + assert another_fixture == "a value" + """ + ) + result = pytester.runpytest() + result.assert_outcomes(passed=1) + + +def test_collect_positional_only(pytester: Pytester) -> None: + """Support the collection of tests with positional-only arguments (#13376).""" + pytester.makepyfile( + """ + import pytest + + class Test: + @pytest.fixture + def fix(self): + return 1 + + def test_method(self, /, fix): + assert fix == 1 + """ + ) + result = pytester.runpytest() + result.assert_outcomes(passed=1) diff --git a/testing/python/integration.py b/testing/python/integration.py index 054c14a39e4..c96b6e4c260 100644 --- a/testing/python/integration.py +++ b/testing/python/integration.py @@ -1,8 +1,11 @@ -import pytest +# mypy: allow-untyped-defs +from __future__ import annotations + from _pytest._code import getfslineno from _pytest.fixtures import getfixturemarker from _pytest.pytester import Pytester from _pytest.python import Function +import pytest def test_wrapped_getfslineno() -> None: @@ -42,9 +45,10 @@ def f(x): assert values == ("x",) def test_getfuncargnames_patching(self): - from _pytest.compat import getfuncargnames from unittest.mock import patch + from _pytest.compat import getfuncargnames + class T: def original(self, x, y, z): pass @@ -161,7 +165,7 @@ def mock_basename(path): @mock.patch("os.path.abspath") @mock.patch("os.path.normpath") @mock.patch("os.path.basename", new=mock_basename) - def test_someting(normpath, abspath, tmp_path): + def test_something(normpath, abspath, tmp_path): abspath.return_value = "this" os.path.normpath(os.path.abspath("hello")) normpath.assert_any_call("this") @@ -174,7 +178,7 @@ def test_someting(normpath, abspath, tmp_path): funcnames = [ call.report.location[2] for call in calls if call.report.when == "call" ] - assert funcnames == ["T.test_hello", "test_someting"] + assert funcnames == ["T.test_hello", "test_something"] def test_mock_sorting(self, pytester: Pytester) -> None: pytest.importorskip("mock", "1.0.1") @@ -403,27 +407,59 @@ def test_params(a, b): res = pytester.runpytest("--collect-only") res.stdout.fnmatch_lines(["*spam-2*", "*ham-2*"]) + def test_param_rejects_usefixtures(self, pytester: Pytester) -> None: + pytester.makepyfile( + """ + import pytest + + @pytest.mark.parametrize("x", [ + pytest.param(1, marks=[pytest.mark.usefixtures("foo")]), + ]) + def test_foo(x): + pass + """ + ) + res = pytester.runpytest("--collect-only") + res.stdout.fnmatch_lines( + ["*test_param_rejects_usefixtures.py:4*", "*pytest.param(*"] + ) + def test_function_instance(pytester: Pytester) -> None: items = pytester.getitems( """ def test_func(): pass + class TestIt: def test_method(self): pass + @classmethod def test_class(cls): pass + @staticmethod def test_static(): pass """ ) assert len(items) == 4 + assert isinstance(items[0], Function) assert items[0].name == "test_func" assert items[0].instance is None + assert isinstance(items[1], Function) assert items[1].name == "test_method" assert items[1].instance is not None assert items[1].instance.__class__.__name__ == "TestIt" + + # Even class and static methods get an instance! + # This is the instance used for bound fixture methods, which + # class/staticmethod tests are perfectly able to request. + assert isinstance(items[2], Function) + assert items[2].name == "test_class" + assert items[2].instance is not None + assert isinstance(items[3], Function) assert items[3].name == "test_static" - assert items[3].instance is None + assert items[3].instance is not None + + assert items[1].instance is not items[2].instance is not items[3].instance diff --git a/testing/python/metafunc.py b/testing/python/metafunc.py index a9e9b526934..7ae26de3a18 100644 --- a/testing/python/metafunc.py +++ b/testing/python/metafunc.py @@ -1,3 +1,8 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + +from collections.abc import Iterator +from collections.abc import Sequence import dataclasses import itertools import re @@ -5,27 +10,21 @@ import textwrap from typing import Any from typing import cast -from typing import Dict -from typing import Iterator -from typing import List -from typing import Optional -from typing import Sequence -from typing import Tuple -from typing import Union import hypothesis from hypothesis import strategies -import pytest from _pytest import fixtures from _pytest import python -from _pytest.compat import _format_args from _pytest.compat import getfuncargnames from _pytest.compat import NOTSET from _pytest.outcomes import fail +from _pytest.outcomes import Failed from _pytest.pytester import Pytester +from _pytest.python import Function from _pytest.python import IdMaker from _pytest.scope import Scope +import pytest class TestMetafunc: @@ -34,11 +33,19 @@ def Metafunc(self, func, config=None) -> python.Metafunc: # on the funcarg level, so we don't need a full blown # initialization. class FuncFixtureInfoMock: - name2fixturedefs = None + name2fixturedefs: dict[str, list[fixtures.FixtureDef[object]]] = {} def __init__(self, names): self.names_closure = names + @dataclasses.dataclass + class FixtureManagerMock: + config: Any + + @dataclasses.dataclass + class SessionMock: + _fixturemanager: FixtureManagerMock + @dataclasses.dataclass class DefinitionMock(python.FunctionDefinition): _nodeid: str @@ -47,6 +54,8 @@ class DefinitionMock(python.FunctionDefinition): names = getfuncargnames(func) fixtureinfo: Any = FuncFixtureInfoMock(names) definition: Any = DefinitionMock._create(obj=func, _nodeid="mock::nodeid") + definition._fixtureinfo = fixtureinfo + definition.session = SessionMock(FixtureManagerMock({})) return python.Metafunc(definition, fixtureinfo, config, _ispytest=True) def test_no_funcargs(self) -> None: @@ -73,11 +82,15 @@ def func(x, y): metafunc = self.Metafunc(func) metafunc.parametrize("x", [1, 2]) - pytest.raises(ValueError, lambda: metafunc.parametrize("x", [5, 6])) - pytest.raises(ValueError, lambda: metafunc.parametrize("x", [5, 6])) + with pytest.raises(pytest.Collector.CollectError): + metafunc.parametrize("x", [5, 6]) + with pytest.raises(pytest.Collector.CollectError): + metafunc.parametrize("x", [5, 6]) metafunc.parametrize("y", [1, 2]) - pytest.raises(ValueError, lambda: metafunc.parametrize("y", [5, 6])) - pytest.raises(ValueError, lambda: metafunc.parametrize("y", [5, 6])) + with pytest.raises(pytest.Collector.CollectError): + metafunc.parametrize("y", [5, 6]) + with pytest.raises(pytest.Collector.CollectError): + metafunc.parametrize("y", [5, 6]) with pytest.raises(TypeError, match="^ids must be a callable or an iterable$"): metafunc.parametrize("y", [5, 6], ids=42) # type: ignore[arg-type] @@ -90,7 +103,7 @@ class Exc(Exception): def __repr__(self): return "Exc(from_gen)" - def gen() -> Iterator[Union[int, None, Exc]]: + def gen() -> Iterator[int | None | Exc]: yield 0 yield None yield Exc() @@ -98,8 +111,8 @@ def gen() -> Iterator[Union[int, None, Exc]]: metafunc = self.Metafunc(func) # When the input is an iterator, only len(args) are taken, # so the bad Exc isn't reached. - metafunc.parametrize("x", [1, 2], ids=gen()) # type: ignore[arg-type] - assert [(x.funcargs, x.id) for x in metafunc._calls] == [ + metafunc.parametrize("x", [1, 2], ids=gen()) + assert [(x.params, x.id) for x in metafunc._calls] == [ ({"x": 1}, "0"), ({"x": 2}, "2"), ] @@ -110,7 +123,7 @@ def gen() -> Iterator[Union[int, None, Exc]]: r"Supported types are: .*" ), ): - metafunc.parametrize("x", [1, 2, 3], ids=gen()) # type: ignore[arg-type] + metafunc.parametrize("x", [1, 2, 3], ids=gen()) def test_parametrize_bad_scope(self) -> None: def func(x): @@ -145,13 +158,14 @@ class DummyFixtureDef: _scope: Scope fixtures_defs = cast( - Dict[str, Sequence[fixtures.FixtureDef[object]]], + dict[str, Sequence[fixtures.FixtureDef[object]]], dict( session_fix=[DummyFixtureDef(Scope.Session)], package_fix=[DummyFixtureDef(Scope.Package)], module_fix=[DummyFixtureDef(Scope.Module)], class_fix=[DummyFixtureDef(Scope.Class)], func_fix=[DummyFixtureDef(Scope.Function)], + mixed_fix=[DummyFixtureDef(Scope.Module), DummyFixtureDef(Scope.Class)], ), ) @@ -188,6 +202,7 @@ def find_scope(argnames, indirect): ) == Scope.Module ) + assert find_scope(["mixed_fix"], indirect=True) == Scope.Class def test_parametrize_and_id(self) -> None: def func(x, y): @@ -333,7 +348,7 @@ def getini(self, name): option = "disable_test_id_escaping_and_forfeit_all_rights_to_community_support" - values: List[Tuple[str, Any, str]] = [ + values: list[tuple[str, Any, str]] = [ ("ação", MockConfig({option: True}), "ação"), ("ação", MockConfig({option: False}), "a\\xe7\\xe3o"), ] @@ -503,7 +518,7 @@ def test_idmaker_enum(self) -> None: def test_idmaker_idfn(self) -> None: """#351""" - def ids(val: object) -> Optional[str]: + def ids(val: object) -> str | None: if isinstance(val, Exception): return repr(val) return None @@ -566,7 +581,7 @@ def getini(self, name): option = "disable_test_id_escaping_and_forfeit_all_rights_to_community_support" - values: List[Tuple[Any, str]] = [ + values: list[tuple[Any, str]] = [ (MockConfig({option: True}), "ação"), (MockConfig({option: False}), "a\\xe7\\xe3o"), ] @@ -604,7 +619,7 @@ def getini(self, name): option = "disable_test_id_escaping_and_forfeit_all_rights_to_community_support" - values: List[Tuple[Any, str]] = [ + values: list[tuple[Any, str]] = [ (MockConfig({option: True}), "ação"), (MockConfig({option: False}), "a\\xe7\\xe3o"), ] @@ -614,6 +629,44 @@ def getini(self, name): ).make_unique_parameterset_ids() assert result == [expected] + def test_idmaker_with_param_id_and_config(self) -> None: + """Unit test for expected behavior to create ids with pytest.param(id=...) and + disable_test_id_escaping_and_forfeit_all_rights_to_community_support + option (#9037). + """ + + class MockConfig: + def __init__(self, config): + self.config = config + + def getini(self, name): + return self.config[name] + + option = "disable_test_id_escaping_and_forfeit_all_rights_to_community_support" + + values: list[tuple[Any, str]] = [ + (MockConfig({option: True}), "ação"), + (MockConfig({option: False}), "a\\xe7\\xe3o"), + ] + for config, expected in values: + result = IdMaker( + ("a",), + [pytest.param("string", id="ação")], + None, + None, + config, + None, + None, + ).make_unique_parameterset_ids() + assert result == [expected] + + def test_idmaker_duplicated_empty_str(self) -> None: + """Regression test for empty strings parametrized more than once (#11563).""" + result = IdMaker( + ("a",), [pytest.param(""), pytest.param("")], None, None, None, None, None + ).make_unique_parameterset_ids() + assert result == ["0", "1"] + def test_parametrize_ids_exception(self, pytester: Pytester) -> None: """ :param pytester: the instance of Pytester class, a temporary @@ -713,8 +766,6 @@ def func(x, y): metafunc.parametrize("x", [1], indirect=True) metafunc.parametrize("y", [2, 3], indirect=True) assert len(metafunc._calls) == 2 - assert metafunc._calls[0].funcargs == {} - assert metafunc._calls[1].funcargs == {} assert metafunc._calls[0].params == dict(x=1, y=2) assert metafunc._calls[1].params == dict(x=1, y=3) @@ -726,8 +777,10 @@ def func(x, y): metafunc = self.Metafunc(func) metafunc.parametrize("x, y", [("a", "b")], indirect=["x"]) - assert metafunc._calls[0].funcargs == dict(y="b") - assert metafunc._calls[0].params == dict(x="a") + assert metafunc._calls[0].params == dict(x="a", y="b") + # Since `y` is a direct parameter, its pseudo-fixture would + # be registered. + assert list(metafunc._arg2fixturedefs.keys()) == ["y"] def test_parametrize_indirect_list_all(self) -> None: """#714""" @@ -737,8 +790,8 @@ def func(x, y): metafunc = self.Metafunc(func) metafunc.parametrize("x, y", [("a", "b")], indirect=["x", "y"]) - assert metafunc._calls[0].funcargs == {} assert metafunc._calls[0].params == dict(x="a", y="b") + assert list(metafunc._arg2fixturedefs.keys()) == [] def test_parametrize_indirect_list_empty(self) -> None: """#714""" @@ -748,8 +801,8 @@ def func(x, y): metafunc = self.Metafunc(func) metafunc.parametrize("x, y", [("a", "b")], indirect=[]) - assert metafunc._calls[0].funcargs == dict(x="a", y="b") - assert metafunc._calls[0].params == {} + assert metafunc._calls[0].params == dict(x="a", y="b") + assert list(metafunc._arg2fixturedefs.keys()) == ["x", "y"] def test_parametrize_indirect_wrong_type(self) -> None: def func(x, y): @@ -943,9 +996,9 @@ def test_parametrize_onearg(self) -> None: metafunc = self.Metafunc(lambda x: None) metafunc.parametrize("x", [1, 2]) assert len(metafunc._calls) == 2 - assert metafunc._calls[0].funcargs == dict(x=1) + assert metafunc._calls[0].params == dict(x=1) assert metafunc._calls[0].id == "1" - assert metafunc._calls[1].funcargs == dict(x=2) + assert metafunc._calls[1].params == dict(x=2) assert metafunc._calls[1].id == "2" def test_parametrize_onearg_indirect(self) -> None: @@ -960,11 +1013,45 @@ def test_parametrize_twoargs(self) -> None: metafunc = self.Metafunc(lambda x, y: None) metafunc.parametrize(("x", "y"), [(1, 2), (3, 4)]) assert len(metafunc._calls) == 2 - assert metafunc._calls[0].funcargs == dict(x=1, y=2) + assert metafunc._calls[0].params == dict(x=1, y=2) assert metafunc._calls[0].id == "1-2" - assert metafunc._calls[1].funcargs == dict(x=3, y=4) + assert metafunc._calls[1].params == dict(x=3, y=4) assert metafunc._calls[1].id == "3-4" + def test_high_scoped_parametrize_reordering(self, pytester: Pytester) -> None: + pytester.makepyfile( + """ + import pytest + + @pytest.mark.parametrize("arg2", [3, 4]) + @pytest.mark.parametrize("arg1", [0, 1, 2], scope='module') + def test1(arg1, arg2): + pass + + def test2(): + pass + + @pytest.mark.parametrize("arg1", [0, 1, 2], scope='module') + def test3(arg1): + pass + """ + ) + result = pytester.runpytest("--collect-only") + result.stdout.re_match_lines( + [ + r" ", + r" ", + r" ", + r" ", + r" ", + r" ", + r" ", + r" ", + r" ", + r" ", + ] + ) + def test_parametrize_multiple_times(self, pytester: Pytester) -> None: pytester.makepyfile( """ @@ -1036,27 +1123,6 @@ def test_3(self, arg, arg2): """ ) - def test_format_args(self) -> None: - def function1(): - pass - - assert _format_args(function1) == "()" - - def function2(arg1): - pass - - assert _format_args(function2) == "(arg1)" - - def function3(arg1, arg2="qwe"): - pass - - assert _format_args(function3) == "(arg1, arg2='qwe')" - - def function4(arg1, *args, **kwargs): - pass - - assert _format_args(function4) == "(arg1, *args, **kwargs)" - class TestMetafuncFunctional: def test_attributes(self, pytester: Pytester) -> None: @@ -1377,13 +1443,13 @@ def test_parametrize_scope_overrides( self, pytester: Pytester, scope: str, length: int ) -> None: pytester.makepyfile( - """ + f""" import pytest values = [] def pytest_generate_tests(metafunc): if "arg" in metafunc.fixturenames: metafunc.parametrize("arg", [1,2], indirect=True, - scope=%r) + scope={scope!r}) @pytest.fixture def arg(request): values.append(request.param) @@ -1393,9 +1459,8 @@ def test_hello(arg): def test_world(arg): assert arg in (1,2) def test_checklength(): - assert len(values) == %d + assert len(values) == {length} """ - % (scope, length) ) reprec = pytester.inline_run() reprec.assertoutcome(passed=5) @@ -1493,7 +1558,7 @@ def test_foo(x): pass """ ) - result = pytester.runpytest("--collectonly") + result = pytester.runpytest("--collect-only") result.stdout.fnmatch_lines( [ "collected 0 items / 1 error", @@ -1508,6 +1573,115 @@ def test_foo(x): ] ) + @pytest.mark.parametrize("scope", ["class", "package"]) + def test_parametrize_missing_scope_doesnt_crash( + self, pytester: Pytester, scope: str + ) -> None: + """Doesn't crash when parametrize(scope=) is used without a + corresponding node.""" + pytester.makepyfile( + f""" + import pytest + + @pytest.mark.parametrize("x", [0], scope="{scope}") + def test_it(x): pass + """ + ) + result = pytester.runpytest() + assert result.ret == 0 + + def test_parametrize_module_level_test_with_class_scope( + self, pytester: Pytester + ) -> None: + """ + Test that a class-scoped parametrization without a corresponding `Class` + gets module scope, i.e. we only create a single FixtureDef for it per module. + """ + module = pytester.makepyfile( + """ + import pytest + + @pytest.mark.parametrize("x", [0, 1], scope="class") + def test_1(x): + pass + + @pytest.mark.parametrize("x", [1, 2], scope="module") + def test_2(x): + pass + """ + ) + test_1_0, _, test_2_0, _ = pytester.genitems((pytester.getmodulecol(module),)) + + assert isinstance(test_1_0, Function) + assert test_1_0.name == "test_1[0]" + test_1_fixture_x = test_1_0._fixtureinfo.name2fixturedefs["x"][-1] + + assert isinstance(test_2_0, Function) + assert test_2_0.name == "test_2[1]" + test_2_fixture_x = test_2_0._fixtureinfo.name2fixturedefs["x"][-1] + + assert test_1_fixture_x is test_2_fixture_x + + def test_reordering_with_scopeless_and_just_indirect_parametrization( + self, pytester: Pytester + ) -> None: + pytester.makeconftest( + """ + import pytest + + @pytest.fixture(scope="package") + def fixture1(): + pass + """ + ) + pytester.makepyfile( + """ + import pytest + + @pytest.fixture(scope="module") + def fixture0(): + pass + + @pytest.fixture(scope="module") + def fixture1(fixture0): + pass + + @pytest.mark.parametrize("fixture1", [0], indirect=True) + def test_0(fixture1): + pass + + @pytest.fixture(scope="module") + def fixture(): + pass + + @pytest.mark.parametrize("fixture", [0], indirect=True) + def test_1(fixture): + pass + + def test_2(): + pass + + class Test: + @pytest.fixture(scope="class") + def fixture(self, fixture): + pass + + @pytest.mark.parametrize("fixture", [0], indirect=True) + def test_3(self, fixture): + pass + """ + ) + result = pytester.runpytest("-v") + assert result.ret == 0 + result.stdout.fnmatch_lines( + [ + "*test_0*", + "*test_1*", + "*test_2*", + "*test_3*", + ] + ) + class TestMetafuncFunctionalAuto: """Tests related to automatically find out the correct scope for @@ -1606,9 +1780,9 @@ def test_parametrize_some_arguments_auto_scope( self, pytester: Pytester, monkeypatch ) -> None: """Integration test for (#3941)""" - class_fix_setup: List[object] = [] + class_fix_setup: list[object] = [] monkeypatch.setattr(sys, "class_fix_setup", class_fix_setup, raising=False) - func_fix_setup: List[object] = [] + func_fix_setup: list[object] = [] monkeypatch.setattr(sys, "func_fix_setup", func_fix_setup, raising=False) pytester.makepyfile( @@ -1798,7 +1972,7 @@ def test_increment(n, expected): @pytest.mark.parametrize("strict", [True, False]) def test_xfail_passing_is_xpass(self, pytester: Pytester, strict: bool) -> None: - s = """ + s = f""" import pytest m = pytest.mark.xfail("sys.version_info > (0, 0, 0)", reason="some bug", strict={strict}) @@ -1810,9 +1984,7 @@ def test_xfail_passing_is_xpass(self, pytester: Pytester, strict: bool) -> None: ]) def test_increment(n, expected): assert n + 1 == expected - """.format( - strict=strict - ) + """ pytester.makepyfile(s) reprec = pytester.inline_run() passed, failed = (2, 1) if strict else (3, 0) @@ -1863,7 +2035,7 @@ def test_limit(limit, myfixture): @pytest.mark.parametrize("strict", [True, False]) def test_parametrize_marked_value(self, pytester: Pytester, strict: bool) -> None: - s = """ + s = f""" import pytest @pytest.mark.parametrize(("n", "expected"), [ @@ -1878,9 +2050,7 @@ def test_parametrize_marked_value(self, pytester: Pytester, strict: bool) -> Non ]) def test_increment(n, expected): assert n + 1 == expected - """.format( - strict=strict - ) + """ pytester.makepyfile(s) reprec = pytester.inline_run() passed, failed = (0, 2) if strict else (2, 0) @@ -1978,3 +2148,127 @@ def test_converted_to_str(a, b): "*= 6 passed in *", ] ) + + +class TestHiddenParam: + """Test that pytest.HIDDEN_PARAM works""" + + def test_parametrize_ids(self, pytester: Pytester) -> None: + items = pytester.getitems( + """ + import pytest + + @pytest.mark.parametrize( + ("foo", "bar"), + [ + ("a", "x"), + ("b", "y"), + ("c", "z"), + ], + ids=["paramset1", pytest.HIDDEN_PARAM, "paramset3"], + ) + def test_func(foo, bar): + pass + """ + ) + names = [item.name for item in items] + assert names == [ + "test_func[paramset1]", + "test_func", + "test_func[paramset3]", + ] + + def test_param_id(self, pytester: Pytester) -> None: + items = pytester.getitems( + """ + import pytest + + @pytest.mark.parametrize( + ("foo", "bar"), + [ + pytest.param("a", "x", id="paramset1"), + pytest.param("b", "y", id=pytest.HIDDEN_PARAM), + ("c", "z"), + ], + ) + def test_func(foo, bar): + pass + """ + ) + names = [item.name for item in items] + assert names == [ + "test_func[paramset1]", + "test_func", + "test_func[c-z]", + ] + + def test_multiple_hidden_param_is_forbidden(self, pytester: Pytester) -> None: + pytester.makepyfile( + """ + import pytest + + @pytest.mark.parametrize( + ("foo", "bar"), + [ + ("a", "x"), + ("b", "y"), + ], + ids=[pytest.HIDDEN_PARAM, pytest.HIDDEN_PARAM], + ) + def test_func(foo, bar): + pass + """ + ) + result = pytester.runpytest("--collect-only") + result.stdout.fnmatch_lines( + [ + "collected 0 items / 1 error", + "", + "*= ERRORS =*", + "*_ ERROR collecting test_multiple_hidden_param_is_forbidden.py _*", + "E Failed: In test_func: multiple instances of HIDDEN_PARAM cannot be used " + "in the same parametrize call, because the tests names need to be unique.", + "*! Interrupted: 1 error during collection !*", + "*= no tests collected, 1 error in *", + ] + ) + + def test_multiple_hidden_param_is_forbidden_idmaker(self) -> None: + id_maker = IdMaker( + ("foo", "bar"), + [pytest.param("a", "x"), pytest.param("b", "y")], + None, + [pytest.HIDDEN_PARAM, pytest.HIDDEN_PARAM], + None, + "some_node_id", + None, + ) + expected = "In some_node_id: multiple instances of HIDDEN_PARAM" + with pytest.raises(Failed, match=expected): + id_maker.make_unique_parameterset_ids() + + def test_multiple_parametrize(self, pytester: Pytester) -> None: + items = pytester.getitems( + """ + import pytest + + @pytest.mark.parametrize( + "bar", + ["x", "y"], + ) + @pytest.mark.parametrize( + "foo", + ["a", "b"], + ids=["a", pytest.HIDDEN_PARAM], + ) + def test_func(foo, bar): + pass + """ + ) + names = [item.name for item in items] + assert names == [ + "test_func[a-x]", + "test_func[a-y]", + "test_func[x]", + "test_func[y]", + ] diff --git a/testing/python/raises.py b/testing/python/raises.py index 3dcec31eb1f..40f9afea3ba 100644 --- a/testing/python/raises.py +++ b/testing/python/raises.py @@ -1,9 +1,18 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + +import io import re import sys -import pytest from _pytest.outcomes import Failed from _pytest.pytester import Pytester +from _pytest.warning_types import PytestWarning +import pytest + + +def wrap_escape(s: str) -> str: + return "^" + re.escape(s) + "$" class TestRaises: @@ -20,13 +29,32 @@ def test_raises_function(self): assert "invalid literal" in str(excinfo.value) def test_raises_does_not_allow_none(self): - with pytest.raises(ValueError, match="Expected an exception type or"): + with pytest.raises( + ValueError, + match=wrap_escape("You must specify at least one parameter to match on."), + ): # We're testing that this invalid usage gives a helpful error, # so we can ignore Mypy telling us that None is invalid. pytest.raises(expected_exception=None) # type: ignore + # it's unclear if this message is helpful, and if it is, should it trigger more + # liberally? Usually you'd get a TypeError here + def test_raises_false_and_arg(self): + with pytest.raises( + ValueError, + match=wrap_escape( + "Expected an exception type or a tuple of exception types, but got `False`. " + "Raising exceptions is already understood as failing the test, so you don't need " + "any special code to say 'this should never raise an exception'." + ), + ): + pytest.raises(False, int) # type: ignore[call-overload] + def test_raises_does_not_allow_empty_tuple(self): - with pytest.raises(ValueError, match="Expected an exception type or"): + with pytest.raises( + ValueError, + match=wrap_escape("You must specify at least one parameter to match on."), + ): pytest.raises(expected_exception=()) def test_raises_callable_no_exception(self) -> None: @@ -129,6 +157,26 @@ def test_division(example_input, expectation): result = pytester.runpytest() result.stdout.fnmatch_lines(["*2 failed*"]) + def test_raises_with_invalid_regex(self, pytester: Pytester) -> None: + pytester.makepyfile( + """ + import pytest + + def test_invalid_regex(): + with pytest.raises(ValueError, match="invalid regex character ["): + raise ValueError() + """ + ) + result = pytester.runpytest() + result.stdout.fnmatch_lines( + [ + "*Invalid regex pattern provided to 'match': unterminated character set at position 24*", + ] + ) + result.stdout.no_fnmatch_line("*Traceback*") + result.stdout.no_fnmatch_line("*File*") + result.stdout.no_fnmatch_line("*line*") + def test_noclass(self) -> None: with pytest.raises(TypeError): pytest.raises("wrong", lambda: None) # type: ignore[call-overload] @@ -146,7 +194,7 @@ def test_no_raise_message(self) -> None: try: pytest.raises(ValueError, int, "0") except pytest.fail.Exception as e: - assert e.msg == f"DID NOT RAISE {repr(ValueError)}" + assert e.msg == f"DID NOT RAISE {ValueError!r}" else: assert False, "Expected pytest.raises.Exception" @@ -154,11 +202,13 @@ def test_no_raise_message(self) -> None: with pytest.raises(ValueError): pass except pytest.fail.Exception as e: - assert e.msg == f"DID NOT RAISE {repr(ValueError)}" + assert e.msg == f"DID NOT RAISE {ValueError!r}" else: assert False, "Expected pytest.raises.Exception" - @pytest.mark.parametrize("method", ["function", "function_match", "with"]) + @pytest.mark.parametrize( + "method", ["function", "function_match", "with", "with_raisesexc", "with_group"] + ) def test_raises_cyclic_reference(self, method): """Ensure pytest.raises does not leave a reference cycle (#1965).""" import gc @@ -174,9 +224,17 @@ def __call__(self): pytest.raises(ValueError, t) elif method == "function_match": pytest.raises(ValueError, t).match("^$") - else: + elif method == "with": with pytest.raises(ValueError): t() + elif method == "with_raisesexc": + with pytest.RaisesExc(ValueError): + t() + elif method == "with_group": + with pytest.RaisesGroup(ValueError, allow_unwrapped=True): + t() + else: # pragma: no cover + raise AssertionError("bad parametrization") # ensure both forms of pytest.raises don't leave exceptions in sys.exc_info() assert sys.exc_info() == (None, None, None) @@ -198,7 +256,7 @@ def test_raises_match(self) -> None: f" Regex: {msg!r}\n" " Input: \"invalid literal for int() with base 10: 'asdf'\"" ) - with pytest.raises(AssertionError, match="(?m)" + re.escape(expr)): + with pytest.raises(AssertionError, match="^" + re.escape(expr) + "$"): with pytest.raises(ValueError, match=msg): int("asdf", base=10) @@ -216,6 +274,16 @@ def tfunc(match): pytest.raises(ValueError, tfunc, match="asdf").match("match=asdf") pytest.raises(ValueError, tfunc, match="").match("match=") + # empty string matches everything, which is probably not what the user wants + with pytest.warns( + PytestWarning, + match=wrap_escape( + "matching against an empty string will *always* pass. If you want to check for an empty message you " + "need to pass '^$'. If you don't want to match you should pass `None` or leave out the parameter." + ), + ): + pytest.raises(match="") + def test_match_failure_string_quoting(self): with pytest.raises(AssertionError) as excinfo: with pytest.raises(AssertionError, match="'foo"): @@ -242,7 +310,10 @@ def test_raises_match_wrong_type(self): pytest should throw the unexpected exception - the pattern match is not really relevant if we got a different exception. """ - with pytest.raises(ValueError): + with pytest.raises( + ValueError, + match=wrap_escape("invalid literal for int() with base 10: 'asdf'"), + ): with pytest.raises(IndexError, match="nomatch"): int("asdf") @@ -278,26 +349,62 @@ def __class__(self): assert "via __class__" in excinfo.value.args[0] def test_raises_context_manager_with_kwargs(self): - with pytest.raises(TypeError) as excinfo: - with pytest.raises(Exception, foo="bar"): # type: ignore[call-overload] + with pytest.raises(expected_exception=ValueError): + raise ValueError + with pytest.raises( + TypeError, + match=wrap_escape( + "Unexpected keyword arguments passed to pytest.raises: foo\n" + "Use context-manager form instead?" + ), + ): + with pytest.raises(OSError, foo="bar"): # type: ignore[call-overload] pass - assert "Unexpected keyword arguments" in str(excinfo.value) def test_expected_exception_is_not_a_baseexception(self) -> None: - with pytest.raises(TypeError) as excinfo: + with pytest.raises( + TypeError, + match=wrap_escape( + "expected exception must be a BaseException type, not 'str'" + ), + ): with pytest.raises("hello"): # type: ignore[call-overload] pass # pragma: no cover - assert "must be a BaseException type, not str" in str(excinfo.value) class NotAnException: pass - with pytest.raises(TypeError) as excinfo: + with pytest.raises( + ValueError, + match=wrap_escape( + "expected exception must be a BaseException type, not 'NotAnException'" + ), + ): with pytest.raises(NotAnException): # type: ignore[type-var] pass # pragma: no cover - assert "must be a BaseException type, not NotAnException" in str(excinfo.value) - with pytest.raises(TypeError) as excinfo: + with pytest.raises( + TypeError, + match=wrap_escape( + "expected exception must be a BaseException type, not 'str'" + ), + ): with pytest.raises(("hello", NotAnException)): # type: ignore[arg-type] pass # pragma: no cover - assert "must be a BaseException type, not str" in str(excinfo.value) + + def test_issue_11872(self) -> None: + """Regression test for #11872. + + urllib.error.HTTPError on Python<=3.9 raises KeyError instead of + AttributeError on invalid attribute access. + + https://github.com/python/cpython/issues/98778 + """ + from email.message import Message + from urllib.error import HTTPError + + with pytest.raises(HTTPError, match="Not Found") as exc_info: + raise HTTPError( + code=404, msg="Not Found", fp=io.BytesIO(), hdrs=Message(), url="" + ) + exc_info.value.close() # avoid a resource warning diff --git a/testing/python/raises_group.py b/testing/python/raises_group.py new file mode 100644 index 00000000000..386e127a13d --- /dev/null +++ b/testing/python/raises_group.py @@ -0,0 +1,1348 @@ +from __future__ import annotations + +# several expected multi-line strings contain long lines. We don't wanna break them up +# as that makes it confusing to see where the line breaks are. +# ruff: noqa: E501 +from contextlib import AbstractContextManager +import re +import sys + +from _pytest._code import ExceptionInfo +from _pytest.outcomes import Failed +from _pytest.pytester import Pytester +from _pytest.raises import RaisesExc +from _pytest.raises import RaisesGroup +from _pytest.raises import repr_callable +import pytest + + +if sys.version_info < (3, 11): + from exceptiongroup import BaseExceptionGroup + from exceptiongroup import ExceptionGroup + + +def wrap_escape(s: str) -> str: + return "^" + re.escape(s) + "$" + + +def fails_raises_group(msg: str, add_prefix: bool = True) -> RaisesExc[Failed]: + assert msg[-1] != "\n", ( + "developer error, expected string should not end with newline" + ) + prefix = "Raised exception group did not match: " if add_prefix else "" + return pytest.raises(Failed, match=wrap_escape(prefix + msg)) + + +def test_raises_group() -> None: + with pytest.raises( + TypeError, + match=wrap_escape("expected exception must be a BaseException type, not 'int'"), + ): + RaisesExc(5) # type: ignore[call-overload] + with pytest.raises( + ValueError, + match=wrap_escape("expected exception must be a BaseException type, not 'int'"), + ): + RaisesExc(int) # type: ignore[type-var] + with pytest.raises( + TypeError, + # TODO: bad sentence structure + match=wrap_escape( + "expected exception must be a BaseException type, RaisesExc, or RaisesGroup, not an exception instance (ValueError)", + ), + ): + RaisesGroup(ValueError()) # type: ignore[call-overload] + with RaisesGroup(ValueError): + raise ExceptionGroup("foo", (ValueError(),)) + + with ( + fails_raises_group("`SyntaxError()` is not an instance of `ValueError`"), + RaisesGroup(ValueError), + ): + raise ExceptionGroup("foo", (SyntaxError(),)) + + # multiple exceptions + with RaisesGroup(ValueError, SyntaxError): + raise ExceptionGroup("foo", (ValueError(), SyntaxError())) + + # order doesn't matter + with RaisesGroup(SyntaxError, ValueError): + raise ExceptionGroup("foo", (ValueError(), SyntaxError())) + + # nested exceptions + with RaisesGroup(RaisesGroup(ValueError)): + raise ExceptionGroup("foo", (ExceptionGroup("bar", (ValueError(),)),)) + + with RaisesGroup( + SyntaxError, + RaisesGroup(ValueError), + RaisesGroup(RuntimeError), + ): + raise ExceptionGroup( + "foo", + ( + SyntaxError(), + ExceptionGroup("bar", (ValueError(),)), + ExceptionGroup("", (RuntimeError(),)), + ), + ) + + +def test_incorrect_number_exceptions() -> None: + # We previously gave an error saying the number of exceptions was wrong, + # but we now instead indicate excess/missing exceptions + with ( + fails_raises_group( + "1 matched exception. Unexpected exception(s): [RuntimeError()]" + ), + RaisesGroup(ValueError), + ): + raise ExceptionGroup("", (RuntimeError(), ValueError())) + + # will error if there's missing exceptions + with ( + fails_raises_group( + "1 matched exception. Too few exceptions raised, found no match for: [SyntaxError]" + ), + RaisesGroup(ValueError, SyntaxError), + ): + raise ExceptionGroup("", (ValueError(),)) + + with ( + fails_raises_group( + "\n" + "1 matched exception. \n" + "Too few exceptions raised!\n" + "The following expected exceptions did not find a match:\n" + " ValueError\n" + " It matches `ValueError()` which was paired with `ValueError`" + ), + RaisesGroup(ValueError, ValueError), + ): + raise ExceptionGroup("", (ValueError(),)) + + with ( + fails_raises_group( + "\n" + "1 matched exception. \n" + "Unexpected exception(s)!\n" + "The following raised exceptions did not find a match\n" + " ValueError('b'):\n" + " It matches `ValueError` which was paired with `ValueError('a')`" + ), + RaisesGroup(ValueError), + ): + raise ExceptionGroup("", (ValueError("a"), ValueError("b"))) + + with ( + fails_raises_group( + "\n" + "1 matched exception. \n" + "The following expected exceptions did not find a match:\n" + " ValueError\n" + " It matches `ValueError()` which was paired with `ValueError`\n" + "The following raised exceptions did not find a match\n" + " SyntaxError():\n" + " `SyntaxError()` is not an instance of `ValueError`" + ), + RaisesGroup(ValueError, ValueError), + ): + raise ExceptionGroup("", [ValueError(), SyntaxError()]) + + +def test_flatten_subgroups() -> None: + # loose semantics, as with expect* + with RaisesGroup(ValueError, flatten_subgroups=True): + raise ExceptionGroup("", (ExceptionGroup("", (ValueError(),)),)) + + with RaisesGroup(ValueError, TypeError, flatten_subgroups=True): + raise ExceptionGroup("", (ExceptionGroup("", (ValueError(), TypeError())),)) + with RaisesGroup(ValueError, TypeError, flatten_subgroups=True): + raise ExceptionGroup("", [ExceptionGroup("", [ValueError()]), TypeError()]) + + # mixed loose is possible if you want it to be at least N deep + with RaisesGroup(RaisesGroup(ValueError, flatten_subgroups=True)): + raise ExceptionGroup("", (ExceptionGroup("", (ValueError(),)),)) + with RaisesGroup(RaisesGroup(ValueError, flatten_subgroups=True)): + raise ExceptionGroup( + "", + (ExceptionGroup("", (ExceptionGroup("", (ValueError(),)),)),), + ) + + # but not the other way around + with pytest.raises( + ValueError, + match=r"^You cannot specify a nested structure inside a RaisesGroup with", + ): + RaisesGroup(RaisesGroup(ValueError), flatten_subgroups=True) # type: ignore[call-overload] + + # flatten_subgroups is not sufficient to catch fully unwrapped + with ( + fails_raises_group( + "`ValueError()` is not an exception group, but would match with `allow_unwrapped=True`" + ), + RaisesGroup(ValueError, flatten_subgroups=True), + ): + raise ValueError + with ( + fails_raises_group( + "RaisesGroup(ValueError, flatten_subgroups=True): `ValueError()` is not an exception group, but would match with `allow_unwrapped=True`" + ), + RaisesGroup(RaisesGroup(ValueError, flatten_subgroups=True)), + ): + raise ExceptionGroup("", (ValueError(),)) + + # helpful suggestion if flatten_subgroups would make it pass + with ( + fails_raises_group( + "Raised exception group did not match: \n" + "The following expected exceptions did not find a match:\n" + " ValueError\n" + " TypeError\n" + "The following raised exceptions did not find a match\n" + " ExceptionGroup('', [ValueError(), TypeError()]):\n" + " Unexpected nested `ExceptionGroup()`, expected `ValueError`\n" + " Unexpected nested `ExceptionGroup()`, expected `TypeError`\n" + "Did you mean to use `flatten_subgroups=True`?", + add_prefix=False, + ), + RaisesGroup(ValueError, TypeError), + ): + raise ExceptionGroup("", [ExceptionGroup("", [ValueError(), TypeError()])]) + # but doesn't consider check (otherwise we'd break typing guarantees) + with ( + fails_raises_group( + "Raised exception group did not match: \n" + "The following expected exceptions did not find a match:\n" + " ValueError\n" + " TypeError\n" + "The following raised exceptions did not find a match\n" + " ExceptionGroup('', [ValueError(), TypeError()]):\n" + " Unexpected nested `ExceptionGroup()`, expected `ValueError`\n" + " Unexpected nested `ExceptionGroup()`, expected `TypeError`\n" + "Did you mean to use `flatten_subgroups=True`?", + add_prefix=False, + ), + RaisesGroup( + ValueError, + TypeError, + check=lambda eg: len(eg.exceptions) == 1, + ), + ): + raise ExceptionGroup("", [ExceptionGroup("", [ValueError(), TypeError()])]) + # correct number of exceptions, and flatten_subgroups would make it pass + # This now doesn't print a repr of the caught exception at all, but that can be found in the traceback + with ( + fails_raises_group( + "Raised exception group did not match: Unexpected nested `ExceptionGroup()`, expected `ValueError`\n" + " Did you mean to use `flatten_subgroups=True`?", + add_prefix=False, + ), + RaisesGroup(ValueError), + ): + raise ExceptionGroup("", [ExceptionGroup("", [ValueError()])]) + # correct number of exceptions, but flatten_subgroups wouldn't help, so we don't suggest it + with ( + fails_raises_group( + "Unexpected nested `ExceptionGroup()`, expected `ValueError`" + ), + RaisesGroup(ValueError), + ): + raise ExceptionGroup("", [ExceptionGroup("", [TypeError()])]) + + # flatten_subgroups can be suggested if nested. This will implicitly ask the user to + # do `RaisesGroup(RaisesGroup(ValueError, flatten_subgroups=True))` which is unlikely + # to be what they actually want - but I don't think it's worth trying to special-case + with ( + fails_raises_group( + "RaisesGroup(ValueError): Unexpected nested `ExceptionGroup()`, expected `ValueError`\n" + " Did you mean to use `flatten_subgroups=True`?", + ), + RaisesGroup(RaisesGroup(ValueError)), + ): + raise ExceptionGroup( + "", + [ExceptionGroup("", [ExceptionGroup("", [ValueError()])])], + ) + + # Don't mention "unexpected nested" if expecting an ExceptionGroup. + # Although it should perhaps be an error to specify `RaisesGroup(ExceptionGroup)` in + # favor of doing `RaisesGroup(RaisesGroup(...))`. + with ( + fails_raises_group( + "`BaseExceptionGroup()` is not an instance of `ExceptionGroup`" + ), + RaisesGroup(ExceptionGroup), + ): + raise BaseExceptionGroup("", [BaseExceptionGroup("", [KeyboardInterrupt()])]) + + +def test_catch_unwrapped_exceptions() -> None: + # Catches lone exceptions with strict=False + # just as except* would + with RaisesGroup(ValueError, allow_unwrapped=True): + raise ValueError + + # expecting multiple unwrapped exceptions is not possible + with pytest.raises( + ValueError, + match=r"^You cannot specify multiple exceptions with", + ): + RaisesGroup(SyntaxError, ValueError, allow_unwrapped=True) # type: ignore[call-overload] + # if users want one of several exception types they need to use a RaisesExc + # (which the error message suggests) + with RaisesGroup( + RaisesExc(check=lambda e: isinstance(e, (SyntaxError, ValueError))), + allow_unwrapped=True, + ): + raise ValueError + + # Unwrapped nested `RaisesGroup` is likely a user error, so we raise an error. + with pytest.raises(ValueError, match="has no effect when expecting"): + RaisesGroup(RaisesGroup(ValueError), allow_unwrapped=True) # type: ignore[call-overload] + + # But it *can* be used to check for nesting level +- 1 if they move it to + # the nested RaisesGroup. Users should probably use `RaisesExc`s instead though. + with RaisesGroup(RaisesGroup(ValueError, allow_unwrapped=True)): + raise ExceptionGroup("", [ExceptionGroup("", [ValueError()])]) + with RaisesGroup(RaisesGroup(ValueError, allow_unwrapped=True)): + raise ExceptionGroup("", [ValueError()]) + + # with allow_unwrapped=False (default) it will not be caught + with ( + fails_raises_group( + "`ValueError()` is not an exception group, but would match with `allow_unwrapped=True`" + ), + RaisesGroup(ValueError), + ): + raise ValueError("value error text") + + # allow_unwrapped on its own won't match against nested groups + with ( + fails_raises_group( + "Unexpected nested `ExceptionGroup()`, expected `ValueError`\n" + " Did you mean to use `flatten_subgroups=True`?", + ), + RaisesGroup(ValueError, allow_unwrapped=True), + ): + raise ExceptionGroup("foo", [ExceptionGroup("bar", [ValueError()])]) + + # you need both allow_unwrapped and flatten_subgroups to fully emulate except* + with RaisesGroup(ValueError, allow_unwrapped=True, flatten_subgroups=True): + raise ExceptionGroup("", [ExceptionGroup("", [ValueError()])]) + + # code coverage + with ( + fails_raises_group( + "Raised exception (group) did not match: `TypeError()` is not an instance of `ValueError`", + add_prefix=False, + ), + RaisesGroup(ValueError, allow_unwrapped=True), + ): + raise TypeError("this text doesn't show up in the error message") + with ( + fails_raises_group( + "Raised exception (group) did not match: RaisesExc(ValueError): `TypeError()` is not an instance of `ValueError`", + add_prefix=False, + ), + RaisesGroup(RaisesExc(ValueError), allow_unwrapped=True), + ): + raise TypeError + + # check we don't suggest unwrapping with nested RaisesGroup + with ( + fails_raises_group("`ValueError()` is not an exception group"), + RaisesGroup(RaisesGroup(ValueError)), + ): + raise ValueError + + +def test_match() -> None: + # supports match string + with RaisesGroup(ValueError, match="bar"): + raise ExceptionGroup("bar", (ValueError(),)) + + # now also works with ^$ + with RaisesGroup(ValueError, match="^bar$"): + raise ExceptionGroup("bar", (ValueError(),)) + + # it also includes notes + with RaisesGroup(ValueError, match="my note"): + e = ExceptionGroup("bar", (ValueError(),)) + e.add_note("my note") + raise e + + # and technically you can match it all with ^$ + # but you're probably better off using a RaisesExc at that point + with RaisesGroup(ValueError, match="^bar\nmy note$"): + e = ExceptionGroup("bar", (ValueError(),)) + e.add_note("my note") + raise e + + with ( + fails_raises_group( + "Regex pattern did not match the `ExceptionGroup()`.\n" + " Regex: 'foo'\n" + " Input: 'bar'" + ), + RaisesGroup(ValueError, match="foo"), + ): + raise ExceptionGroup("bar", (ValueError(),)) + + # Suggest a fix for easy pitfall of adding match to the RaisesGroup instead of + # using a RaisesExc. + # This requires a single expected & raised exception, the expected is a type, + # and `isinstance(raised, expected_type)`. + with ( + fails_raises_group( + "Regex pattern did not match the `ExceptionGroup()`.\n" + " Regex: 'foo'\n" + " Input: 'bar'\n" + " but matched the expected `ValueError`.\n" + " You might want `RaisesGroup(RaisesExc(ValueError, match='foo'))`" + ), + RaisesGroup(ValueError, match="foo"), + ): + raise ExceptionGroup("bar", [ValueError("foo")]) + + +def test_check() -> None: + exc = ExceptionGroup("", (ValueError(),)) + + def is_exc(e: ExceptionGroup[ValueError]) -> bool: + return e is exc + + is_exc_repr = repr_callable(is_exc) + with RaisesGroup(ValueError, check=is_exc): + raise exc + + with ( + fails_raises_group( + f"check {is_exc_repr} did not return True on the ExceptionGroup" + ), + RaisesGroup(ValueError, check=is_exc), + ): + raise ExceptionGroup("", (ValueError(),)) + + def is_value_error(e: BaseException) -> bool: + return isinstance(e, ValueError) + + # helpful suggestion if the user thinks the check is for the sub-exception + with ( + fails_raises_group( + f"check {is_value_error} did not return True on the ExceptionGroup, but did return True for the expected ValueError. You might want RaisesGroup(RaisesExc(ValueError, check=<...>))" + ), + RaisesGroup(ValueError, check=is_value_error), + ): + raise ExceptionGroup("", (ValueError(),)) + + +def test_unwrapped_match_check() -> None: + def my_check(e: object) -> bool: # pragma: no cover + return True + + msg = ( + "`allow_unwrapped=True` bypasses the `match` and `check` parameters" + " if the exception is unwrapped. If you intended to match/check the" + " exception you should use a `RaisesExc` object. If you want to match/check" + " the exceptiongroup when the exception *is* wrapped you need to" + " do e.g. `if isinstance(exc.value, ExceptionGroup):" + " assert RaisesGroup(...).matches(exc.value)` afterwards." + ) + with pytest.raises(ValueError, match=re.escape(msg)): + RaisesGroup(ValueError, allow_unwrapped=True, match="foo") # type: ignore[call-overload] + with pytest.raises(ValueError, match=re.escape(msg)): + RaisesGroup(ValueError, allow_unwrapped=True, check=my_check) # type: ignore[call-overload] + + # Users should instead use a RaisesExc + rg = RaisesGroup(RaisesExc(ValueError, match="^foo$"), allow_unwrapped=True) + with rg: + raise ValueError("foo") + with rg: + raise ExceptionGroup("", [ValueError("foo")]) + + # or if they wanted to match/check the group, do a conditional `.matches()` + with RaisesGroup(ValueError, allow_unwrapped=True) as exc: + raise ExceptionGroup("bar", [ValueError("foo")]) + if isinstance(exc.value, ExceptionGroup): # pragma: no branch + assert RaisesGroup(ValueError, match="bar").matches(exc.value) + + +def test_matches() -> None: + rg = RaisesGroup(ValueError) + assert not rg.matches(None) + assert not rg.matches(ValueError()) + assert rg.matches(ExceptionGroup("", (ValueError(),))) + + re = RaisesExc(ValueError) + assert not re.matches(None) + assert re.matches(ValueError()) + + +def test_message() -> None: + def check_message( + message: str, + body: RaisesGroup[BaseException], + ) -> None: + with ( + pytest.raises( + Failed, + match=f"^DID NOT RAISE any exception, expected `{re.escape(message)}`$", + ), + body, + ): + ... + + # basic + check_message("ExceptionGroup(ValueError)", RaisesGroup(ValueError)) + # multiple exceptions + check_message( + "ExceptionGroup(ValueError, ValueError)", + RaisesGroup(ValueError, ValueError), + ) + # nested + check_message( + "ExceptionGroup(ExceptionGroup(ValueError))", + RaisesGroup(RaisesGroup(ValueError)), + ) + + # RaisesExc + check_message( + "ExceptionGroup(RaisesExc(ValueError, match='my_str'))", + RaisesGroup(RaisesExc(ValueError, match="my_str")), + ) + check_message( + "ExceptionGroup(RaisesExc(match='my_str'))", + RaisesGroup(RaisesExc(match="my_str")), + ) + # one-size tuple is printed as not being a tuple + check_message( + "ExceptionGroup(RaisesExc(ValueError))", + RaisesGroup(RaisesExc((ValueError,))), + ) + check_message( + "ExceptionGroup(RaisesExc((ValueError, IndexError)))", + RaisesGroup(RaisesExc((ValueError, IndexError))), + ) + + # BaseExceptionGroup + check_message( + "BaseExceptionGroup(KeyboardInterrupt)", + RaisesGroup(KeyboardInterrupt), + ) + # BaseExceptionGroup with type inside RaisesExc + check_message( + "BaseExceptionGroup(RaisesExc(KeyboardInterrupt))", + RaisesGroup(RaisesExc(KeyboardInterrupt)), + ) + check_message( + "BaseExceptionGroup(RaisesExc((ValueError, KeyboardInterrupt)))", + RaisesGroup(RaisesExc((ValueError, KeyboardInterrupt))), + ) + # Base-ness transfers to parent containers + check_message( + "BaseExceptionGroup(BaseExceptionGroup(KeyboardInterrupt))", + RaisesGroup(RaisesGroup(KeyboardInterrupt)), + ) + # but not to child containers + check_message( + "BaseExceptionGroup(BaseExceptionGroup(KeyboardInterrupt), ExceptionGroup(ValueError))", + RaisesGroup(RaisesGroup(KeyboardInterrupt), RaisesGroup(ValueError)), + ) + + +def test_assert_message() -> None: + # the message does not need to list all parameters to RaisesGroup, nor all exceptions + # in the exception group, as those are both visible in the traceback. + # first fails to match + with ( + fails_raises_group("`TypeError()` is not an instance of `ValueError`"), + RaisesGroup(ValueError), + ): + raise ExceptionGroup("a", [TypeError()]) + with ( + fails_raises_group( + "Raised exception group did not match: \n" + "The following expected exceptions did not find a match:\n" + " RaisesGroup(ValueError)\n" + " RaisesGroup(ValueError, match='a')\n" + "The following raised exceptions did not find a match\n" + " ExceptionGroup('', [RuntimeError()]):\n" + " RaisesGroup(ValueError): `RuntimeError()` is not an instance of `ValueError`\n" + " RaisesGroup(ValueError, match='a'): Regex pattern did not match the `ExceptionGroup()`.\n" + " Regex: 'a'\n" + " Input: ''\n" + " RuntimeError():\n" + " RaisesGroup(ValueError): `RuntimeError()` is not an exception group\n" + " RaisesGroup(ValueError, match='a'): `RuntimeError()` is not an exception group", + add_prefix=False, # to see the full structure + ), + RaisesGroup(RaisesGroup(ValueError), RaisesGroup(ValueError, match="a")), + ): + raise ExceptionGroup( + "", + [ExceptionGroup("", [RuntimeError()]), RuntimeError()], + ) + + with ( + fails_raises_group( + "Raised exception group did not match: \n" + "2 matched exceptions. \n" + "The following expected exceptions did not find a match:\n" + " RaisesGroup(RuntimeError)\n" + " RaisesGroup(ValueError)\n" + "The following raised exceptions did not find a match\n" + " RuntimeError():\n" + " RaisesGroup(RuntimeError): `RuntimeError()` is not an exception group, but would match with `allow_unwrapped=True`\n" + " RaisesGroup(ValueError): `RuntimeError()` is not an exception group\n" + " ValueError('bar'):\n" + " It matches `ValueError` which was paired with `ValueError('foo')`\n" + " RaisesGroup(RuntimeError): `ValueError()` is not an exception group\n" + " RaisesGroup(ValueError): `ValueError()` is not an exception group, but would match with `allow_unwrapped=True`", + add_prefix=False, # to see the full structure + ), + RaisesGroup( + ValueError, + RaisesExc(TypeError), + RaisesGroup(RuntimeError), + RaisesGroup(ValueError), + ), + ): + raise ExceptionGroup( + "a", + [RuntimeError(), TypeError(), ValueError("foo"), ValueError("bar")], + ) + + with ( + fails_raises_group( + "1 matched exception. `AssertionError()` is not an instance of `TypeError`" + ), + RaisesGroup(ValueError, TypeError), + ): + raise ExceptionGroup("a", [ValueError(), AssertionError()]) + + with ( + fails_raises_group( + "RaisesExc(ValueError): `TypeError()` is not an instance of `ValueError`" + ), + RaisesGroup(RaisesExc(ValueError)), + ): + raise ExceptionGroup("a", [TypeError()]) + + # suggest escaping + with ( + fails_raises_group( + # TODO: did not match Exceptiongroup('h(ell)o', ...) ? + "Raised exception group did not match: Regex pattern did not match the `ExceptionGroup()`.\n" + " Regex: 'h(ell)o'\n" + " Input: 'h(ell)o'\n" + " Did you mean to `re.escape()` the regex?", + add_prefix=False, # to see the full structure + ), + RaisesGroup(ValueError, match="h(ell)o"), + ): + raise ExceptionGroup("h(ell)o", [ValueError()]) + with ( + fails_raises_group( + "RaisesExc(match='h(ell)o'): Regex pattern did not match.\n" + " Regex: 'h(ell)o'\n" + " Input: 'h(ell)o'\n" + " Did you mean to `re.escape()` the regex?", + ), + RaisesGroup(RaisesExc(match="h(ell)o")), + ): + raise ExceptionGroup("", [ValueError("h(ell)o")]) + + with ( + fails_raises_group( + "Raised exception group did not match: \n" + "The following expected exceptions did not find a match:\n" + " ValueError\n" + " ValueError\n" + " ValueError\n" + " ValueError\n" + "The following raised exceptions did not find a match\n" + " ExceptionGroup('', [ValueError(), TypeError()]):\n" + " Unexpected nested `ExceptionGroup()`, expected `ValueError`\n" + " Unexpected nested `ExceptionGroup()`, expected `ValueError`\n" + " Unexpected nested `ExceptionGroup()`, expected `ValueError`\n" + " Unexpected nested `ExceptionGroup()`, expected `ValueError`", + add_prefix=False, # to see the full structure + ), + RaisesGroup(ValueError, ValueError, ValueError, ValueError), + ): + raise ExceptionGroup("", [ExceptionGroup("", [ValueError(), TypeError()])]) + + +def test_message_indent() -> None: + with ( + fails_raises_group( + "Raised exception group did not match: \n" + "The following expected exceptions did not find a match:\n" + " RaisesGroup(ValueError, ValueError)\n" + " ValueError\n" + "The following raised exceptions did not find a match\n" + " ExceptionGroup('', [TypeError(), RuntimeError()]):\n" + " RaisesGroup(ValueError, ValueError): \n" + " The following expected exceptions did not find a match:\n" + " ValueError\n" + " ValueError\n" + " The following raised exceptions did not find a match\n" + " TypeError():\n" + " `TypeError()` is not an instance of `ValueError`\n" + " `TypeError()` is not an instance of `ValueError`\n" + " RuntimeError():\n" + " `RuntimeError()` is not an instance of `ValueError`\n" + " `RuntimeError()` is not an instance of `ValueError`\n" + # TODO: this line is not great, should maybe follow the same format as the other and say + # ValueError: Unexpected nested `ExceptionGroup()` (?) + " Unexpected nested `ExceptionGroup()`, expected `ValueError`\n" + " TypeError():\n" + " RaisesGroup(ValueError, ValueError): `TypeError()` is not an exception group\n" + " `TypeError()` is not an instance of `ValueError`", + add_prefix=False, + ), + RaisesGroup( + RaisesGroup(ValueError, ValueError), + ValueError, + ), + ): + raise ExceptionGroup( + "", + [ + ExceptionGroup("", [TypeError(), RuntimeError()]), + TypeError(), + ], + ) + with ( + fails_raises_group( + "Raised exception group did not match: \n" + "RaisesGroup(ValueError, ValueError): \n" + " The following expected exceptions did not find a match:\n" + " ValueError\n" + " ValueError\n" + " The following raised exceptions did not find a match\n" + " TypeError():\n" + " `TypeError()` is not an instance of `ValueError`\n" + " `TypeError()` is not an instance of `ValueError`\n" + " RuntimeError():\n" + " `RuntimeError()` is not an instance of `ValueError`\n" + " `RuntimeError()` is not an instance of `ValueError`", + add_prefix=False, + ), + RaisesGroup( + RaisesGroup(ValueError, ValueError), + ), + ): + raise ExceptionGroup( + "", + [ + ExceptionGroup("", [TypeError(), RuntimeError()]), + ], + ) + + +def test_suggestion_on_nested_and_brief_error() -> None: + # Make sure "Did you mean" suggestion gets indented iff it follows a single-line error + with ( + fails_raises_group( + "\n" + "The following expected exceptions did not find a match:\n" + " RaisesGroup(ValueError)\n" + " ValueError\n" + "The following raised exceptions did not find a match\n" + " ExceptionGroup('', [ExceptionGroup('', [ValueError()])]):\n" + " RaisesGroup(ValueError): Unexpected nested `ExceptionGroup()`, expected `ValueError`\n" + " Did you mean to use `flatten_subgroups=True`?\n" + " Unexpected nested `ExceptionGroup()`, expected `ValueError`", + ), + RaisesGroup(RaisesGroup(ValueError), ValueError), + ): + raise ExceptionGroup( + "", + [ExceptionGroup("", [ExceptionGroup("", [ValueError()])])], + ) + # if indented here it would look like another raised exception + with ( + fails_raises_group( + "\n" + "The following expected exceptions did not find a match:\n" + " RaisesGroup(ValueError, ValueError)\n" + " ValueError\n" + "The following raised exceptions did not find a match\n" + " ExceptionGroup('', [ValueError(), ExceptionGroup('', [ValueError()])]):\n" + " RaisesGroup(ValueError, ValueError): \n" + " 1 matched exception. \n" + " The following expected exceptions did not find a match:\n" + " ValueError\n" + " It matches `ValueError()` which was paired with `ValueError`\n" + " The following raised exceptions did not find a match\n" + " ExceptionGroup('', [ValueError()]):\n" + " Unexpected nested `ExceptionGroup()`, expected `ValueError`\n" + " Did you mean to use `flatten_subgroups=True`?\n" + " Unexpected nested `ExceptionGroup()`, expected `ValueError`" + ), + RaisesGroup(RaisesGroup(ValueError, ValueError), ValueError), + ): + raise ExceptionGroup( + "", + [ExceptionGroup("", [ValueError(), ExceptionGroup("", [ValueError()])])], + ) + + # re.escape always comes after single-line errors + with ( + fails_raises_group( + "\n" + "The following expected exceptions did not find a match:\n" + " RaisesGroup(Exception, match='^hello')\n" + " ValueError\n" + "The following raised exceptions did not find a match\n" + " ExceptionGroup('^hello', [Exception()]):\n" + " RaisesGroup(Exception, match='^hello'): Regex pattern did not match the `ExceptionGroup()`.\n" + " Regex: '^hello'\n" + " Input: '^hello'\n" + " Did you mean to `re.escape()` the regex?\n" + " Unexpected nested `ExceptionGroup()`, expected `ValueError`" + ), + RaisesGroup(RaisesGroup(Exception, match="^hello"), ValueError), + ): + raise ExceptionGroup("", [ExceptionGroup("^hello", [Exception()])]) + + +def test_assert_message_nested() -> None: + # we only get one instance of aaaaaaaaaa... and bbbbbb..., but we do get multiple instances of ccccc... and dddddd.. + # but I think this now only prints the full repr when that is necessary to disambiguate exceptions + with ( + fails_raises_group( + "Raised exception group did not match: \n" + "The following expected exceptions did not find a match:\n" + " RaisesGroup(ValueError)\n" + " RaisesGroup(RaisesGroup(ValueError))\n" + " RaisesGroup(RaisesExc(TypeError, match='foo'))\n" + " RaisesGroup(TypeError, ValueError)\n" + "The following raised exceptions did not find a match\n" + " TypeError('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'):\n" + " RaisesGroup(ValueError): `TypeError()` is not an exception group\n" + " RaisesGroup(RaisesGroup(ValueError)): `TypeError()` is not an exception group\n" + " RaisesGroup(RaisesExc(TypeError, match='foo')): `TypeError()` is not an exception group\n" + " RaisesGroup(TypeError, ValueError): `TypeError()` is not an exception group\n" + " ExceptionGroup('Exceptions from Trio nursery', [TypeError('bbbbbbbbbbbbbbbbbbbbbbbbbbbbbb')]):\n" + " RaisesGroup(ValueError): `TypeError()` is not an instance of `ValueError`\n" + " RaisesGroup(RaisesGroup(ValueError)): RaisesGroup(ValueError): `TypeError()` is not an exception group\n" + " RaisesGroup(RaisesExc(TypeError, match='foo')): RaisesExc(TypeError, match='foo'): Regex pattern did not match.\n" + " Regex: 'foo'\n" + " Input: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'\n" + " RaisesGroup(TypeError, ValueError): 1 matched exception. Too few exceptions raised, found no match for: [ValueError]\n" + " ExceptionGroup('Exceptions from Trio nursery', [TypeError('cccccccccccccccccccccccccccccc'), TypeError('dddddddddddddddddddddddddddddd')]):\n" + " RaisesGroup(ValueError): \n" + " The following expected exceptions did not find a match:\n" + " ValueError\n" + " The following raised exceptions did not find a match\n" + " TypeError('cccccccccccccccccccccccccccccc'):\n" + " `TypeError()` is not an instance of `ValueError`\n" + " TypeError('dddddddddddddddddddddddddddddd'):\n" + " `TypeError()` is not an instance of `ValueError`\n" + " RaisesGroup(RaisesGroup(ValueError)): \n" + " The following expected exceptions did not find a match:\n" + " RaisesGroup(ValueError)\n" + " The following raised exceptions did not find a match\n" + " TypeError('cccccccccccccccccccccccccccccc'):\n" + " RaisesGroup(ValueError): `TypeError()` is not an exception group\n" + " TypeError('dddddddddddddddddddddddddddddd'):\n" + " RaisesGroup(ValueError): `TypeError()` is not an exception group\n" + " RaisesGroup(RaisesExc(TypeError, match='foo')): \n" + " The following expected exceptions did not find a match:\n" + " RaisesExc(TypeError, match='foo')\n" + " The following raised exceptions did not find a match\n" + " TypeError('cccccccccccccccccccccccccccccc'):\n" + " RaisesExc(TypeError, match='foo'): Regex pattern did not match.\n" + " Regex: 'foo'\n" + " Input: 'cccccccccccccccccccccccccccccc'\n" + " TypeError('dddddddddddddddddddddddddddddd'):\n" + " RaisesExc(TypeError, match='foo'): Regex pattern did not match.\n" + " Regex: 'foo'\n" + " Input: 'dddddddddddddddddddddddddddddd'\n" + " RaisesGroup(TypeError, ValueError): \n" + " 1 matched exception. \n" + " The following expected exceptions did not find a match:\n" + " ValueError\n" + " The following raised exceptions did not find a match\n" + " TypeError('dddddddddddddddddddddddddddddd'):\n" + " It matches `TypeError` which was paired with `TypeError('cccccccccccccccccccccccccccccc')`\n" + " `TypeError()` is not an instance of `ValueError`", + add_prefix=False, # to see the full structure + ), + RaisesGroup( + RaisesGroup(ValueError), + RaisesGroup(RaisesGroup(ValueError)), + RaisesGroup(RaisesExc(TypeError, match="foo")), + RaisesGroup(TypeError, ValueError), + ), + ): + raise ExceptionGroup( + "", + [ + TypeError("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), + ExceptionGroup( + "Exceptions from Trio nursery", + [TypeError("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")], + ), + ExceptionGroup( + "Exceptions from Trio nursery", + [ + TypeError("cccccccccccccccccccccccccccccc"), + TypeError("dddddddddddddddddddddddddddddd"), + ], + ), + ], + ) + + +# CI always runs with hypothesis, but this is not a critical test - it overlaps +# with several others +@pytest.mark.skipif( + "hypothesis" in sys.modules, + reason="hypothesis may have monkeypatched _check_repr", +) +def test_check_no_patched_repr() -> None: # pragma: no cover + # We make `_check_repr` monkeypatchable to avoid this very ugly and verbose + # repr. The other tests that use `check` make use of `_check_repr` so they'll + # continue passing in case it is patched - but we have this one test that + # demonstrates just how nasty it gets otherwise. + match_str = ( + r"^Raised exception group did not match: \n" + r"The following expected exceptions did not find a match:\n" + r" RaisesExc\(check=. at .*>\)\n" + r" TypeError\n" + r"The following raised exceptions did not find a match\n" + r" ValueError\('foo'\):\n" + r" RaisesExc\(check=. at .*>\): check did not return True\n" + r" `ValueError\(\)` is not an instance of `TypeError`\n" + r" ValueError\('bar'\):\n" + r" RaisesExc\(check=. at .*>\): check did not return True\n" + r" `ValueError\(\)` is not an instance of `TypeError`$" + ) + with ( + pytest.raises(Failed, match=match_str), + RaisesGroup(RaisesExc(check=lambda x: False), TypeError), + ): + raise ExceptionGroup("", [ValueError("foo"), ValueError("bar")]) + + +def test_misordering_example() -> None: + with ( + fails_raises_group( + "\n" + "3 matched exceptions. \n" + "The following expected exceptions did not find a match:\n" + " RaisesExc(ValueError, match='foo')\n" + " It matches `ValueError('foo')` which was paired with `ValueError`\n" + " It matches `ValueError('foo')` which was paired with `ValueError`\n" + " It matches `ValueError('foo')` which was paired with `ValueError`\n" + "The following raised exceptions did not find a match\n" + " ValueError('bar'):\n" + " It matches `ValueError` which was paired with `ValueError('foo')`\n" + " It matches `ValueError` which was paired with `ValueError('foo')`\n" + " It matches `ValueError` which was paired with `ValueError('foo')`\n" + " RaisesExc(ValueError, match='foo'): Regex pattern did not match.\n" + " Regex: 'foo'\n" + " Input: 'bar'\n" + "There exist a possible match when attempting an exhaustive check, but RaisesGroup uses a greedy algorithm. Please make your expected exceptions more stringent with `RaisesExc` etc so the greedy algorithm can function." + ), + RaisesGroup( + ValueError, ValueError, ValueError, RaisesExc(ValueError, match="foo") + ), + ): + raise ExceptionGroup( + "", + [ + ValueError("foo"), + ValueError("foo"), + ValueError("foo"), + ValueError("bar"), + ], + ) + + +def test_brief_error_on_one_fail() -> None: + """If only one raised and one expected fail to match up, we print a full table iff + the raised exception would match one of the expected that previously got matched""" + # no also-matched + with ( + fails_raises_group( + "1 matched exception. `TypeError()` is not an instance of `RuntimeError`" + ), + RaisesGroup(ValueError, RuntimeError), + ): + raise ExceptionGroup("", [ValueError(), TypeError()]) + + # raised would match an expected + with ( + fails_raises_group( + "\n" + "1 matched exception. \n" + "The following expected exceptions did not find a match:\n" + " RuntimeError\n" + "The following raised exceptions did not find a match\n" + " TypeError():\n" + " It matches `Exception` which was paired with `ValueError()`\n" + " `TypeError()` is not an instance of `RuntimeError`" + ), + RaisesGroup(Exception, RuntimeError), + ): + raise ExceptionGroup("", [ValueError(), TypeError()]) + + # expected would match a raised + with ( + fails_raises_group( + "\n" + "1 matched exception. \n" + "The following expected exceptions did not find a match:\n" + " ValueError\n" + " It matches `ValueError()` which was paired with `ValueError`\n" + "The following raised exceptions did not find a match\n" + " TypeError():\n" + " `TypeError()` is not an instance of `ValueError`" + ), + RaisesGroup(ValueError, ValueError), + ): + raise ExceptionGroup("", [ValueError(), TypeError()]) + + +def test_identity_oopsies() -> None: + # it's both possible to have several instances of the same exception in the same group + # and to expect multiple of the same type + # this previously messed up the logic + + with ( + fails_raises_group( + "3 matched exceptions. `RuntimeError()` is not an instance of `TypeError`" + ), + RaisesGroup(ValueError, ValueError, ValueError, TypeError), + ): + raise ExceptionGroup( + "", [ValueError(), ValueError(), ValueError(), RuntimeError()] + ) + + e = ValueError("foo") + m = RaisesExc(match="bar") + with ( + fails_raises_group( + "\n" + "The following expected exceptions did not find a match:\n" + " RaisesExc(match='bar')\n" + " RaisesExc(match='bar')\n" + " RaisesExc(match='bar')\n" + "The following raised exceptions did not find a match\n" + " ValueError('foo'):\n" + " RaisesExc(match='bar'): Regex pattern did not match.\n" + " Regex: 'bar'\n" + " Input: 'foo'\n" + " RaisesExc(match='bar'): Regex pattern did not match.\n" + " Regex: 'bar'\n" + " Input: 'foo'\n" + " RaisesExc(match='bar'): Regex pattern did not match.\n" + " Regex: 'bar'\n" + " Input: 'foo'\n" + " ValueError('foo'):\n" + " RaisesExc(match='bar'): Regex pattern did not match.\n" + " Regex: 'bar'\n" + " Input: 'foo'\n" + " RaisesExc(match='bar'): Regex pattern did not match.\n" + " Regex: 'bar'\n" + " Input: 'foo'\n" + " RaisesExc(match='bar'): Regex pattern did not match.\n" + " Regex: 'bar'\n" + " Input: 'foo'\n" + " ValueError('foo'):\n" + " RaisesExc(match='bar'): Regex pattern did not match.\n" + " Regex: 'bar'\n" + " Input: 'foo'\n" + " RaisesExc(match='bar'): Regex pattern did not match.\n" + " Regex: 'bar'\n" + " Input: 'foo'\n" + " RaisesExc(match='bar'): Regex pattern did not match.\n" + " Regex: 'bar'\n" + " Input: 'foo'" + ), + RaisesGroup(m, m, m), + ): + raise ExceptionGroup("", [e, e, e]) + + +def test_raisesexc() -> None: + with pytest.raises( + ValueError, + match=r"^You must specify at least one parameter to match on.$", + ): + RaisesExc() # type: ignore[call-overload] + with pytest.raises( + ValueError, + match=wrap_escape( + "expected exception must be a BaseException type, not 'object'" + ), + ): + RaisesExc(object) # type: ignore[type-var] + + with RaisesGroup(RaisesExc(ValueError)): + raise ExceptionGroup("", (ValueError(),)) + with ( + fails_raises_group( + "RaisesExc(TypeError): `ValueError()` is not an instance of `TypeError`" + ), + RaisesGroup(RaisesExc(TypeError)), + ): + raise ExceptionGroup("", (ValueError(),)) + + with RaisesExc(ValueError): + raise ValueError + + # FIXME: leaving this one formatted differently for now to not change + # tests in python/raises.py + with pytest.raises(Failed, match=wrap_escape("DID NOT RAISE ")): + with RaisesExc(ValueError): + ... + + with pytest.raises(Failed, match=wrap_escape("DID NOT RAISE any exception")): + with RaisesExc(match="foo"): + ... + + with pytest.raises( + # FIXME: do we want repr(type) or type.__name__ ? + Failed, + match=wrap_escape( + "DID NOT RAISE any of (, )" + ), + ): + with RaisesExc((ValueError, TypeError)): + ... + + # currently RaisesGroup says "Raised exception did not match" but RaisesExc doesn't... + with pytest.raises( + AssertionError, + match=wrap_escape("Regex pattern did not match.\n Regex: 'foo'\n Input: 'bar'"), + ): + with RaisesExc(TypeError, match="foo"): + raise TypeError("bar") + + +def test_raisesexc_match() -> None: + with RaisesGroup(RaisesExc(ValueError, match="foo")): + raise ExceptionGroup("", (ValueError("foo"),)) + with ( + fails_raises_group( + "RaisesExc(ValueError, match='foo'): Regex pattern did not match.\n" + " Regex: 'foo'\n" + " Input: 'bar'" + ), + RaisesGroup(RaisesExc(ValueError, match="foo")), + ): + raise ExceptionGroup("", (ValueError("bar"),)) + + # Can be used without specifying the type + with RaisesGroup(RaisesExc(match="foo")): + raise ExceptionGroup("", (ValueError("foo"),)) + with ( + fails_raises_group( + "RaisesExc(match='foo'): Regex pattern did not match.\n" + " Regex: 'foo'\n" + " Input: 'bar'" + ), + RaisesGroup(RaisesExc(match="foo")), + ): + raise ExceptionGroup("", (ValueError("bar"),)) + + # check ^$ + with RaisesGroup(RaisesExc(ValueError, match="^bar$")): + raise ExceptionGroup("", [ValueError("bar")]) + with ( + fails_raises_group( + "\nRaisesExc(ValueError, match='^bar$'): \n - barr\n ? -\n + bar" + ), + RaisesGroup(RaisesExc(ValueError, match="^bar$")), + ): + raise ExceptionGroup("", [ValueError("barr")]) + + +def test_RaisesExc_check() -> None: + def check_oserror_and_errno_is_5(e: BaseException) -> bool: + return isinstance(e, OSError) and e.errno == 5 + + with RaisesGroup(RaisesExc(check=check_oserror_and_errno_is_5)): + raise ExceptionGroup("", (OSError(5, ""),)) + + # specifying exception_type narrows the parameter type to the callable + def check_errno_is_5(e: OSError) -> bool: + return e.errno == 5 + + with RaisesGroup(RaisesExc(OSError, check=check_errno_is_5)): + raise ExceptionGroup("", (OSError(5, ""),)) + + # avoid printing overly verbose repr multiple times + with ( + fails_raises_group( + f"RaisesExc(OSError, check={check_errno_is_5!r}): check did not return True" + ), + RaisesGroup(RaisesExc(OSError, check=check_errno_is_5)), + ): + raise ExceptionGroup("", (OSError(6, ""),)) + + # in nested cases you still get it multiple times though + # to address this you'd need logic in RaisesExc.__repr__ and RaisesGroup.__repr__ + with ( + fails_raises_group( + f"RaisesGroup(RaisesExc(OSError, check={check_errno_is_5!r})): RaisesExc(OSError, check={check_errno_is_5!r}): check did not return True" + ), + RaisesGroup(RaisesGroup(RaisesExc(OSError, check=check_errno_is_5))), + ): + raise ExceptionGroup("", [ExceptionGroup("", [OSError(6, "")])]) + + +def test_raisesexc_tostring() -> None: + assert str(RaisesExc(ValueError)) == "RaisesExc(ValueError)" + assert str(RaisesExc(match="[a-z]")) == "RaisesExc(match='[a-z]')" + pattern_no_flags = re.compile(r"noflag", 0) + assert str(RaisesExc(match=pattern_no_flags)) == "RaisesExc(match='noflag')" + pattern_flags = re.compile(r"noflag", re.IGNORECASE) + assert str(RaisesExc(match=pattern_flags)) == f"RaisesExc(match={pattern_flags!r})" + assert ( + str(RaisesExc(ValueError, match="re", check=bool)) + == f"RaisesExc(ValueError, match='re', check={bool!r})" + ) + + +def test_raisesgroup_tostring() -> None: + def check_str_and_repr(s: str) -> None: + evaled = eval(s) + assert s == str(evaled) == repr(evaled) + + check_str_and_repr("RaisesGroup(ValueError)") + check_str_and_repr("RaisesGroup(RaisesGroup(ValueError))") + check_str_and_repr("RaisesGroup(RaisesExc(ValueError))") + check_str_and_repr("RaisesGroup(ValueError, allow_unwrapped=True)") + check_str_and_repr("RaisesGroup(ValueError, match='aoeu')") + + assert ( + str(RaisesGroup(ValueError, match="[a-z]", check=bool)) + == f"RaisesGroup(ValueError, match='[a-z]', check={bool!r})" + ) + + +def test_assert_matches() -> None: + e = ValueError() + + # it's easy to do this + assert RaisesExc(ValueError).matches(e) + + # but you don't get a helpful error + with pytest.raises(AssertionError, match=r"assert False\n \+ where False = .*"): + assert RaisesExc(TypeError).matches(e) + + with pytest.raises( + AssertionError, + match=wrap_escape( + "`ValueError()` is not an instance of `TypeError`\n" + "assert False\n" + " + where False = matches(ValueError())\n" + " + where matches = RaisesExc(TypeError).matches" + ), + ): + # you'd need to do this arcane incantation + assert (m := RaisesExc(TypeError)).matches(e), m.fail_reason + + # but even if we add assert_matches, will people remember to use it? + # other than writing a linter rule, I don't think we can catch `assert RaisesExc(...).matches` + # ... no wait pytest catches other asserts ... so we probably can?? + + +# https://github.com/pytest-dev/pytest/issues/12504 +def test_xfail_raisesgroup(pytester: Pytester) -> None: + pytester.makepyfile( + """ + import sys + import pytest + if sys.version_info < (3, 11): + from exceptiongroup import ExceptionGroup + @pytest.mark.xfail(raises=pytest.RaisesGroup(ValueError)) + def test_foo() -> None: + raise ExceptionGroup("foo", [ValueError()]) + """ + ) + result = pytester.runpytest() + result.assert_outcomes(xfailed=1) + + +def test_xfail_RaisesExc(pytester: Pytester) -> None: + pytester.makepyfile( + """ + import pytest + @pytest.mark.xfail(raises=pytest.RaisesExc(ValueError)) + def test_foo() -> None: + raise ValueError + """ + ) + result = pytester.runpytest() + result.assert_outcomes(xfailed=1) + + +@pytest.mark.parametrize( + "wrap_in_group,handler", + [ + (False, pytest.raises(ValueError)), + (True, RaisesGroup(ValueError)), + ], +) +def test_parametrizing_conditional_raisesgroup( + wrap_in_group: bool, handler: AbstractContextManager[ExceptionInfo[BaseException]] +) -> None: + with handler: + if wrap_in_group: + raise ExceptionGroup("", [ValueError()]) + raise ValueError() + + +def test_annotated_group() -> None: + # repr depends on if exceptiongroup backport is being used or not + t = repr(ExceptionGroup[ValueError]) + msg = "Only `ExceptionGroup[Exception]` or `BaseExceptionGroup[BaseException]` are accepted as generic types but got `{}`. As `raises` will catch all instances of the specified group regardless of the generic argument specific nested exceptions has to be checked with `RaisesGroup`." + + fail_msg = wrap_escape(msg.format(t)) + with pytest.raises(ValueError, match=fail_msg): + RaisesGroup(ExceptionGroup[ValueError]) + with pytest.raises(ValueError, match=fail_msg): + RaisesExc(ExceptionGroup[ValueError]) + with pytest.raises( + ValueError, + match=wrap_escape(msg.format(repr(BaseExceptionGroup[KeyboardInterrupt]))), + ): + with RaisesExc(BaseExceptionGroup[KeyboardInterrupt]): + raise BaseExceptionGroup("", [KeyboardInterrupt()]) + + with RaisesGroup(ExceptionGroup[Exception]): + raise ExceptionGroup( + "", [ExceptionGroup("", [ValueError(), ValueError(), ValueError()])] + ) + with RaisesExc(BaseExceptionGroup[BaseException]): + raise BaseExceptionGroup("", [KeyboardInterrupt()]) + + +def test_tuples() -> None: + # raises has historically supported one of several exceptions being raised + with pytest.raises((ValueError, IndexError)): + raise ValueError + # so now RaisesExc also does + with RaisesExc((ValueError, IndexError)): + raise IndexError + # but RaisesGroup currently doesn't. There's an argument it shouldn't because + # it can be confusing - RaisesGroup((ValueError, TypeError)) looks a lot like + # RaisesGroup(ValueError, TypeError), and the former might be interpreted as the latter. + with pytest.raises( + TypeError, + match=wrap_escape( + "expected exception must be a BaseException type, RaisesExc, or RaisesGroup, not 'tuple'.\n" + "RaisesGroup does not support tuples of exception types when expecting one of " + "several possible exception types like RaisesExc.\n" + "If you meant to expect a group with multiple exceptions, list them as separate arguments." + ), + ): + RaisesGroup((ValueError, IndexError)) # type: ignore[call-overload] diff --git a/testing/python/show_fixtures_per_test.py b/testing/python/show_fixtures_per_test.py index f756dca41c7..c860b61e21b 100644 --- a/testing/python/show_fixtures_per_test.py +++ b/testing/python/show_fixtures_per_test.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from _pytest.pytester import Pytester diff --git a/testing/test_argcomplete.py b/testing/test_argcomplete.py index 8c10e230b0c..5d1513b6206 100644 --- a/testing/test_argcomplete.py +++ b/testing/test_argcomplete.py @@ -1,9 +1,13 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + +from pathlib import Path import subprocess import sys -from pathlib import Path -import pytest from _pytest.monkeypatch import MonkeyPatch +import pytest + # Test for _argcomplete but not specific for any application. diff --git a/testing/test_assertion.py b/testing/test_assertion.py index 7119b3b5aa6..2c2830eb929 100644 --- a/testing/test_assertion.py +++ b/testing/test_assertion.py @@ -1,32 +1,81 @@ -import collections +# mypy: allow-untyped-defs +from __future__ import annotations + +from collections.abc import MutableSequence import sys import textwrap from typing import Any -from typing import List -from typing import MutableSequence -from typing import Optional +from typing import NamedTuple import attr -import _pytest.assertion as plugin -import pytest from _pytest import outcomes +import _pytest.assertion as plugin from _pytest.assertion import truncate from _pytest.assertion import util +from _pytest.config import Config as _Config from _pytest.monkeypatch import MonkeyPatch from _pytest.pytester import Pytester +import pytest + +def mock_config(verbose: int = 0, assertion_override: int | None = None): + class TerminalWriter: + def _highlight(self, source, lexer="python"): + return source -def mock_config(verbose=0): class Config: - def getoption(self, name): - if name == "verbose": + def get_terminal_writer(self): + return TerminalWriter() + + def get_verbosity(self, verbosity_type: str | None = None) -> int: + if verbosity_type is None: return verbose - raise KeyError("Not mocked out: %s" % name) + if verbosity_type == _Config.VERBOSITY_ASSERTIONS: + if assertion_override is not None: + return assertion_override + return verbose + + raise KeyError(f"Not mocked out: {verbosity_type}") return Config() +class TestMockConfig: + SOME_VERBOSITY_LEVEL = 3 + SOME_OTHER_VERBOSITY_LEVEL = 10 + + def test_verbose_exposes_value(self): + config = mock_config(verbose=TestMockConfig.SOME_VERBOSITY_LEVEL) + + assert config.get_verbosity() == TestMockConfig.SOME_VERBOSITY_LEVEL + + def test_get_assertion_override_not_set_verbose_value(self): + config = mock_config(verbose=TestMockConfig.SOME_VERBOSITY_LEVEL) + + assert ( + config.get_verbosity(_Config.VERBOSITY_ASSERTIONS) + == TestMockConfig.SOME_VERBOSITY_LEVEL + ) + + def test_get_assertion_override_set_custom_value(self): + config = mock_config( + verbose=TestMockConfig.SOME_VERBOSITY_LEVEL, + assertion_override=TestMockConfig.SOME_OTHER_VERBOSITY_LEVEL, + ) + + assert ( + config.get_verbosity(_Config.VERBOSITY_ASSERTIONS) + == TestMockConfig.SOME_OTHER_VERBOSITY_LEVEL + ) + + def test_get_unsupported_type_error(self): + config = mock_config(verbose=TestMockConfig.SOME_VERBOSITY_LEVEL) + + with pytest.raises(KeyError): + config.get_verbosity("--- NOT A VERBOSITY LEVEL ---") + + class TestImportHookInstallation: @pytest.mark.parametrize("initial_conftest", [True, False]) @pytest.mark.parametrize("mode", ["plain", "rewrite"]) @@ -52,7 +101,7 @@ def test(check_first): """, } pytester.makepyfile(**contents) - result = pytester.runpytest_subprocess("--assert=%s" % mode) + result = pytester.runpytest_subprocess(f"--assert={mode}") if mode == "plain": expected = "E AssertionError" elif mode == "rewrite": @@ -114,7 +163,7 @@ def test_foo(check_first): """, } pytester.makepyfile(**contents) - result = pytester.runpytest_subprocess("--assert=%s" % mode) + result = pytester.runpytest_subprocess(f"--assert={mode}") if mode == "plain": expected = "E AssertionError" elif mode == "rewrite": @@ -133,11 +182,9 @@ def test_pytest_plugins_rewrite_module_names( """ plugins = '"ham"' if mode == "str" else '["ham"]' contents = { - "conftest.py": """ + "conftest.py": f""" pytest_plugins = {plugins} - """.format( - plugins=plugins - ), + """, "ham.py": """ import pytest """, @@ -171,12 +218,38 @@ def test_foo(pytestconfig): assert result.ret == 0 @pytest.mark.parametrize("mode", ["plain", "rewrite"]) + @pytest.mark.parametrize("disable_plugin_autoload", ["env_var", "cli", ""]) + @pytest.mark.parametrize("explicit_specify", ["env_var", "cli", ""]) def test_installed_plugin_rewrite( - self, pytester: Pytester, mode, monkeypatch + self, + pytester: Pytester, + mode: str, + monkeypatch: pytest.MonkeyPatch, + disable_plugin_autoload: str, + explicit_specify: str, ) -> None: - monkeypatch.delenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD", raising=False) + args = ["mainwrapper.py", "-s", f"--assert={mode}"] + if disable_plugin_autoload == "env_var": + monkeypatch.setenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD", "1") + elif disable_plugin_autoload == "cli": + monkeypatch.delenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD", raising=False) + args.append("--disable-plugin-autoload") + else: + assert disable_plugin_autoload == "" + monkeypatch.delenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD", raising=False) + + name = "spamplugin" + + if explicit_specify == "env_var": + monkeypatch.setenv("PYTEST_PLUGINS", name) + elif explicit_specify == "cli": + args.append("-p") + args.append(name) + else: + assert explicit_specify == "" + # Make sure the hook is installed early enough so that plugins - # installed via setuptools are rewritten. + # installed via distribution package are rewritten. pytester.mkdir("hampkg") contents = { "hampkg/__init__.py": """\ @@ -199,11 +272,11 @@ def check(values, value): return check """, "mainwrapper.py": """\ + import importlib.metadata import pytest - from _pytest.compat import importlib_metadata class DummyEntryPoint(object): - name = 'spam' + name = 'spamplugin' module_name = 'spam.py' group = 'pytest11' @@ -220,7 +293,7 @@ class DummyDistInfo(object): def distributions(): return (DummyDistInfo(),) - importlib_metadata.distributions = distributions + importlib.metadata.distributions = distributions pytest.main() """, "test_foo.py": """\ @@ -228,20 +301,29 @@ def test(check_first): check_first([10, 30], 30) def test2(check_first2): - check_first([10, 30], 30) + check_first2([10, 30], 30) """, } pytester.makepyfile(**contents) - result = pytester.run( - sys.executable, "mainwrapper.py", "-s", "--assert=%s" % mode - ) + result = pytester.run(sys.executable, *args) if mode == "plain": expected = "E AssertionError" elif mode == "rewrite": expected = "*assert 10 == 30*" else: assert 0 - result.stdout.fnmatch_lines([expected]) + + if not disable_plugin_autoload or explicit_specify: + result.assert_outcomes(failed=2) + result.stdout.fnmatch_lines([expected, expected]) + else: + result.assert_outcomes(errors=2) + result.stdout.fnmatch_lines( + [ + "E fixture 'check_first' not found", + "E fixture 'check_first2' not found", + ] + ) def test_rewrite_ast(self, pytester: Pytester) -> None: pytester.mkdir("pkg") @@ -322,12 +404,12 @@ def test_check(list): result.stdout.fnmatch_lines(["*test_hello*FAIL*", "*test_check*PASS*"]) -def callop(op: str, left: Any, right: Any, verbose: int = 0) -> Optional[List[str]]: +def callop(op: str, left: Any, right: Any, verbose: int = 0) -> list[str] | None: config = mock_config(verbose=verbose) return plugin.pytest_assertrepr_compare(config, op, left, right) -def callequal(left: Any, right: Any, verbose: int = 0) -> Optional[List[str]]: +def callequal(left: Any, right: Any, verbose: int = 0) -> list[str] | None: return callop("==", left, right, verbose) @@ -344,6 +426,7 @@ def test_summary(self) -> None: def test_text_diff(self) -> None: assert callequal("spam", "eggs") == [ "'spam' == 'eggs'", + "", "- eggs", "+ spam", ] @@ -351,7 +434,7 @@ def test_text_diff(self) -> None: def test_text_skipping(self) -> None: lines = callequal("a" * 50 + "spam", "a" * 50 + "eggs") assert lines is not None - assert "Skipping" in lines[1] + assert "Skipping" in lines[2] for line in lines: assert "a" * 50 not in line @@ -375,6 +458,7 @@ def test_bytes_diff_normal(self) -> None: assert diff == [ "b'spam' == b'eggs'", + "", "At index 0 diff: b's' != b'e'", "Use -v to get more diff", ] @@ -384,7 +468,9 @@ def test_bytes_diff_verbose(self) -> None: diff = callequal(b"spam", b"eggs", verbose=1) assert diff == [ "b'spam' == b'eggs'", + "", "At index 0 diff: b's' != b'e'", + "", "Full diff:", "- b'eggs'", "+ b'spam'", @@ -403,11 +489,14 @@ def test_list(self) -> None: [0, 2], """ Full diff: - - [0, 2] + [ + 0, + - 2, ? ^ - + [0, 1] + + 1, ? ^ - """, + ] + """, id="lists", ), pytest.param( @@ -415,10 +504,12 @@ def test_list(self) -> None: {0: 2}, """ Full diff: - - {0: 2} - ? ^ - + {0: 1} - ? ^ + { + - 0: 2, + ? ^ + + 0: 1, + ? ^ + } """, id="dicts", ), @@ -427,10 +518,13 @@ def test_list(self) -> None: {0, 2}, """ Full diff: - - {0, 2} + { + 0, + - 2, ? ^ - + {0, 1} + + 1, ? ^ + } """, id="sets", ), @@ -453,6 +547,7 @@ def test_iterable_quiet(self) -> None: expl = callequal([1, 2], [10, 2], verbose=-1) assert expl == [ "[1, 2] == [10, 2]", + "", "At index 0 diff: 1 != 10", "Use -v to get more diff", ] @@ -491,26 +586,30 @@ def test_list_wrap_for_multiple_lines(self) -> None: diff = callequal(l1, l2, verbose=True) assert diff == [ "['a', 'b', 'c'] == ['a', 'b', 'c...dddddddddddd']", + "", "Right contains one more item: '" + long_d + "'", + "", "Full diff:", " [", - " 'a',", - " 'b',", - " 'c',", - "- '" + long_d + "',", + " 'a',", + " 'b',", + " 'c',", + "- '" + long_d + "',", " ]", ] diff = callequal(l2, l1, verbose=True) assert diff == [ "['a', 'b', 'c...dddddddddddd'] == ['a', 'b', 'c']", + "", "Left contains one more item: '" + long_d + "'", + "", "Full diff:", " [", - " 'a',", - " 'b',", - " 'c',", - "+ '" + long_d + "',", + " 'a',", + " 'b',", + " 'c',", + "+ '" + long_d + "',", " ]", ] @@ -523,36 +622,40 @@ def test_list_wrap_for_width_rewrap_same_length(self) -> None: diff = callequal(l1, l2, verbose=True) assert diff == [ "['aaaaaaaaaaa...cccccccccccc'] == ['bbbbbbbbbbb...aaaaaaaaaaaa']", + "", "At index 0 diff: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' != 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'", + "", "Full diff:", " [", - "+ 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',", - " 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',", - " 'cccccccccccccccccccccccccccccc',", - "- 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',", + "+ 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',", + " 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',", + " 'cccccccccccccccccccccccccccccc',", + "- 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',", " ]", ] def test_list_dont_wrap_strings(self) -> None: long_a = "a" * 10 - l1 = ["a"] + [long_a for _ in range(0, 7)] + l1 = ["a"] + [long_a for _ in range(7)] l2 = ["should not get wrapped"] diff = callequal(l1, l2, verbose=True) assert diff == [ "['a', 'aaaaaa...aaaaaaa', ...] == ['should not get wrapped']", + "", "At index 0 diff: 'a' != 'should not get wrapped'", "Left contains 7 more items, first extra item: 'aaaaaaaaaa'", + "", "Full diff:", " [", - "- 'should not get wrapped',", - "+ 'a',", - "+ 'aaaaaaaaaa',", - "+ 'aaaaaaaaaa',", - "+ 'aaaaaaaaaa',", - "+ 'aaaaaaaaaa',", - "+ 'aaaaaaaaaa',", - "+ 'aaaaaaaaaa',", - "+ 'aaaaaaaaaa',", + "- 'should not get wrapped',", + "+ 'a',", + "+ 'aaaaaaaaaa',", + "+ 'aaaaaaaaaa',", + "+ 'aaaaaaaaaa',", + "+ 'aaaaaaaaaa',", + "+ 'aaaaaaaaaa',", + "+ 'aaaaaaaaaa',", + "+ 'aaaaaaaaaa',", " ]", ] @@ -563,31 +666,45 @@ def test_dict_wrap(self) -> None: diff = callequal(d1, d2, verbose=True) assert diff == [ "{'common': 1,...1, 'env2': 2}} == {'common': 1,...: {'env1': 1}}", + "", "Omitting 1 identical items, use -vv to show", "Differing items:", "{'env': {'env1': 1, 'env2': 2}} != {'env': {'env1': 1}}", + "", "Full diff:", - "- {'common': 1, 'env': {'env1': 1}}", - "+ {'common': 1, 'env': {'env1': 1, 'env2': 2}}", - "? +++++++++++", + " {", + " 'common': 1,", + " 'env': {", + " 'env1': 1,", + "+ 'env2': 2,", + " },", + " }", ] long_a = "a" * 80 - sub = {"long_a": long_a, "sub1": {"long_a": "substring that gets wrapped " * 2}} + sub = {"long_a": long_a, "sub1": {"long_a": "substring that gets wrapped " * 3}} d1 = {"env": {"sub": sub}} d2 = {"env": {"sub": sub}, "new": 1} diff = callequal(d1, d2, verbose=True) assert diff == [ "{'env': {'sub... wrapped '}}}} == {'env': {'sub...}}}, 'new': 1}", + "", "Omitting 1 identical items, use -vv to show", "Right contains 1 more item:", "{'new': 1}", + "", "Full diff:", " {", - " 'env': {'sub': {'long_a': '" + long_a + "',", - " 'sub1': {'long_a': 'substring that gets wrapped substring '", - " 'that gets wrapped '}}},", - "- 'new': 1,", + " 'env': {", + " 'sub': {", + f" 'long_a': '{long_a}',", + " 'sub1': {", + " 'long_a': 'substring that gets wrapped substring that gets wrapped '", + " 'substring that gets wrapped ',", + " },", + " },", + " },", + "- 'new': 1,", " }", ] @@ -599,7 +716,7 @@ def test_dict(self) -> None: def test_dict_omitting(self) -> None: lines = callequal({"a": 0, "b": 1}, {"a": 1, "b": 1}) assert lines is not None - assert lines[1].startswith("Omitting 1 identical item") + assert lines[2].startswith("Omitting 1 identical item") assert "Common items" not in lines for line in lines[1:]: assert "b" not in line @@ -608,60 +725,109 @@ def test_dict_omitting_with_verbosity_1(self) -> None: """Ensure differing items are visible for verbosity=1 (#1512).""" lines = callequal({"a": 0, "b": 1}, {"a": 1, "b": 1}, verbose=1) assert lines is not None - assert lines[1].startswith("Omitting 1 identical item") - assert lines[2].startswith("Differing items") - assert lines[3] == "{'a': 0} != {'a': 1}" + assert lines[1] == "" + assert lines[2].startswith("Omitting 1 identical item") + assert lines[3].startswith("Differing items") + assert lines[4] == "{'a': 0} != {'a': 1}" assert "Common items" not in lines def test_dict_omitting_with_verbosity_2(self) -> None: lines = callequal({"a": 0, "b": 1}, {"a": 1, "b": 1}, verbose=2) assert lines is not None - assert lines[1].startswith("Common items:") - assert "Omitting" not in lines[1] - assert lines[2] == "{'b': 1}" + assert lines[2].startswith("Common items:") + assert "Omitting" not in lines[2] + assert lines[3] == "{'b': 1}" def test_dict_different_items(self) -> None: lines = callequal({"a": 0}, {"b": 1, "c": 2}, verbose=2) assert lines == [ "{'a': 0} == {'b': 1, 'c': 2}", + "", "Left contains 1 more item:", "{'a': 0}", "Right contains 2 more items:", "{'b': 1, 'c': 2}", + "", "Full diff:", - "- {'b': 1, 'c': 2}", - "+ {'a': 0}", + " {", + "- 'b': 1,", + "? ^ ^", + "+ 'a': 0,", + "? ^ ^", + "- 'c': 2,", + " }", ] lines = callequal({"b": 1, "c": 2}, {"a": 0}, verbose=2) assert lines == [ "{'b': 1, 'c': 2} == {'a': 0}", + "", "Left contains 2 more items:", "{'b': 1, 'c': 2}", "Right contains 1 more item:", "{'a': 0}", + "", "Full diff:", - "- {'a': 0}", - "+ {'b': 1, 'c': 2}", + " {", + "- 'a': 0,", + "? ^ ^", + "+ 'b': 1,", + "? ^ ^", + "+ 'c': 2,", + " }", ] def test_sequence_different_items(self) -> None: lines = callequal((1, 2), (3, 4, 5), verbose=2) assert lines == [ "(1, 2) == (3, 4, 5)", + "", "At index 0 diff: 1 != 3", "Right contains one more item: 5", + "", "Full diff:", - "- (3, 4, 5)", - "+ (1, 2)", + " (", + "- 3,", + "? ^", + "+ 1,", + "? ^", + "- 4,", + "? ^", + "+ 2,", + "? ^", + "- 5,", + " )", ] lines = callequal((1, 2, 3), (4,), verbose=2) assert lines == [ "(1, 2, 3) == (4,)", + "", "At index 0 diff: 1 != 4", "Left contains 2 more items, first extra item: 2", + "", + "Full diff:", + " (", + "- 4,", + "? ^", + "+ 1,", + "? ^", + "+ 2,", + "+ 3,", + " )", + ] + lines = callequal((1, 2, 3), (1, 20, 3), verbose=2) + assert lines == [ + "(1, 2, 3) == (1, 20, 3)", + "", + "At index 1 diff: 2 != 20", + "", "Full diff:", - "- (4,)", - "+ (1, 2, 3)", + " (", + " 1,", + "- 20,", + "? -", + "+ 2,", + " 3,", + " )", ] def test_set(self) -> None: @@ -692,7 +858,7 @@ def __setitem__(self, item, value): def __delitem__(self, item): pass - def insert(self, item, index): + def insert(self, index, value): pass expl = callequal(TestSequence([0, 1]), list([0, 2])) @@ -719,11 +885,9 @@ def __repr__(self): assert expl is not None assert expl[0].startswith("{} == <[ValueError") assert "raised in repr" in expl[0] - assert expl[1:] == [ + assert expl[2:] == [ "(pytest_assertion plugin: representation of details failed:" - " {}:{}: ValueError: 42.".format( - __file__, A.__repr__.__code__.co_firstlineno + 1 - ), + f" {__file__}:{A.__repr__.__code__.co_firstlineno + 1}: ValueError: 42.", " Probably an object has a faulty __repr__.)", ] @@ -745,6 +909,7 @@ def test_repr_no_exc(self) -> None: def test_unicode(self) -> None: assert callequal("£€", "£") == [ "'£€' == '£'", + "", "- £", "+ £€", ] @@ -760,7 +925,7 @@ def __repr__(self): return "\xff" expl = callequal(A(), "1") - assert expl == ["ÿ == '1'", "- 1"] + assert expl == ["ÿ == '1'", "", "- 1"] def test_format_nonascii_explanation(self) -> None: assert util.format_explanation("λ") @@ -783,15 +948,17 @@ def test_nfc_nfd_same_string(self) -> None: expl = callequal(left, right) assert expl == [ r"'hyv\xe4' == 'hyva\u0308'", - f"- {str(right)}", - f"+ {str(left)}", + "", + f"- {right!s}", + f"+ {left!s}", ] expl = callequal(left, right, verbose=2) assert expl == [ r"'hyv\xe4' == 'hyva\u0308'", - f"- {str(right)}", - f"+ {str(left)}", + "", + f"- {right!s}", + f"+ {left!s}", ] @@ -1017,7 +1184,7 @@ class SimpleDataObjectTwo: def test_attrs_with_auto_detect_and_custom_eq(self) -> None: @attr.s( auto_detect=True - ) # attr.s doesn’t ignore a custom eq if auto_detect=True + ) # attr.s doesn't ignore a custom eq if auto_detect=True class SimpleDataObject: field_a = attr.ib() @@ -1047,7 +1214,9 @@ def __eq__(self, other): # pragma: no cover class TestAssert_reprcompare_namedtuple: def test_namedtuple(self) -> None: - NT = collections.namedtuple("NT", ["a", "b"]) + class NT(NamedTuple): + a: Any + b: Any left = NT(1, "b") right = NT(1, "c") @@ -1068,8 +1237,13 @@ def test_namedtuple(self) -> None: ] def test_comparing_two_different_namedtuple(self) -> None: - NT1 = collections.namedtuple("NT1", ["a", "b"]) - NT2 = collections.namedtuple("NT2", ["a", "b"]) + class NT1(NamedTuple): + a: Any + b: Any + + class NT2(NamedTuple): + a: Any + b: Any left = NT1(1, "b") right = NT2(2, "b") @@ -1078,6 +1252,7 @@ def test_comparing_two_different_namedtuple(self) -> None: # Because the types are different, uses the generic sequence matcher. assert lines == [ "NT1(a=1, b='b') == NT2(a=2, b='b')", + "", "At index 0 diff: 1 != 2", "Use -v to get more diff", ] @@ -1176,7 +1351,7 @@ class TestTruncateExplanation: LINES_IN_TRUNCATION_MSG = 2 def test_doesnt_truncate_when_input_is_empty_list(self) -> None: - expl: List[str] = [] + expl: list[str] = [] result = truncate._truncate_explanation(expl, max_lines=8, max_chars=100) assert result == expl @@ -1262,20 +1437,18 @@ def test_truncates_at_1_line_when_first_line_is_GT_max_chars(self) -> None: def test_full_output_truncated(self, monkeypatch, pytester: Pytester) -> None: """Test against full runpytest() output.""" - line_count = 7 line_len = 100 - expected_truncated_lines = 1 + expected_truncated_lines = 2 pytester.makepyfile( - r""" + rf""" def test_many_lines(): - a = list([str(i)[0] * %d for i in range(%d)]) + a = list([str(i)[0] * {line_len} for i in range({line_count})]) b = a[::2] a = '\n'.join(map(str, a)) b = '\n'.join(map(str, b)) assert a == b """ - % (line_len, line_count) ) monkeypatch.delenv("CI", raising=False) @@ -1285,8 +1458,7 @@ def test_many_lines(): [ "*+ 1*", "*+ 3*", - "*+ 5*", - "*truncated (%d line hidden)*use*-vv*" % expected_truncated_lines, + f"*truncated ({expected_truncated_lines} lines hidden)*use*-vv*", ] ) @@ -1297,6 +1469,66 @@ def test_many_lines(): result = pytester.runpytest() result.stdout.fnmatch_lines(["* 6*"]) + @pytest.mark.parametrize( + ["truncation_lines", "truncation_chars", "expected_lines_hidden"], + ( + (3, None, 3), + (4, None, 0), + (0, None, 0), + (None, 8, 6), + (None, 9, 0), + (None, 0, 0), + (0, 0, 0), + (0, 1000, 0), + (1000, 0, 0), + ), + ) + def test_truncation_with_ini( + self, + monkeypatch, + pytester: Pytester, + truncation_lines: int | None, + truncation_chars: int | None, + expected_lines_hidden: int, + ) -> None: + pytester.makepyfile( + """\ + string_a = "123456789\\n23456789\\n3" + string_b = "123456789\\n23456789\\n4" + + def test(): + assert string_a == string_b + """ + ) + + # This test produces 6 lines of diff output or 79 characters + # So the effect should be when threshold is < 4 lines (considering 2 additional lines for explanation) + # Or < 9 characters (considering 70 additional characters for explanation) + + monkeypatch.delenv("CI", raising=False) + + ini = "[pytest]\n" + if truncation_lines is not None: + ini += f"truncation_limit_lines = {truncation_lines}\n" + if truncation_chars is not None: + ini += f"truncation_limit_chars = {truncation_chars}\n" + pytester.makeini(ini) + + result = pytester.runpytest() + + if expected_lines_hidden != 0: + result.stdout.fnmatch_lines( + [f"*truncated ({expected_lines_hidden} lines hidden)*"] + ) + else: + result.stdout.no_fnmatch_line("*truncated*") + result.stdout.fnmatch_lines( + [ + "*- 4*", + "*+ 3*", + ] + ) + def test_python25_compile_issue257(pytester: Pytester) -> None: pytester.makepyfile( @@ -1329,6 +1561,7 @@ def test_rewritten(): def test_reprcompare_notin() -> None: assert callop("not in", "foo", "aaafoobbb") == [ "'foo' not in 'aaafoobbb'", + "", "'foo' is contained here:", " aaafoobbb", "? +++", @@ -1338,6 +1571,7 @@ def test_reprcompare_notin() -> None: def test_reprcompare_whitespaces() -> None: assert callequal("\r\n", "\n") == [ r"'\r\n' == '\n'", + "", r"Strings contain only whitespace, escaping them using repr()", r"- '\n'", r"+ '\r\n'", @@ -1345,48 +1579,80 @@ def test_reprcompare_whitespaces() -> None: ] -def test_pytest_assertrepr_compare_integration(pytester: Pytester) -> None: - pytester.makepyfile( +class TestSetAssertions: + @pytest.mark.parametrize("op", [">=", ">", "<=", "<", "=="]) + def test_set_extra_item(self, op, pytester: Pytester) -> None: + pytester.makepyfile( + f""" + def test_hello(): + x = set("hello x") + y = set("hello y") + assert x {op} y """ - def test_hello(): - x = set(range(100)) - y = x.copy() - y.remove(50) - assert x == y - """ - ) - result = pytester.runpytest() - result.stdout.fnmatch_lines( - [ - "*def test_hello():*", - "*assert x == y*", - "*E*Extra items*left*", - "*E*50*", - "*= 1 failed in*", - ] - ) + ) + + result = pytester.runpytest() + result.stdout.fnmatch_lines( + [ + "*def test_hello():*", + f"*assert x {op} y*", + ] + ) + if op in [">=", ">", "=="]: + result.stdout.fnmatch_lines( + [ + "*E*Extra items in the right set:*", + "*E*'y'", + ] + ) + if op in ["<=", "<", "=="]: + result.stdout.fnmatch_lines( + [ + "*E*Extra items in the left set:*", + "*E*'x'", + ] + ) + + @pytest.mark.parametrize("op", [">", "<", "!="]) + def test_set_proper_superset_equal(self, pytester: Pytester, op) -> None: + pytester.makepyfile( + f""" + def test_hello(): + x = set([1, 2, 3]) + y = x.copy() + assert x {op} y + """ + ) + result = pytester.runpytest() + result.stdout.fnmatch_lines( + [ + "*def test_hello():*", + f"*assert x {op} y*", + "*E*Both sets are equal*", + ] + ) -def test_sequence_comparison_uses_repr(pytester: Pytester) -> None: - pytester.makepyfile( + def test_pytest_assertrepr_compare_integration(self, pytester: Pytester) -> None: + pytester.makepyfile( + """ + def test_hello(): + x = set(range(100)) + y = x.copy() + y.remove(50) + assert x == y """ - def test_hello(): - x = set("hello x") - y = set("hello y") - assert x == y - """ - ) - result = pytester.runpytest() - result.stdout.fnmatch_lines( - [ - "*def test_hello():*", - "*assert x == y*", - "*E*Extra items*left*", - "*E*'x'*", - "*E*Extra items*right*", - "*E*'y'*", - ] - ) + ) + result = pytester.runpytest() + result.stdout.fnmatch_lines( + [ + "*def test_hello():*", + "*assert x == y*", + "*E*Extra items*left*", + "*E*50*", + "*= 1 failed in*", + ] + ) def test_assertrepr_loaded_per_dir(pytester: Pytester) -> None: @@ -1405,12 +1671,12 @@ def test_assertrepr_loaded_per_dir(pytester: Pytester) -> None: result = pytester.runpytest() result.stdout.fnmatch_lines( [ - "*def test_base():*", - "*E*assert 1 == 2*", "*def test_a():*", "*E*assert summary a*", "*def test_b():*", "*E*assert summary b*", + "*def test_base():*", + "*E*assert 1 == 2*", ] ) @@ -1575,9 +1841,9 @@ def test_something(): ) result = pytester.runpytest("--collect-only") result.stdout.fnmatch_lines( - """ - - """ + [ + " ", + ] ) @@ -1752,3 +2018,167 @@ def test_reprcompare_verbose_long() -> None: "{'v0': 0, 'v1': 1, 'v2': 12, 'v3': 3, 'v4': 4, 'v5': 5, " "'v6': 6, 'v7': 7, 'v8': 8, 'v9': 9, 'v10': 10}" ) + + +@pytest.mark.parametrize("enable_colors", [True, False]) +@pytest.mark.parametrize( + ("test_code", "expected_lines"), + ( + ( + """ + def test(): + assert [0, 1] == [0, 2] + """, + [ + "{bold}{red}E At index 1 diff: {reset}{number}1{hl-reset}{endline} != {reset}{number}2*", + "{bold}{red}E {light-red}- 2,{hl-reset}{endline}{reset}", + "{bold}{red}E {light-green}+ 1,{hl-reset}{endline}{reset}", + ], + ), + ( + """ + def test(): + assert {f"number-is-{i}": i for i in range(1, 6)} == { + f"number-is-{i}": i for i in range(5) + } + """, + [ + "{bold}{red}E Common items:{reset}", + "{bold}{red}E {reset}{{{str}'{hl-reset}{str}number-is-1{hl-reset}{str}'{hl-reset}: {number}1*", + "{bold}{red}E Left contains 1 more item:{reset}", + "{bold}{red}E {reset}{{{str}'{hl-reset}{str}number-is-5{hl-reset}{str}'{hl-reset}: {number}5*", + "{bold}{red}E Right contains 1 more item:{reset}", + "{bold}{red}E {reset}{{{str}'{hl-reset}{str}number-is-0{hl-reset}{str}'{hl-reset}: {number}0*", + "{bold}{red}E {reset}{light-gray} {hl-reset} {{{endline}{reset}", + "{bold}{red}E {light-gray} {hl-reset} 'number-is-1': 1,{endline}{reset}", + "{bold}{red}E {light-green}+ 'number-is-5': 5,{hl-reset}{endline}{reset}", + ], + ), + ( + """ + def test(): + assert "abcd" == "abce" + """, + [ + "{bold}{red}E {reset}{light-red}- abce{hl-reset}{endline}{reset}", + "{bold}{red}E {light-green}+ abcd{hl-reset}{endline}{reset}", + ], + ), + ), +) +def test_comparisons_handle_colors( + pytester: Pytester, color_mapping, enable_colors, test_code, expected_lines +) -> None: + p = pytester.makepyfile(test_code) + result = pytester.runpytest( + f"--color={'yes' if enable_colors else 'no'}", "-vv", str(p) + ) + formatter = ( + color_mapping.format_for_fnmatch + if enable_colors + else color_mapping.strip_colors + ) + + result.stdout.fnmatch_lines(formatter(expected_lines), consecutive=False) + + +def test_fine_grained_assertion_verbosity(pytester: Pytester): + long_text = "Lorem ipsum dolor sit amet " * 10 + p = pytester.makepyfile( + f""" + def test_ok(): + pass + + + def test_words_fail(): + fruits1 = ["banana", "apple", "grapes", "melon", "kiwi"] + fruits2 = ["banana", "apple", "orange", "melon", "kiwi"] + assert fruits1 == fruits2 + + + def test_numbers_fail(): + number_to_text1 = {{str(x): x for x in range(5)}} + number_to_text2 = {{str(x * 10): x * 10 for x in range(5)}} + assert number_to_text1 == number_to_text2 + + + def test_long_text_fail(): + long_text = "{long_text}" + assert "hello world" in long_text + """ + ) + pytester.makeini( + """ + [pytest] + verbosity_assertions = 2 + """ + ) + result = pytester.runpytest(p) + + result.stdout.fnmatch_lines( + [ + f"{p.name} .FFF [100%]", + "E At index 2 diff: 'grapes' != 'orange'", + "E Full diff:", + "E [", + "E 'banana',", + "E 'apple',", + "E - 'orange',", + "E ? ^ ^^", + "E + 'grapes',", + "E ? ^ ^ +", + "E 'melon',", + "E 'kiwi',", + "E ]", + "E Full diff:", + "E {", + "E '0': 0,", + "E - '10': 10,", + "E ? - -", + "E + '1': 1,", + "E - '20': 20,", + "E ? - -", + "E + '2': 2,", + "E - '30': 30,", + "E ? - -", + "E + '3': 3,", + "E - '40': 40,", + "E ? - -", + "E + '4': 4,", + "E }", + f"E AssertionError: assert 'hello world' in '{long_text}'", + ] + ) + + +def test_full_output_vvv(pytester: Pytester) -> None: + pytester.makepyfile( + r""" + def crash_helper(m): + assert 1 == 2 + def test_vvv(): + crash_helper(500 * "a") + """ + ) + result = pytester.runpytest("") + # without -vvv, the passed args are truncated + expected_non_vvv_arg_line = "m = 'aaaaaaaaaaaaaaa*..aaaaaaaaaaaa*" + result.stdout.fnmatch_lines( + [ + expected_non_vvv_arg_line, + "test_full_output_vvv.py:2: AssertionError", + ], + ) + # double check that the untruncated part is not in the output + expected_vvv_arg_line = "m = '{}'".format(500 * "a") + result.stdout.no_fnmatch_line(expected_vvv_arg_line) + + # but with "-vvv" the args are not truncated + result = pytester.runpytest("-vvv") + result.stdout.fnmatch_lines( + [ + expected_vvv_arg_line, + "test_full_output_vvv.py:2: AssertionError", + ] + ) + result.stdout.no_fnmatch_line(expected_non_vvv_arg_line) diff --git a/testing/test_assertrewrite.py b/testing/test_assertrewrite.py index 778f843e6cf..e2e448fe5e6 100644 --- a/testing/test_assertrewrite.py +++ b/testing/test_assertrewrite.py @@ -1,31 +1,33 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + import ast +from collections.abc import Generator +from collections.abc import Mapping +import dis import errno +from functools import partial import glob import importlib +import inspect import marshal import os +from pathlib import Path import py_compile +import re import stat import sys import textwrap -import zipfile -from functools import partial -from pathlib import Path from typing import cast -from typing import Dict -from typing import Generator -from typing import List -from typing import Mapping -from typing import Optional -from typing import Set from unittest import mock +import zipfile import _pytest._code -import pytest from _pytest._io.saferepr import DEFAULT_REPR_MAX_SIZE from _pytest.assertion import util from _pytest.assertion.rewrite import _get_assertion_exprs from _pytest.assertion.rewrite import _get_maxsize_for_saferepr +from _pytest.assertion.rewrite import _saferepr from _pytest.assertion.rewrite import AssertionRewritingHook from _pytest.assertion.rewrite import get_cache_dir from _pytest.assertion.rewrite import PYC_TAIL @@ -35,6 +37,7 @@ from _pytest.config import ExitCode from _pytest.pathlib import make_numbered_dir from _pytest.pytester import Pytester +import pytest def rewrite(src: str) -> ast.Module: @@ -44,13 +47,13 @@ def rewrite(src: str) -> ast.Module: def getmsg( - f, extra_ns: Optional[Mapping[str, object]] = None, *, must_pass: bool = False -) -> Optional[str]: + f, extra_ns: Mapping[str, object] | None = None, *, must_pass: bool = False +) -> str | None: """Rewrite the assertions in f, run it, and get the failure message.""" src = "\n".join(_pytest._code.Code.from_function(f).source().lines) mod = rewrite(src) code = compile(mod, "", "exec") - ns: Dict[str, object] = {} + ns: dict[str, object] = {} if extra_ns is not None: ns.update(extra_ns) exec(code, ns) @@ -129,11 +132,212 @@ def test_location_is_set(self) -> None: if isinstance(node, ast.Import): continue for n in [node, *ast.iter_child_nodes(node)]: - assert n.lineno == 3 - assert n.col_offset == 0 - if sys.version_info >= (3, 8): - assert n.end_lineno == 6 - assert n.end_col_offset == 3 + assert isinstance(n, (ast.stmt, ast.expr)) + for location in [ + (n.lineno, n.col_offset), + (n.end_lineno, n.end_col_offset), + ]: + assert (3, 0) <= location <= (6, 3) + + def test_positions_are_preserved(self) -> None: + """Ensure AST positions are preserved during rewriting (#12818).""" + + def preserved(code: str) -> None: + s = textwrap.dedent(code) + locations = [] + + def loc(msg: str | None = None) -> None: + frame = inspect.currentframe() + assert frame + frame = frame.f_back + assert frame + frame = frame.f_back + assert frame + + offset = frame.f_lasti + + instructions = {i.offset: i for i in dis.get_instructions(frame.f_code)} + + # skip CACHE instructions + while offset not in instructions and offset >= 0: + offset -= 1 + + instruction = instructions[offset] + if sys.version_info >= (3, 11): + position = instruction.positions + else: + position = instruction.starts_line + + locations.append((msg, instruction.opname, position)) + + globals = {"loc": loc} + + m = rewrite(s) + mod = compile(m, "", "exec") + exec(mod, globals, globals) + transformed_locations = locations + locations = [] + + mod = compile(s, "", "exec") + exec(mod, globals, globals) + original_locations = locations + + assert len(original_locations) > 0 + assert original_locations == transformed_locations + + preserved(""" + def f(): + loc() + return 8 + + assert f() in [8] + assert (f() + in + [8]) + """) + + preserved(""" + class T: + def __init__(self): + loc("init") + def __getitem__(self,index): + loc("getitem") + return index + + assert T()[5] == 5 + assert (T + () + [5] + == + 5) + """) + + for name, op in [ + ("pos", "+"), + ("neg", "-"), + ("invert", "~"), + ]: + preserved(f""" + class T: + def __{name}__(self): + loc("{name}") + return "{name}" + + assert {op}T() == "{name}" + assert ({op} + T + () + == + "{name}") + """) + + for name, op in [ + ("add", "+"), + ("sub", "-"), + ("mul", "*"), + ("truediv", "/"), + ("floordiv", "//"), + ("mod", "%"), + ("pow", "**"), + ("lshift", "<<"), + ("rshift", ">>"), + ("or", "|"), + ("xor", "^"), + ("and", "&"), + ("matmul", "@"), + ]: + preserved(f""" + class T: + def __{name}__(self,other): + loc("{name}") + return other + + def __r{name}__(self,other): + loc("r{name}") + return other + + assert T() {op} 2 == 2 + assert 2 {op} T() == 2 + + assert (T + () + {op} + 2 + == + 2) + + assert (2 + {op} + T + () + == + 2) + """) + + for name, op in [ + ("eq", "=="), + ("ne", "!="), + ("lt", "<"), + ("le", "<="), + ("gt", ">"), + ("ge", ">="), + ]: + preserved(f""" + class T: + def __{name}__(self,other): + loc() + return True + + assert T() {op} 5 + assert (T + () + {op} + 5) + """) + + for name, op in [ + ("eq", "=="), + ("ne", "!="), + ("lt", ">"), + ("le", ">="), + ("gt", "<"), + ("ge", "<="), + ("contains", "in"), + ]: + preserved(f""" + class T: + def __{name}__(self,other): + loc() + return True + + assert 5 {op} T() + assert (5 + {op} + T + ()) + """) + + preserved(""" + def func(value): + loc("func") + return value + + class T: + def __iter__(self): + loc("iter") + return iter([5]) + + assert func(*T()) == 5 + """) + + preserved(""" + class T: + def __getattr__(self,name): + loc() + return name + + assert T().attr == "attr" + """) def test_dont_rewrite(self) -> None: s = """'PYTEST_DONT_REWRITE'\nassert 14""" @@ -200,7 +404,7 @@ def f2() -> None: assert getmsg(f2) == "assert False" def f3() -> None: - assert a_global # type: ignore[name-defined] # noqa + assert a_global # type: ignore[name-defined] # noqa: F821 assert getmsg(f3, {"a_global": False}) == "assert False" @@ -308,9 +512,7 @@ def test_foo(): ) result = pytester.runpytest() assert result.ret == 1 - result.stdout.fnmatch_lines( - ["*AssertionError*%s*" % repr((1, 2)), "*assert 1 == 2*"] - ) + result.stdout.fnmatch_lines([f"*AssertionError*{(1, 2)!r}*", "*assert 1 == 2*"]) def test_assertion_message_expr(self, pytester: Pytester) -> None: pytester.makepyfile( @@ -342,6 +544,34 @@ def test_assertion_messages_bytes(self, pytester: Pytester) -> None: assert result.ret == 1 result.stdout.fnmatch_lines(["*AssertionError: b'ohai!'", "*assert False"]) + def test_assertion_message_verbosity(self, pytester: Pytester) -> None: + """ + Obey verbosity levels when printing the "message" part of assertions, when they are + non-strings (#6682). + """ + pytester.makepyfile( + """ + class LongRepr: + + def __repr__(self): + return "A" * 500 + + def test_assertion_verbosity(): + assert False, LongRepr() + """ + ) + # Normal verbosity: assertion message gets abbreviated. + result = pytester.runpytest() + assert result.ret == 1 + result.stdout.re_match_lines( + [r".*AssertionError: A+\.\.\.A+$", ".*assert False"] + ) + + # High-verbosity: do not abbreviate the assertion message. + result = pytester.runpytest("-vv") + assert result.ret == 1 + result.stdout.re_match_lines([r".*AssertionError: A+$", ".*assert False"]) + def test_boolop(self) -> None: def f1() -> None: f = g = False @@ -429,7 +659,7 @@ def f1() -> None: def f2() -> None: x = 1 - assert x == 1 or x == 2 + assert x == 1 or x == 2 # noqa: PLR1714 getmsg(f2, must_pass=True) @@ -686,6 +916,25 @@ def myany(x) -> bool: assert msg is not None assert " < 0" in msg + def test_assert_handling_raise_in__iter__(self, pytester: Pytester) -> None: + pytester.makepyfile( + """\ + class A: + def __iter__(self): + raise ValueError() + + def __eq__(self, o: object) -> bool: + return self is o + + def __repr__(self): + return "" + + assert A() == A() + """ + ) + result = pytester.runpytest() + result.stdout.fnmatch_lines(["*E*assert == "]) + def test_formatchar(self) -> None: def f() -> None: assert "%test" == "test" # type: ignore[comparison-overlap] @@ -726,6 +975,23 @@ def __repr__(self): assert "UnicodeDecodeError" not in msg assert "UnicodeEncodeError" not in msg + def test_assert_fixture(self, pytester: Pytester) -> None: + pytester.makepyfile( + """\ + import pytest + @pytest.fixture + def fixt(): + return 42 + + def test_something(): # missing "fixt" argument + assert fixt == 42 + """ + ) + result = pytester.runpytest() + result.stdout.fnmatch_lines( + ["*assert )> == 42*"] + ) + class TestRewriteOnImport: def test_pycache_is_a_file(self, pytester: Pytester) -> None: @@ -762,18 +1028,13 @@ def test_zipfile(self, pytester: Pytester) -> None: f.close() z.chmod(256) pytester.makepyfile( - """ + f""" import sys - sys.path.append(%r) + sys.path.append({z_fn!r}) import test_gum.test_lizard""" - % (z_fn,) ) assert pytester.runpytest().ret == ExitCode.NO_TESTS_COLLECTED - @pytest.mark.skipif( - sys.version_info < (3, 9), - reason="importlib.resources.files was introduced in 3.9", - ) def test_load_resource_via_files_with_rewrite(self, pytester: Pytester) -> None: example = pytester.path.joinpath("demo") / "example" init = pytester.path.joinpath("demo") / "__init__.py" @@ -877,7 +1138,11 @@ def test_foo(): ) @pytest.mark.skipif('"__pypy__" in sys.modules') - def test_pyc_vs_pyo(self, pytester: Pytester, monkeypatch) -> None: + def test_pyc_vs_pyo( + self, + pytester: Pytester, + monkeypatch: pytest.MonkeyPatch, + ) -> None: pytester.makepyfile( """ import pytest @@ -886,14 +1151,14 @@ def test_optimized(): assert test_optimized.__doc__ is None""" ) p = make_numbered_dir(root=Path(pytester.path), prefix="runpytest-") - tmp = "--basetemp=%s" % p - monkeypatch.setenv("PYTHONOPTIMIZE", "2") - monkeypatch.delenv("PYTHONDONTWRITEBYTECODE", raising=False) - monkeypatch.delenv("PYTHONPYCACHEPREFIX", raising=False) - assert pytester.runpytest_subprocess(tmp).ret == 0 - tagged = "test_pyc_vs_pyo." + PYTEST_TAG - assert tagged + ".pyo" in os.listdir("__pycache__") - monkeypatch.undo() + tmp = f"--basetemp={p}" + with monkeypatch.context() as mp: + mp.setenv("PYTHONOPTIMIZE", "2") + mp.delenv("PYTHONDONTWRITEBYTECODE", raising=False) + mp.delenv("PYTHONPYCACHEPREFIX", raising=False) + assert pytester.runpytest_subprocess(tmp).ret == 0 + tagged = "test_pyc_vs_pyo." + PYTEST_TAG + assert tagged + ".pyo" in os.listdir("__pycache__") monkeypatch.delenv("PYTHONDONTWRITEBYTECODE", raising=False) monkeypatch.delenv("PYTHONPYCACHEPREFIX", raising=False) assert pytester.runpytest_subprocess(tmp).ret == 1 @@ -932,7 +1197,23 @@ def test_rewrite_warning(self, pytester: Pytester) -> None: ) # needs to be a subprocess because pytester explicitly disables this warning result = pytester.runpytest_subprocess() - result.stdout.fnmatch_lines(["*Module already imported*: _pytest"]) + result.stdout.fnmatch_lines(["*Module already imported*; _pytest"]) + + def test_rewrite_warning_ignore(self, pytester: Pytester) -> None: + pytester.makeconftest( + """ + import pytest + pytest.register_assert_rewrite("_pytest") + """ + ) + # needs to be a subprocess because pytester explicitly disables this warning + result = pytester.runpytest_subprocess( + "-W", + "ignore:Module already imported so cannot be rewritten; _pytest:pytest.PytestAssertRewriteWarning", + ) + # Previously, when the message pattern used to contain an extra `:`, an error was raised. + assert not result.stderr.str().strip() + result.stdout.no_fnmatch_line("*Module already imported*; _pytest") def test_rewrite_module_imported_from_conftest(self, pytester: Pytester) -> None: pytester.makeconftest( @@ -1014,8 +1295,8 @@ def test_meta_path(): assert pytester.runpytest().ret == 0 def test_write_pyc(self, pytester: Pytester, tmp_path) -> None: - from _pytest.assertion.rewrite import _write_pyc from _pytest.assertion import AssertionState + from _pytest.assertion.rewrite import _write_pyc config = pytester.parseconfig() state = AssertionState(config, "rewrite") @@ -1065,6 +1346,7 @@ def test_read_pyc(self, tmp_path: Path) -> None: an exception that is propagated to the caller. """ import py_compile + from _pytest.assertion.rewrite import _read_pyc source = tmp_path / "source.py" @@ -1270,9 +1552,6 @@ def test_simple_failure(): result.stdout.fnmatch_lines(["*E*assert (1 + 1) == 3"]) -@pytest.mark.skipif( - sys.version_info < (3, 8), reason="walrus operator not available in py<38" -) class TestIssue10743: def test_assertion_walrus_operator(self, pytester: Pytester) -> None: pytester.makepyfile( @@ -1441,9 +1720,6 @@ def test_walrus_operator_not_override_value(): assert result.ret == 0 -@pytest.mark.skipif( - sys.version_info < (3, 8), reason="walrus operator not available in py<38" -) class TestIssue11028: def test_assertion_walrus_operator_in_operand(self, pytester: Pytester) -> None: pytester.makepyfile( @@ -1531,6 +1807,27 @@ def test_gt(): result.stdout.fnmatch_lines(["*assert 4 > 5", "*where 5 = add_one(4)"]) +class TestIssue11239: + def test_assertion_walrus_different_test_cases(self, pytester: Pytester) -> None: + """Regression for (#11239) + + Walrus operator rewriting would leak to separate test cases if they used the same variables. + """ + pytester.makepyfile( + """ + def test_1(): + state = {"x": 2}.get("x") + assert state is not None + + def test_2(): + db = {"x": 2} + assert (state := db.get("x")) is not None + """ + ) + result = pytester.runpytest() + assert result.ret == 0 + + @pytest.mark.skipif( sys.maxsize <= (2**31 - 1), reason="Causes OverflowError on 32bit systems" ) @@ -1595,14 +1892,14 @@ class TestEarlyRewriteBailout: @pytest.fixture def hook( self, pytestconfig, monkeypatch, pytester: Pytester - ) -> Generator[AssertionRewritingHook, None, None]: + ) -> Generator[AssertionRewritingHook]: """Returns a patched AssertionRewritingHook instance so we can configure its initial paths and track if PathFinder.find_spec has been called. """ import importlib.machinery - self.find_spec_calls: List[str] = [] - self.initial_paths: Set[Path] = set() + self.find_spec_calls: list[str] = [] + self.initial_paths: set[Path] = set() class StubSession: _initialpaths = self.initial_paths @@ -1693,8 +1990,8 @@ def test_cwd_changed(self, pytester: Pytester, monkeypatch) -> None: import os import tempfile - with tempfile.TemporaryDirectory() as d: - os.chdir(d) + with tempfile.TemporaryDirectory() as newpath: + os.chdir(newpath) """, "test_test.py": """\ def test(): @@ -1818,10 +2115,10 @@ def test_simple(): result.assert_outcomes(passed=1) +# fmt: off @pytest.mark.parametrize( ("src", "expected"), ( - # fmt: off pytest.param(b"", {}, id="trivial"), pytest.param( b"def x(): assert 1\n", @@ -1898,9 +2195,9 @@ def test_simple(): {1: "5"}, id="no newline at end of file", ), - # fmt: on ), ) +# fmt: on def test_get_assertion_exprs(src, expected) -> None: assert _get_assertion_exprs(src) == expected @@ -1936,6 +2233,11 @@ def fake_mkdir(p, exist_ok=False, *, exc): monkeypatch.setattr(os, "makedirs", partial(fake_mkdir, exc=err)) assert not try_makedirs(p) + err = OSError() + err.errno = errno.ENOSYS + monkeypatch.setattr(os, "makedirs", partial(fake_mkdir, exc=err)) + assert not try_makedirs(p) + # unhandled OSError should raise err = OSError() err.errno = errno.ECHILD @@ -1957,16 +2259,10 @@ class TestPyCacheDir: ) def test_get_cache_dir(self, monkeypatch, prefix, source, expected) -> None: monkeypatch.delenv("PYTHONPYCACHEPREFIX", raising=False) - - if prefix is not None and sys.version_info < (3, 8): - pytest.skip("pycache_prefix not available in py<38") monkeypatch.setattr(sys, "pycache_prefix", prefix, raising=False) assert get_cache_dir(Path(source)) == Path(expected) - @pytest.mark.skipif( - sys.version_info < (3, 8), reason="pycache_prefix not available in py<38" - ) @pytest.mark.skipif( sys.version_info[:2] == (3, 9) and sys.platform.startswith("win"), reason="#9298", @@ -2002,8 +2298,8 @@ def test_foo(): assert test_foo_pyc.is_file() # normal file: not touched by pytest, normal cache tag - bar_init_pyc = get_cache_dir(bar_init) / "__init__.{cache_tag}.pyc".format( - cache_tag=sys.implementation.cache_tag + bar_init_pyc = ( + get_cache_dir(bar_init) / f"__init__.{sys.implementation.cache_tag}.pyc" ) assert bar_init_pyc.is_file() @@ -2025,13 +2321,15 @@ class TestReprSizeVerbosity: ) def test_get_maxsize_for_saferepr(self, verbose: int, expected_size) -> None: class FakeConfig: - def getoption(self, name: str) -> int: - assert name == "verbose" + def get_verbosity(self, verbosity_type: str | None = None) -> int: return verbose config = FakeConfig() assert _get_maxsize_for_saferepr(cast(Config, config)) == expected_size + def test_get_maxsize_for_saferepr_no_config(self) -> None: + assert _get_maxsize_for_saferepr(None) == DEFAULT_REPR_MAX_SIZE + def create_test_file(self, pytester: Pytester, size: int) -> None: pytester.makepyfile( f""" @@ -2055,3 +2353,40 @@ def test_max_increased_verbosity(self, pytester: Pytester) -> None: self.create_test_file(pytester, DEFAULT_REPR_MAX_SIZE * 10) result = pytester.runpytest("-vv") result.stdout.no_fnmatch_line("*xxx...xxx*") + + +class TestIssue11140: + def test_constant_not_picked_as_module_docstring(self, pytester: Pytester) -> None: + pytester.makepyfile( + """\ + 0 + + def test_foo(): + pass + """ + ) + result = pytester.runpytest() + assert result.ret == 0 + + +class TestSafereprUnbounded: + class Help: + def bound_method(self): # pragma: no cover + pass + + def test_saferepr_bound_method(self): + """saferepr() of a bound method should show only the method name""" + assert _saferepr(self.Help().bound_method) == "bound_method" + + def test_saferepr_unbounded(self): + """saferepr() of an unbound method should still show the full information""" + obj = self.Help() + # using id() to fetch memory address fails on different platforms + pattern = re.compile( + rf"<{Path(__file__).stem}.{self.__class__.__name__}.Help object at 0x[0-9a-fA-F]*>", + ) + assert pattern.match(_saferepr(obj)) + assert ( + _saferepr(self.Help) + == f"" + ) diff --git a/testing/test_cacheprovider.py b/testing/test_cacheprovider.py index e2e195ca7f5..ca417e86ee5 100644 --- a/testing/test_cacheprovider.py +++ b/testing/test_cacheprovider.py @@ -1,14 +1,21 @@ +from __future__ import annotations + +from collections.abc import Generator +from collections.abc import Sequence +from enum import auto +from enum import Enum import os -import shutil from pathlib import Path -from typing import Generator -from typing import List +import shutil +from typing import Any -import pytest +from _pytest.compat import assert_never from _pytest.config import ExitCode from _pytest.monkeypatch import MonkeyPatch from _pytest.pytester import Pytester from _pytest.tmpdir import TempPathFactory +import pytest + pytest_plugins = ("pytester",) @@ -24,6 +31,21 @@ def test_config_cache_mkdir(self, pytester: Pytester) -> None: p = config.cache.mkdir("name") assert p.is_dir() + def test_cache_dir_permissions(self, pytester: Pytester) -> None: + """The .pytest_cache directory should have world-readable permissions + (depending on umask). + + Regression test for #12308. + """ + pytester.makeini("[pytest]") + config = pytester.parseconfigure() + assert config.cache is not None + p = config.cache.mkdir("name") + assert p.is_dir() + # Instead of messing with umask, make sure .pytest_cache has the same + # permissions as the default that `mkdir` gives `p`. + assert (p.parent.stat().st_mode & 0o777) == (p.stat().st_mode & 0o777) + def test_config_cache_dataerror(self, pytester: Pytester) -> None: pytester.makeini("[pytest]") config = pytester.parseconfigure() @@ -36,7 +58,7 @@ def test_config_cache_dataerror(self, pytester: Pytester) -> None: assert val == -2 @pytest.mark.filterwarnings("ignore:could not create cache path") - def test_cache_writefail_cachfile_silent(self, pytester: Pytester) -> None: + def test_cache_writefail_cachefile_silent(self, pytester: Pytester) -> None: pytester.makeini("[pytest]") pytester.path.joinpath(".pytest_cache").write_text( "gone wrong", encoding="utf-8" @@ -47,7 +69,7 @@ def test_cache_writefail_cachfile_silent(self, pytester: Pytester) -> None: cache.set("test/broken", []) @pytest.fixture - def unwritable_cache_dir(self, pytester: Pytester) -> Generator[Path, None, None]: + def unwritable_cache_dir(self, pytester: Pytester) -> Generator[Path]: cache_dir = pytester.path.joinpath(".pytest_cache") cache_dir.mkdir() mode = cache_dir.stat().st_mode @@ -82,7 +104,7 @@ def test_cache_failure_warns( pytester.makepyfile("def test_error(): raise Exception") result = pytester.runpytest() assert result.ret == 1 - # warnings from nodeids, lastfailed, and stepwise + # warnings from nodeids and lastfailed result.stdout.fnmatch_lines( [ # Validate location/stacklevel of warning from cacheprovider. @@ -91,7 +113,7 @@ def test_cache_failure_warns( " */cacheprovider.py:*: PytestCacheWarning: could not create cache path " f"{unwritable_cache_dir}/v/cache/nodeids: *", ' config.cache.set("cache/nodeids", sorted(self.cached_nodeids))', - "*1 failed, 3 warnings in*", + "*1 failed, 2 warnings in*", ] ) @@ -133,12 +155,10 @@ def test_cachefuncarg(cache): def test_custom_rel_cache_dir(self, pytester: Pytester) -> None: rel_cache_dir = os.path.join("custom_cache_dir", "subdir") pytester.makeini( - """ + f""" [pytest] - cache_dir = {cache_dir} - """.format( - cache_dir=rel_cache_dir - ) + cache_dir = {rel_cache_dir} + """ ) pytester.makepyfile(test_errored="def test_error():\n assert False") pytester.runpytest() @@ -150,12 +170,10 @@ def test_custom_abs_cache_dir( tmp = tmp_path_factory.mktemp("tmp") abs_cache_dir = tmp / "custom_cache_dir" pytester.makeini( - """ + f""" [pytest] - cache_dir = {cache_dir} - """.format( - cache_dir=abs_cache_dir - ) + cache_dir = {abs_cache_dir} + """ ) pytester.makepyfile(test_errored="def test_error():\n assert False") pytester.runpytest() @@ -169,17 +187,17 @@ def test_custom_cache_dir_with_env_var( """ [pytest] cache_dir = {cache_dir} - """.format( - cache_dir="$env_var" - ) + """.format(cache_dir="$env_var") ) pytester.makepyfile(test_errored="def test_error():\n assert False") pytester.runpytest() assert pytester.path.joinpath("custom_cache_dir").is_dir() -@pytest.mark.parametrize("env", ((), ("TOX_ENV_DIR", "/tox_env_dir"))) -def test_cache_reportheader(env, pytester: Pytester, monkeypatch: MonkeyPatch) -> None: +@pytest.mark.parametrize("env", ((), ("TOX_ENV_DIR", "mydir/tox-env"))) +def test_cache_reportheader( + env: Sequence[str], pytester: Pytester, monkeypatch: MonkeyPatch +) -> None: pytester.makepyfile("""def test_foo(): pass""") if env: monkeypatch.setenv(*env) @@ -188,7 +206,7 @@ def test_cache_reportheader(env, pytester: Pytester, monkeypatch: MonkeyPatch) - monkeypatch.delenv("TOX_ENV_DIR", raising=False) expected = ".pytest_cache" result = pytester.runpytest("-v") - result.stdout.fnmatch_lines(["cachedir: %s" % expected]) + result.stdout.fnmatch_lines([f"cachedir: {expected}"]) def test_cache_reportheader_external_abspath( @@ -200,12 +218,10 @@ def test_cache_reportheader_external_abspath( pytester.makepyfile("def test_hello(): pass") pytester.makeini( - """ + f""" [pytest] - cache_dir = {abscache} - """.format( - abscache=external_cache - ) + cache_dir = {external_cache} + """ ) result = pytester.runpytest("-v") result.stdout.fnmatch_lines([f"cachedir: {external_cache}"]) @@ -422,7 +438,7 @@ def test_fail(val): result = pytester.runpytest() result.stdout.fnmatch_lines(["*1 failed in*"]) - @pytest.mark.parametrize("parent", ("session", "package")) + @pytest.mark.parametrize("parent", ("directory", "package")) def test_terminal_report_lastfailed(self, pytester: Pytester, parent: str) -> None: if parent == "package": pytester.makepyfile( @@ -513,7 +529,7 @@ def test_hello(): """ ) - def rlf(fail_import, fail_run): + def rlf(fail_import: int, fail_run: int) -> Any: monkeypatch.setenv("FAILIMPORT", str(fail_import)) monkeypatch.setenv("FAILTEST", str(fail_run)) @@ -561,7 +577,9 @@ def test_pass(): """ ) - def rlf(fail_import, fail_run, args=()): + def rlf( + fail_import: int, fail_run: int, args: Sequence[str] = () + ) -> tuple[Any, Any]: monkeypatch.setenv("FAILIMPORT", str(fail_import)) monkeypatch.setenv("FAILTEST", str(fail_run)) @@ -645,13 +663,11 @@ def test(): assert 0 assert result.ret == 1 pytester.makepyfile( - """ + f""" import pytest @pytest.{mark} def test(): assert 0 - """.format( - mark=mark - ) + """ ) result = pytester.runpytest() assert result.ret == 0 @@ -677,7 +693,7 @@ def test_lf_and_ff_prints_no_needless_message( else: assert "rerun previous" in result.stdout.str() - def get_cached_last_failed(self, pytester: Pytester) -> List[str]: + def get_cached_last_failed(self, pytester: Pytester) -> list[str]: config = pytester.parseconfigure() assert config.cache is not None return sorted(config.cache.get("cache/lastfailed", {})) @@ -936,8 +952,10 @@ def test_lastfailed_with_known_failures_not_being_selected( "collected 1 item", "run-last-failure: rerun previous 1 failure (skipped 1 file)", "", - "", - " ", + "", + " ", + " ", + " ", ] ) @@ -966,8 +984,10 @@ def test_fail(): assert 0 "*collected 1 item", "run-last-failure: 1 known failures not in selected tests", "", - "", - " ", + "", + " ", + " ", + " ", ], consecutive=True, ) @@ -981,8 +1001,10 @@ def test_fail(): assert 0 "collected 2 items / 1 deselected / 1 selected", "run-last-failure: rerun previous 1 failure", "", - "", - " ", + "", + " ", + " ", + " ", "*= 1/2 tests collected (1 deselected) in *", ], ) @@ -1011,10 +1033,12 @@ def test_other(): assert 0 "collected 3 items / 1 deselected / 2 selected", "run-last-failure: rerun previous 2 failures", "", - "", - " ", - " ", - " ", + "", + " ", + " ", + " ", + " ", + " ", "", "*= 2/3 tests collected (1 deselected) in *", ], @@ -1048,8 +1072,10 @@ def test_pass(): pass "collected 1 item", "run-last-failure: 1 known failures not in selected tests", "", - "", - " ", + "", + " ", + " ", + " ", "", "*= 1 test collected in*", ], @@ -1137,7 +1163,7 @@ def test_1(): assert 1 ) p1.write_text( - "def test_1(): assert 1\n" "def test_2(): assert 1\n", encoding="utf-8" + "def test_1(): assert 1\ndef test_2(): assert 1\n", encoding="utf-8" ) os.utime(p1, ns=(p1.stat().st_atime_ns, int(1e9))) @@ -1252,20 +1278,41 @@ def test_readme_failed(self, pytester: Pytester) -> None: assert self.check_readme(pytester) is True -def test_gitignore(pytester: Pytester) -> None: +class Action(Enum): + """Action to perform on the cache directory.""" + + MKDIR = auto() + SET = auto() + + +@pytest.mark.parametrize("action", list(Action)) +def test_gitignore( + pytester: Pytester, + action: Action, +) -> None: """Ensure we automatically create .gitignore file in the pytest_cache directory (#3286).""" from _pytest.cacheprovider import Cache config = pytester.parseconfig() cache = Cache.for_config(config, _ispytest=True) - cache.set("foo", "bar") + if action == Action.MKDIR: + cache.mkdir("foo") + elif action == Action.SET: + cache.set("foo", "bar") + else: + assert_never(action) msg = "# Created by pytest automatically.\n*\n" gitignore_path = cache._cachedir.joinpath(".gitignore") assert gitignore_path.read_text(encoding="UTF-8") == msg # Does not overwrite existing/custom one. gitignore_path.write_text("custom", encoding="utf-8") - cache.set("something", "else") + if action == Action.MKDIR: + cache.mkdir("something") + elif action == Action.SET: + cache.set("something", "else") + else: + assert_never(action) assert gitignore_path.read_text(encoding="UTF-8") == "custom" diff --git a/testing/test_capture.py b/testing/test_capture.py index b6ea8161356..d9dacebd938 100644 --- a/testing/test_capture.py +++ b/testing/test_capture.py @@ -1,16 +1,19 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + +from collections.abc import Generator import contextlib import io +from io import UnsupportedOperation import os +import re import subprocess import sys import textwrap -from io import UnsupportedOperation from typing import BinaryIO from typing import cast -from typing import Generator from typing import TextIO -import pytest from _pytest import capture from _pytest.capture import _get_multicapture from _pytest.capture import CaptureFixture @@ -20,6 +23,8 @@ from _pytest.config import ExitCode from _pytest.monkeypatch import MonkeyPatch from _pytest.pytester import Pytester +import pytest + # note: py.io capture tests where copied from # pylib 1.4.20.dev2 (rev 13d9af95547e) @@ -101,16 +106,15 @@ def test_init_capturing(self): def test_capturing_unicode(pytester: Pytester, method: str) -> None: obj = "'b\u00f6y'" pytester.makepyfile( - """\ + f"""\ # taken from issue 227 from nosetests def test_unicode(): import sys print(sys.stdout) - print(%s) + print({obj}) """ - % obj ) - result = pytester.runpytest("--capture=%s" % method) + result = pytester.runpytest(f"--capture={method}") result.stdout.fnmatch_lines(["*1 passed*"]) @@ -122,7 +126,7 @@ def test_unicode(): print('b\\u00f6y') """ ) - result = pytester.runpytest("--capture=%s" % method) + result = pytester.runpytest(f"--capture={method}") result.stdout.fnmatch_lines(["*1 passed*"]) @@ -442,6 +446,38 @@ def test_hello(capsys): ) reprec.assertoutcome(passed=1) + def test_capteesys(self, pytester: Pytester) -> None: + p = pytester.makepyfile( + """\ + import sys + def test_one(capteesys): + print("sTdoUt") + print("sTdeRr", file=sys.stderr) + out, err = capteesys.readouterr() + assert out == "sTdoUt\\n" + assert err == "sTdeRr\\n" + """ + ) + # -rN and --capture=tee-sys means we'll read them on stdout/stderr, + # as opposed to both being reported on stdout + result = pytester.runpytest(p, "--quiet", "--quiet", "-rN", "--capture=tee-sys") + assert result.ret == ExitCode.OK + result.stdout.fnmatch_lines(["sTdoUt"]) # tee'd out + result.stderr.fnmatch_lines(["sTdeRr"]) # tee'd out + + result = pytester.runpytest(p, "--quiet", "--quiet", "-rA", "--capture=tee-sys") + assert result.ret == ExitCode.OK + result.stdout.fnmatch_lines( + ["sTdoUt", "sTdoUt", "sTdeRr"] + ) # tee'd out, the next two reported + result.stderr.fnmatch_lines(["sTdeRr"]) # tee'd out + + # -rA and --capture=sys means we'll read them on stdout. + result = pytester.runpytest(p, "--quiet", "--quiet", "-rA", "--capture=sys") + assert result.ret == ExitCode.OK + result.stdout.fnmatch_lines(["sTdoUt", "sTdeRr"]) # no tee, just reported + assert not result.stderr.lines + def test_capsyscapfd(self, pytester: Pytester) -> None: p = pytester.makepyfile( """\ @@ -502,13 +538,11 @@ def test_capture_is_represented_on_failure_issue128( self, pytester: Pytester, method ) -> None: p = pytester.makepyfile( - """\ - def test_hello(cap{}): + f"""\ + def test_hello(cap{method}): print("xxx42xxx") assert 0 - """.format( - method - ) + """ ) result = pytester.runpytest(p) result.stdout.fnmatch_lines(["xxx42xxx"]) @@ -623,7 +657,7 @@ def test_disabled_capture_fixture( self, pytester: Pytester, fixture: str, no_capture: bool ) -> None: pytester.makepyfile( - """\ + f"""\ def test_disabled({fixture}): print('captured before') with {fixture}.disabled(): @@ -633,9 +667,7 @@ def test_disabled({fixture}): def test_normal(): print('test_normal executed') - """.format( - fixture=fixture - ) + """ ) args = ("-s",) if no_capture else () result = pytester.runpytest_subprocess(*args) @@ -680,7 +712,7 @@ def test_fixture_use_by_other_fixtures(self, pytester: Pytester, fixture) -> Non """Ensure that capsys and capfd can be used by other fixtures during setup and teardown.""" pytester.makepyfile( - """\ + f"""\ import sys import pytest @@ -702,9 +734,7 @@ def test_captured_print(captured_print): out, err = captured_print assert out == 'stdout contents begin\\n' assert err == 'stderr contents begin\\n' - """.format( - fixture=fixture - ) + """ ) result = pytester.runpytest_subprocess() result.stdout.fnmatch_lines(["*1 passed*"]) @@ -717,7 +747,7 @@ def test_fixture_use_by_other_fixtures_teardown( ) -> None: """Ensure we can access setup and teardown buffers from teardown when using capsys/capfd (##3033)""" pytester.makepyfile( - """\ + f"""\ import sys import pytest import os @@ -734,9 +764,7 @@ def fix({cap}): def test_a(fix): print("call out") sys.stderr.write("call err\\n") - """.format( - cap=cap - ) + """ ) reprec = pytester.inline_run() reprec.assertoutcome(passed=1) @@ -944,7 +972,7 @@ def test_captureresult() -> None: @pytest.fixture -def tmpfile(pytester: Pytester) -> Generator[BinaryIO, None, None]: +def tmpfile(pytester: Pytester) -> Generator[BinaryIO]: f = pytester.makepyfile("").open("wb+") yield f if not f.closed: @@ -1048,16 +1076,12 @@ def test_simple_resume_suspend(self) -> None: pytest.raises(AssertionError, cap.suspend) assert repr(cap) == ( - "".format( - cap.targetfd_save, cap.tmpfile - ) + f"" ) # Should not crash with missing "_old". assert isinstance(cap.syscapture, capture.SysCapture) assert repr(cap.syscapture) == ( - " _state='done' tmpfile={!r}>".format( - cap.syscapture.tmpfile - ) + f" _state='done' tmpfile={cap.syscapture.tmpfile!r}>" ) def test_capfd_sys_stdout_mode(self, capfd) -> None: @@ -1198,7 +1222,6 @@ class TestTeeStdCapture(TestStdCapture): def test_capturing_error_recursive(self) -> None: r"""For TeeStdCapture since we passthrough stderr/stdout, cap1 should get all output, while cap2 should only get "cap2\n".""" - with self.getcapture() as cap1: print("cap1") with self.getcapture() as cap2: @@ -1393,28 +1416,27 @@ def test_capture_again(): def test_capturing_and_logging_fundamentals(pytester: Pytester, method: str) -> None: # here we check a fundamental feature p = pytester.makepyfile( - """ + f""" import sys, os, logging from _pytest import capture cap = capture.MultiCapture( in_=None, out=None, - err=capture.%s, + err=capture.{method}, ) cap.start_capturing() logging.warning("hello1") outerr = cap.readouterr() - print("suspend, captured %%s" %%(outerr,)) + print("suspend, captured %s" %(outerr,)) logging.warning("hello2") cap.pop_outerr_to_orig() logging.warning("hello3") outerr = cap.readouterr() - print("suspend2, captured %%s" %% (outerr,)) + print("suspend2, captured %s" % (outerr,)) """ - % (method,) ) result = pytester.runpython(p) result.stdout.fnmatch_lines( @@ -1580,16 +1602,16 @@ def test_capture_with_live_logging( # capture should work with live cli logging pytester.makepyfile( - """ + f""" import logging import sys logger = logging.getLogger(__name__) - def test_capture({0}): + def test_capture({capture_fixture}): print("hello") sys.stderr.write("world\\n") - captured = {0}.readouterr() + captured = {capture_fixture}.readouterr() assert captured.out == "hello\\n" assert captured.err == "world\\n" @@ -1597,11 +1619,9 @@ def test_capture({0}): print("next") logging.info("something") - captured = {0}.readouterr() + captured = {capture_fixture}.readouterr() assert captured.out == "next\\n" - """.format( - capture_fixture - ) + """ ) result = pytester.runpytest_subprocess("--log-cli-level=INFO") @@ -1679,3 +1699,32 @@ def test_logging(): ) result.stdout.no_fnmatch_line("*Captured stderr call*") result.stdout.no_fnmatch_line("*during collection*") + + +def test_libedit_workaround(pytester: Pytester) -> None: + pytester.makeconftest(""" + import pytest + + + def pytest_terminal_summary(config): + capture = config.pluginmanager.getplugin("capturemanager") + capture.suspend_global_capture(in_=True) + + print("Enter 'hi'") + value = input() + print(f"value: {value!r}") + + capture.resume_global_capture() + """) + readline = pytest.importorskip("readline") + backend = getattr(readline, "backend", readline.__doc__) # added in Python 3.13 + print(f"Readline backend: {backend}") + + child = pytester.spawn_pytest("") + child.expect(r"Enter 'hi'") + child.sendline("hi") + rest = child.read().decode("utf8") + print(rest) + match = re.search(r"^value: '(.*)'\r?$", rest, re.MULTILINE) + assert match is not None + assert match.group(1) == "hi" diff --git a/testing/test_collect_imported_tests.py b/testing/test_collect_imported_tests.py new file mode 100644 index 00000000000..28b92e17f6f --- /dev/null +++ b/testing/test_collect_imported_tests.py @@ -0,0 +1,102 @@ +"""Tests for the `collect_imported_tests` configuration value.""" + +from __future__ import annotations + +import textwrap + +from _pytest.pytester import Pytester +import pytest + + +def setup_files(pytester: Pytester) -> None: + src_dir = pytester.mkdir("src") + tests_dir = pytester.mkdir("tests") + src_file = src_dir / "foo.py" + + src_file.write_text( + textwrap.dedent("""\ + class Testament: + def test_collections(self): + pass + + def test_testament(): pass + """), + encoding="utf-8", + ) + + test_file = tests_dir / "foo_test.py" + test_file.write_text( + textwrap.dedent("""\ + from foo import Testament, test_testament + + class TestDomain: + def test(self): + testament = Testament() + assert testament + """), + encoding="utf-8", + ) + + pytester.syspathinsert(src_dir) + + +def test_collect_imports_disabled(pytester: Pytester) -> None: + """ + When collect_imported_tests is disabled, only objects in the + test modules are collected as tests, so the imported names (`Testament` and `test_testament`) + are not collected. + """ + pytester.makeini( + """ + [pytest] + collect_imported_tests = false + """ + ) + + setup_files(pytester) + result = pytester.runpytest("-v", "tests") + result.stdout.fnmatch_lines( + [ + "tests/foo_test.py::TestDomain::test PASSED*", + ] + ) + + # Ensure that the hooks were only called for the collected item. + reprec = result.reprec # type:ignore[attr-defined] + reports = reprec.getreports("pytest_collectreport") + [modified] = reprec.getcalls("pytest_collection_modifyitems") + [item_collected] = reprec.getcalls("pytest_itemcollected") + + assert [x.nodeid for x in reports] == [ + "", + "tests/foo_test.py::TestDomain", + "tests/foo_test.py", + "tests", + ] + assert [x.nodeid for x in modified.items] == ["tests/foo_test.py::TestDomain::test"] + assert item_collected.item.nodeid == "tests/foo_test.py::TestDomain::test" + + +@pytest.mark.parametrize("configure_ini", [False, True]) +def test_collect_imports_enabled(pytester: Pytester, configure_ini: bool) -> None: + """ + When collect_imported_tests is enabled (the default), all names in the + test modules are collected as tests. + """ + if configure_ini: + pytester.makeini( + """ + [pytest] + collect_imported_tests = true + """ + ) + + setup_files(pytester) + result = pytester.runpytest("-v", "tests") + result.stdout.fnmatch_lines( + [ + "tests/foo_test.py::Testament::test_collections PASSED*", + "tests/foo_test.py::test_testament PASSED*", + "tests/foo_test.py::TestDomain::test PASSED*", + ] + ) diff --git a/testing/test_collection.py b/testing/test_collection.py index 8b0a1ab3650..7cb20734e0d 100644 --- a/testing/test_collection.py +++ b/testing/test_collection.py @@ -1,12 +1,16 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + import os +from pathlib import Path +from pathlib import PurePath import pprint import shutil import sys +import tempfile import textwrap -from pathlib import Path -from typing import List -import pytest +from _pytest.assertion.util import running_on_ci from _pytest.config import ExitCode from _pytest.fixtures import FixtureRequest from _pytest.main import _in_venv @@ -16,6 +20,7 @@ from _pytest.pathlib import symlink_or_skip from _pytest.pytester import HookRecorder from _pytest.pytester import Pytester +import pytest def ensure_file(file_path: Path) -> Path: @@ -99,7 +104,8 @@ def test_getcustomfile_roundtrip(self, pytester: Pytester) -> None: conftest=""" import pytest class CustomFile(pytest.File): - pass + def collect(self): + return [] def pytest_collect_file(file_path, parent): if file_path.suffix == ".xxx": return CustomFile.from_parent(path=file_path, parent=parent) @@ -147,20 +153,17 @@ def test_ignored_certain_directories(self, pytester: Pytester) -> None: assert "test_notfound" not in s assert "test_found" in s - @pytest.mark.parametrize( - "fname", - ( - "activate", - "activate.csh", - "activate.fish", - "Activate", - "Activate.bat", - "Activate.ps1", - ), + known_environment_types = pytest.mark.parametrize( + "env_path", + [ + pytest.param(PurePath("pyvenv.cfg"), id="venv"), + pytest.param(PurePath("conda-meta", "history"), id="conda"), + ], ) - def test_ignored_virtualenvs(self, pytester: Pytester, fname: str) -> None: - bindir = "Scripts" if sys.platform.startswith("win") else "bin" - ensure_file(pytester.path / "virtual" / bindir / fname) + + @known_environment_types + def test_ignored_virtualenvs(self, pytester: Pytester, env_path: PurePath) -> None: + ensure_file(pytester.path / "virtual" / env_path) testfile = ensure_file(pytester.path / "virtual" / "test_invenv.py") testfile.write_text("def test_hello(): pass", encoding="utf-8") @@ -174,23 +177,12 @@ def test_ignored_virtualenvs(self, pytester: Pytester, fname: str) -> None: result = pytester.runpytest("virtual") assert "test_invenv" in result.stdout.str() - @pytest.mark.parametrize( - "fname", - ( - "activate", - "activate.csh", - "activate.fish", - "Activate", - "Activate.bat", - "Activate.ps1", - ), - ) + @known_environment_types def test_ignored_virtualenvs_norecursedirs_precedence( - self, pytester: Pytester, fname: str + self, pytester: Pytester, env_path ) -> None: - bindir = "Scripts" if sys.platform.startswith("win") else "bin" # norecursedirs takes priority - ensure_file(pytester.path / ".virtual" / bindir / fname) + ensure_file(pytester.path / ".virtual" / env_path) testfile = ensure_file(pytester.path / ".virtual" / "test_invenv.py") testfile.write_text("def test_hello(): pass", encoding="utf-8") result = pytester.runpytest("--collect-in-virtualenv") @@ -199,27 +191,14 @@ def test_ignored_virtualenvs_norecursedirs_precedence( result = pytester.runpytest("--collect-in-virtualenv", ".virtual") assert "test_invenv" in result.stdout.str() - @pytest.mark.parametrize( - "fname", - ( - "activate", - "activate.csh", - "activate.fish", - "Activate", - "Activate.bat", - "Activate.ps1", - ), - ) - def test__in_venv(self, pytester: Pytester, fname: str) -> None: + @known_environment_types + def test__in_venv(self, pytester: Pytester, env_path: PurePath) -> None: """Directly test the virtual env detection function""" - bindir = "Scripts" if sys.platform.startswith("win") else "bin" - # no bin/activate, not a virtualenv + # no env path, not a env base_path = pytester.mkdir("venv") assert _in_venv(base_path) is False - # with bin/activate, totally a virtualenv - bin_path = base_path.joinpath(bindir) - bin_path.mkdir() - bin_path.joinpath(fname).touch() + # with env path, totally a env + ensure_file(base_path.joinpath(env_path)) assert _in_venv(base_path) is True def test_custom_norecursedirs(self, pytester: Pytester) -> None: @@ -271,14 +250,31 @@ def test_testpaths_ini(self, pytester: Pytester, monkeypatch: MonkeyPatch) -> No # collects the tests for dirname in ("a", "b", "c"): items, reprec = pytester.inline_genitems(tmp_path.joinpath(dirname)) - assert [x.name for x in items] == ["test_%s" % dirname] + assert [x.name for x in items] == [f"test_{dirname}"] # changing cwd to each subdirectory and running pytest without # arguments collects the tests in that directory normally for dirname in ("a", "b", "c"): monkeypatch.chdir(pytester.path.joinpath(dirname)) items, reprec = pytester.inline_genitems() - assert [x.name for x in items] == ["test_%s" % dirname] + assert [x.name for x in items] == [f"test_{dirname}"] + + def test_missing_permissions_on_unselected_directory_doesnt_crash( + self, pytester: Pytester + ) -> None: + """Regression test for #12120.""" + test = pytester.makepyfile(test="def test(): pass") + bad = pytester.mkdir("bad") + try: + bad.chmod(0) + + result = pytester.runpytest(test) + finally: + bad.chmod(750) + bad.rmdir() + + assert result.ret == ExitCode.OK + result.assert_outcomes(passed=1) class TestCollectPluginHookRelay: @@ -334,17 +330,39 @@ def test_collect_report_postprocessing(self, pytester: Pytester) -> None: pytester.makeconftest( """ import pytest - @pytest.hookimpl(hookwrapper=True) + @pytest.hookimpl(wrapper=True) def pytest_make_collect_report(): - outcome = yield - rep = outcome.get_result() + rep = yield rep.headerlines += ["header1"] - outcome.force_result(rep) + return rep """ ) result = pytester.runpytest(p) result.stdout.fnmatch_lines(["*ERROR collecting*", "*header1*"]) + def test_collection_error_traceback_is_clean(self, pytester: Pytester) -> None: + """When a collection error occurs, the report traceback doesn't contain + internal pytest stack entries. + + Issue #11710. + """ + pytester.makepyfile( + """ + raise Exception("LOUSY") + """ + ) + result = pytester.runpytest() + result.stdout.fnmatch_lines( + [ + "*ERROR collecting*", + "test_*.py:1: in ", + ' raise Exception("LOUSY")', + "E Exception: LOUSY", + "*= short test summary info =*", + ], + consecutive=True, + ) + class TestCustomConftests: def test_ignore_collect_path(self, pytester: Pytester) -> None: @@ -490,9 +508,9 @@ def test_collect_topdir(self, pytester: Pytester) -> None: # assert root2 == rcol, rootid colitems = rcol.perform_collect([rcol.nodeid], genitems=False) assert len(colitems) == 1 - assert colitems[0].path == p + assert colitems[0].path == topdir - def get_reported_items(self, hookrec: HookRecorder) -> List[Item]: + def get_reported_items(self, hookrec: HookRecorder) -> list[Item]: """Return pytest.Item instances reported by the pytest_collectreport hook""" calls = hookrec.getcalls("pytest_collectreport") return [ @@ -511,7 +529,7 @@ def test_collect_protocol_single_function(self, pytester: Pytester) -> None: newid = item.nodeid assert newid == id pprint.pprint(hookrec.calls) - topdir = pytester.path # noqa + topdir = pytester.path # noqa: F841 hookrec.assert_contains( [ ("pytest_collectstart", "collector.path == topdir"), @@ -546,7 +564,7 @@ def test_method(self): def test_collect_custom_nodes_multi_id(self, pytester: Pytester) -> None: p = pytester.makepyfile("def test_func(): pass") pytester.makeconftest( - """ + f""" import pytest class SpecialItem(pytest.Item): def runtest(self): @@ -555,10 +573,9 @@ class SpecialFile(pytest.File): def collect(self): return [SpecialItem.from_parent(name="check", parent=self)] def pytest_collect_file(file_path, parent): - if file_path.name == %r: + if file_path.name == {p.name!r}: return SpecialFile.from_parent(path=file_path, parent=parent) """ - % p.name ) id = p.name @@ -647,6 +664,23 @@ def test_method(self): # ensure we are reporting the collection of the single test item (#2464) assert [x.name for x in self.get_reported_items(hookrec)] == ["test_method"] + def test_collect_parametrized_order(self, pytester: Pytester) -> None: + p = pytester.makepyfile( + """ + import pytest + + @pytest.mark.parametrize('i', [0, 1, 2]) + def test_param(i): ... + """ + ) + items, hookrec = pytester.inline_genitems(f"{p}::test_param") + assert len(items) == 3 + assert [item.nodeid for item in items] == [ + "test_collect_parametrized_order.py::test_param[0]", + "test_collect_parametrized_order.py::test_param[1]", + "test_collect_parametrized_order.py::test_param[2]", + ] + class Test_getinitialnodes: def test_global_file(self, pytester: Pytester) -> None: @@ -657,7 +691,8 @@ def test_global_file(self, pytester: Pytester) -> None: assert isinstance(col, pytest.Module) assert col.name == "x.py" assert col.parent is not None - assert col.parent.parent is None + assert col.parent.parent is not None + assert col.parent.parent.parent is None for parent in col.listchain(): assert parent.config is config @@ -818,7 +853,7 @@ def runtest(self): result = pytester.runpytest() assert result.ret == 0 result.stdout.fnmatch_lines(["*2 passed*"]) - res = pytester.runpytest("%s::item2" % p.name) + res = pytester.runpytest(f"{p.name}::item2") res.stdout.fnmatch_lines(["*1 passed*"]) @@ -937,6 +972,46 @@ def test_method(self): pass assert "baz" not in mod.keywords +class TestCollectDirectoryHook: + def test_custom_directory_example(self, pytester: Pytester) -> None: + """Verify the example from the customdirectory.rst doc.""" + pytester.copy_example("customdirectory") + + reprec = pytester.inline_run() + + reprec.assertoutcome(passed=2, failed=0) + calls = reprec.getcalls("pytest_collect_directory") + assert len(calls) == 2 + assert calls[0].path == pytester.path + assert isinstance(calls[0].parent, pytest.Session) + assert calls[1].path == pytester.path / "tests" + assert isinstance(calls[1].parent, pytest.Dir) + + def test_directory_ignored_if_none(self, pytester: Pytester) -> None: + """If the (entire) hook returns None, it's OK, the directory is ignored.""" + pytester.makeconftest( + """ + import pytest + + @pytest.hookimpl(wrapper=True) + def pytest_collect_directory(): + yield + return None + """, + ) + pytester.makepyfile( + **{ + "tests/test_it.py": """ + import pytest + + def test_it(): pass + """, + }, + ) + reprec = pytester.inline_run() + reprec.assertoutcome(passed=0, failed=0) + + COLLECTION_ERROR_PY_FILES = dict( test_01_failure=""" def test_1(): @@ -1098,22 +1173,24 @@ def test_collect_init_tests(pytester: Pytester) -> None: result.stdout.fnmatch_lines( [ "collected 2 items", - "", - " ", - " ", - " ", - " ", + "", + " ", + " ", + " ", + " ", + " ", ] ) result = pytester.runpytest("./tests", "--collect-only") result.stdout.fnmatch_lines( [ "collected 2 items", - "", - " ", - " ", - " ", - " ", + "", + " ", + " ", + " ", + " ", + " ", ] ) # Ignores duplicates with "." and pkginit (#4310). @@ -1121,11 +1198,12 @@ def test_collect_init_tests(pytester: Pytester) -> None: result.stdout.fnmatch_lines( [ "collected 2 items", - "", - " ", - " ", - " ", - " ", + "", + " ", + " ", + " ", + " ", + " ", ] ) # Same as before, but different order. @@ -1133,21 +1211,32 @@ def test_collect_init_tests(pytester: Pytester) -> None: result.stdout.fnmatch_lines( [ "collected 2 items", - "", - " ", - " ", - " ", - " ", + "", + " ", + " ", + " ", + " ", + " ", ] ) result = pytester.runpytest("./tests/test_foo.py", "--collect-only") result.stdout.fnmatch_lines( - ["", " ", " "] + [ + "", + " ", + " ", + " ", + ] ) result.stdout.no_fnmatch_line("*test_init*") result = pytester.runpytest("./tests/__init__.py", "--collect-only") result.stdout.fnmatch_lines( - ["", " ", " "] + [ + "", + " ", + " ", + " ", + ] ) result.stdout.no_fnmatch_line("*test_foo*") @@ -1195,7 +1284,7 @@ def test_1(): """ ) result = pytester.runpytest() - result.stdout.fnmatch_lines(["*1 passed in*"]) + result.assert_outcomes(passed=1) assert result.ret == 0 @@ -1203,21 +1292,19 @@ def test_collect_with_chdir_during_import(pytester: Pytester) -> None: subdir = pytester.mkdir("sub") pytester.path.joinpath("conftest.py").write_text( textwrap.dedent( - """ + f""" import os - os.chdir(%r) + os.chdir({str(subdir)!r}) """ - % (str(subdir),) ), encoding="utf-8", ) pytester.makepyfile( - """ + f""" def test_1(): import os - assert os.getcwd() == %r + assert os.getcwd() == {str(subdir)!r} """ - % (str(subdir),) ) result = pytester.runpytest() result.stdout.fnmatch_lines(["*1 passed in*"]) @@ -1261,7 +1348,7 @@ def test_collect_pyargs_with_testpaths( with monkeypatch.context() as mp: mp.chdir(root) result = pytester.runpytest_subprocess() - result.stdout.fnmatch_lines(["*1 passed in*"]) + result.assert_outcomes(passed=1) def test_initial_conftests_with_testpaths(pytester: Pytester) -> None: @@ -1348,7 +1435,7 @@ def test_nodeid(request): symlink_to_sub = out_of_tree.joinpath("symlink_to_sub") symlink_or_skip(sub, symlink_to_sub) os.chdir(sub) - result = pytester.runpytest("-vs", "--rootdir=%s" % sub, symlink_to_sub) + result = pytester.runpytest("-vs", f"--rootdir={sub}", symlink_to_sub) result.stdout.fnmatch_lines( [ # Should not contain "sub/"! @@ -1392,19 +1479,27 @@ def test_collect_pkg_init_and_file_in_args(pytester: Pytester) -> None: p = subdir.joinpath("test_file.py") p.write_text("def test_file(): pass", encoding="utf-8") - # NOTE: without "-o python_files=*.py" this collects test_file.py twice. - # This changed/broke with "Add package scoped fixtures #2283" (2b1410895) - # initially (causing a RecursionError). - result = pytester.runpytest("-v", str(init), str(p)) + # Just the package directory, the __init__.py module is filtered out. + result = pytester.runpytest("-v", subdir) result.stdout.fnmatch_lines( [ "sub/test_file.py::test_file PASSED*", + "*1 passed in*", + ] + ) + + # But it's included if specified directly. + result = pytester.runpytest("-v", init, p) + result.stdout.fnmatch_lines( + [ + "sub/__init__.py::test_init PASSED*", "sub/test_file.py::test_file PASSED*", "*2 passed in*", ] ) - result = pytester.runpytest("-v", "-o", "python_files=*.py", str(init), str(p)) + # Or if the pattern allows it. + result = pytester.runpytest("-v", "-o", "python_files=*.py", subdir) result.stdout.fnmatch_lines( [ "sub/__init__.py::test_init PASSED*", @@ -1419,10 +1514,13 @@ def test_collect_pkg_init_only(pytester: Pytester) -> None: init = subdir.joinpath("__init__.py") init.write_text("def test_init(): pass", encoding="utf-8") - result = pytester.runpytest(str(init)) + result = pytester.runpytest(subdir) result.stdout.fnmatch_lines(["*no tests ran in*"]) - result = pytester.runpytest("-v", "-o", "python_files=*.py", str(init)) + result = pytester.runpytest("-v", init) + result.stdout.fnmatch_lines(["sub/__init__.py::test_init PASSED*", "*1 passed in*"]) + + result = pytester.runpytest("-v", "-o", "python_files=*.py", subdir) result.stdout.fnmatch_lines(["sub/__init__.py::test_init PASSED*", "*1 passed in*"]) @@ -1499,13 +1597,16 @@ def __init__(self, *k, x, **kw): super().__init__(*k, **kw) self.x = x + def collect(self): + raise NotImplementedError() + collector = MyCollector.from_parent( parent=request.session, path=pytester.path / "foo", x=10 ) assert collector.x == 10 -def test_class_from_parent(pytester: Pytester, request: FixtureRequest) -> None: +def test_class_from_parent(request: FixtureRequest) -> None: """Ensure Class.from_parent can forward custom arguments to the constructor.""" class MyCollector(pytest.Class): @@ -1546,13 +1647,11 @@ def test_conftest(self, pytester: Pytester) -> None: pytester.makepyfile( **{ "tests/conftest.py": "", - "tests/test_foo.py": """ + "tests/test_foo.py": f""" import sys def test_check(): assert r"{tests_dir}" not in sys.path - """.format( - tests_dir=tests_dir - ), + """, } ) result = pytester.runpytest("-v", "--import-mode=importlib") @@ -1653,3 +1752,186 @@ def test_foo(): assert True assert result.ret == ExitCode.OK assert result.parseoutcomes() == {"passed": 1} + + +@pytest.mark.skipif(not sys.platform.startswith("win"), reason="Windows only") +def test_collect_short_file_windows(pytester: Pytester) -> None: + """Reproducer for #11895: short paths not collected on Windows.""" + short_path = tempfile.mkdtemp() + if "~" not in short_path: # pragma: no cover + if running_on_ci(): + # On CI, we are expecting that under the current GitHub actions configuration, + # tempfile.mkdtemp() is producing short paths, so we want to fail to prevent + # this from silently changing without us noticing. + pytest.fail( + f"tempfile.mkdtemp() failed to produce a short path on CI: {short_path}" + ) + else: + # We want to skip failing this test locally in this situation because + # depending on the local configuration tempfile.mkdtemp() might not produce a short path: + # For example, user might have configured %TEMP% exactly to avoid generating short paths. + pytest.skip( + f"tempfile.mkdtemp() failed to produce a short path: {short_path}, skipping" + ) + + test_file = Path(short_path).joinpath("test_collect_short_file_windows.py") + test_file.write_text("def test(): pass", encoding="UTF-8") + result = pytester.runpytest(short_path) + assert result.parseoutcomes() == {"passed": 1} + + +def test_pyargs_collection_tree(pytester: Pytester, monkeypatch: MonkeyPatch) -> None: + """When using `--pyargs`, the collection tree of a pyargs collection + argument should only include parents in the import path, not up to confcutdir. + + Regression test for #11904. + """ + site_packages = pytester.path / "venv/lib/site-packages" + site_packages.mkdir(parents=True) + monkeypatch.syspath_prepend(site_packages) + pytester.makepyfile( + **{ + "venv/lib/site-packages/pkg/__init__.py": "", + "venv/lib/site-packages/pkg/sub/__init__.py": "", + "venv/lib/site-packages/pkg/sub/test_it.py": "def test(): pass", + } + ) + + result = pytester.runpytest("--pyargs", "--collect-only", "pkg.sub.test_it") + assert result.ret == ExitCode.OK + result.stdout.fnmatch_lines( + [ + "", + " ", + " ", + " ", + ], + consecutive=True, + ) + + # Now with an unrelated rootdir with unrelated files. + monkeypatch.chdir(tempfile.gettempdir()) + + result = pytester.runpytest("--pyargs", "--collect-only", "pkg.sub.test_it") + assert result.ret == ExitCode.OK + result.stdout.fnmatch_lines( + [ + "", + " ", + " ", + " ", + ], + consecutive=True, + ) + + +def test_do_not_collect_symlink_siblings( + pytester: Pytester, tmp_path: Path, request: pytest.FixtureRequest +) -> None: + """ + Regression test for #12039: Do not collect from directories that are symlinks to other directories in the same path. + + The check for short paths under Windows via os.path.samefile, introduced in #11936, also finds the symlinked + directory created by tmp_path/tmpdir. + """ + # Use tmp_path because it creates a symlink with the name "current" next to the directory it creates. + symlink_path = tmp_path.parent / (tmp_path.name[:-1] + "current") + assert symlink_path.is_symlink() is True + + # Create test file. + tmp_path.joinpath("test_foo.py").write_text("def test(): pass", encoding="UTF-8") + + # Ensure we collect it only once if we pass the tmp_path. + result = pytester.runpytest(tmp_path, "-sv") + result.assert_outcomes(passed=1) + + # Ensure we collect it only once if we pass the symlinked directory. + result = pytester.runpytest(symlink_path, "-sv") + result.assert_outcomes(passed=1) + + +@pytest.mark.parametrize( + "exception_class, msg", + [ + (KeyboardInterrupt, "*!!! KeyboardInterrupt !!!*"), + (SystemExit, "INTERNALERROR> SystemExit"), + ], +) +def test_respect_system_exceptions( + pytester: Pytester, + exception_class: type[BaseException], + msg: str, +): + head = "Before exception" + tail = "After exception" + ensure_file(pytester.path / "test_eggs.py").write_text( + f"print('{head}')", encoding="UTF-8" + ) + ensure_file(pytester.path / "test_ham.py").write_text( + f"raise {exception_class.__name__}()", encoding="UTF-8" + ) + ensure_file(pytester.path / "test_spam.py").write_text( + f"print('{tail}')", encoding="UTF-8" + ) + + result = pytester.runpytest_subprocess("-s") + result.stdout.fnmatch_lines([f"*{head}*"]) + result.stdout.fnmatch_lines([msg]) + result.stdout.no_fnmatch_line(f"*{tail}*") + + +def test_yield_disallowed_in_tests(pytester: Pytester): + """Ensure generator test functions with 'yield' fail collection (#12960).""" + pytester.makepyfile( + """ + def test_with_yield(): + yield 1 + """ + ) + result = pytester.runpytest() + assert result.ret == 2 + result.stdout.fnmatch_lines( + ["*'yield' keyword is allowed in fixtures, but not in tests (test_with_yield)*"] + ) + # Assert that no tests were collected + result.stdout.fnmatch_lines(["*collected 0 items*"]) + + +def test_annotations_deferred_future(pytester: Pytester): + """Ensure stringified annotations don't raise any errors.""" + pytester.makepyfile( + """ + from __future__ import annotations + import pytest + + @pytest.fixture + def func() -> X: ... # X is undefined + + def test_func(): + assert True + """ + ) + result = pytester.runpytest() + assert result.ret == 0 + result.stdout.fnmatch_lines(["*1 passed*"]) + + +@pytest.mark.skipif( + sys.version_info < (3, 14), reason="Annotations are only skipped on 3.14+" +) +def test_annotations_deferred_314(pytester: Pytester): + """Ensure annotation eval is deferred.""" + pytester.makepyfile( + """ + import pytest + + @pytest.fixture + def func() -> X: ... # X is undefined + + def test_func(): + assert True + """ + ) + result = pytester.runpytest() + assert result.ret == 0 + result.stdout.fnmatch_lines(["*1 passed*"]) diff --git a/testing/test_compat.py b/testing/test_compat.py index 8a80fd625dc..3722bfcfb40 100644 --- a/testing/test_compat.py +++ b/testing/test_compat.py @@ -1,36 +1,24 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + import enum -import sys +from functools import cached_property from functools import partial from functools import wraps from typing import TYPE_CHECKING -from typing import Union -import pytest -from _pytest.compat import _PytestWrapper from _pytest.compat import assert_never -from _pytest.compat import cached_property from _pytest.compat import get_real_func -from _pytest.compat import is_generator from _pytest.compat import safe_getattr from _pytest.compat import safe_isclass from _pytest.outcomes import OutcomeException -from _pytest.pytester import Pytester +import pytest + if TYPE_CHECKING: from typing_extensions import Literal -def test_is_generator() -> None: - def zap(): - yield # pragma: no cover - - def foo(): - pass # pragma: no cover - - assert is_generator(zap) - assert not is_generator(foo) - - def test_real_func_loop_limit() -> None: class Evil: def __init__(self): @@ -49,10 +37,7 @@ def __getattr__(self, attr): with pytest.raises( ValueError, - match=( - "could not find real function of \n" - "stopped at " - ), + match=("wrapper loop when unwrapping "), ): get_real_func(evil) @@ -76,10 +61,13 @@ def func(): wrapped_func2 = decorator(decorator(wrapped_func)) assert get_real_func(wrapped_func2) is func - # special case for __pytest_wrapped__ attribute: used to obtain the function up until the point - # a function was wrapped by pytest itself - wrapped_func2.__pytest_wrapped__ = _PytestWrapper(wrapped_func) - assert get_real_func(wrapped_func2) is wrapped_func + # obtain the function up until the point a function was wrapped by pytest itself + @pytest.fixture + def wrapped_func3(): + pass # pragma: no cover + + wrapped_func4 = decorator(wrapped_func3) + assert get_real_func(wrapped_func4) is wrapped_func3._get_wrapped_function() def test_get_real_func_partial() -> None: @@ -92,65 +80,6 @@ def foo(x): assert get_real_func(partial(foo)) is foo -@pytest.mark.skipif(sys.version_info >= (3, 11), reason="couroutine removed") -def test_is_generator_asyncio(pytester: Pytester) -> None: - pytester.makepyfile( - """ - from _pytest.compat import is_generator - import asyncio - @asyncio.coroutine - def baz(): - yield from [1,2,3] - - def test_is_generator_asyncio(): - assert not is_generator(baz) - """ - ) - # avoid importing asyncio into pytest's own process, - # which in turn imports logging (#8) - result = pytester.runpytest_subprocess() - result.stdout.fnmatch_lines(["*1 passed*"]) - - -def test_is_generator_async_syntax(pytester: Pytester) -> None: - pytester.makepyfile( - """ - from _pytest.compat import is_generator - def test_is_generator_py35(): - async def foo(): - await foo() - - async def bar(): - pass - - assert not is_generator(foo) - assert not is_generator(bar) - """ - ) - result = pytester.runpytest() - result.stdout.fnmatch_lines(["*1 passed*"]) - - -def test_is_generator_async_gen_syntax(pytester: Pytester) -> None: - pytester.makepyfile( - """ - from _pytest.compat import is_generator - def test_is_generator(): - async def foo(): - yield - await foo() - - async def bar(): - yield - - assert not is_generator(foo) - assert not is_generator(bar) - """ - ) - result = pytester.runpytest() - result.stdout.fnmatch_lines(["*1 passed*"]) - - class ErrorsHelper: @property def raise_baseexception(self): @@ -167,17 +96,17 @@ def raise_fail_outcome(self): def test_helper_failures() -> None: helper = ErrorsHelper() - with pytest.raises(Exception): - helper.raise_exception + with pytest.raises(Exception): # noqa: B017 + _ = helper.raise_exception with pytest.raises(OutcomeException): - helper.raise_fail_outcome + _ = helper.raise_fail_outcome def test_safe_getattr() -> None: helper = ErrorsHelper() assert safe_getattr(helper, "raise_exception", "default") == "default" assert safe_getattr(helper, "raise_fail_outcome", "default") == "default" - with pytest.raises(BaseException): + with pytest.raises(BaseException): # noqa: B017 assert safe_getattr(helper, "raise_baseexception", "default") @@ -214,7 +143,7 @@ def prop(self) -> int: def test_assert_never_union() -> None: - x: Union[int, str] = 10 + x: int | str = 10 if isinstance(x, int): pass diff --git a/testing/test_config.py b/testing/test_config.py index 3aec5d7631f..3e8635fd1fc 100644 --- a/testing/test_config.py +++ b/testing/test_config.py @@ -1,19 +1,18 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + +from collections.abc import Sequence import dataclasses +import importlib.metadata import os +from pathlib import Path +import platform import re import sys import textwrap -from pathlib import Path -from typing import Dict -from typing import List -from typing import Sequence -from typing import Tuple -from typing import Type -from typing import Union +from typing import Any import _pytest._code -import pytest -from _pytest.compat import importlib_metadata from _pytest.config import _get_plugin_specs_as_list from _pytest.config import _iter_rewritable_modules from _pytest.config import _strtobool @@ -21,6 +20,8 @@ from _pytest.config import ConftestImportFailure from _pytest.config import ExitCode from _pytest.config import parse_warning_filter +from _pytest.config.argparsing import get_ini_default_for_type +from _pytest.config.argparsing import Parser from _pytest.config.exceptions import UsageError from _pytest.config.findpaths import determine_setup from _pytest.config.findpaths import get_common_ancestor @@ -28,6 +29,7 @@ from _pytest.monkeypatch import MonkeyPatch from _pytest.pathlib import absolutepath from _pytest.pytester import Pytester +import pytest class TestParseIni: @@ -47,16 +49,14 @@ def test_getcfg_and_config( monkeypatch.chdir(sub) (tmp_path / filename).write_text( textwrap.dedent( - """\ + f"""\ [{section}] name = value - """.format( - section=section - ) + """ ), encoding="utf-8", ) - _, _, cfg = locate_config([sub]) + _, _, cfg = locate_config(Path.cwd(), [sub]) assert cfg["name"] == "value" config = pytester.parseconfigure(str(sub)) assert config.inicfg["name"] == "value" @@ -65,13 +65,12 @@ def test_setupcfg_uses_toolpytest_with_pytest(self, pytester: Pytester) -> None: p1 = pytester.makepyfile("def test(): pass") pytester.makefile( ".cfg", - setup=""" + setup=f""" [tool:pytest] - testpaths=%s + testpaths={p1.name} [pytest] testpaths=ignored - """ - % p1.name, + """, ) result = pytester.runpytest() result.stdout.fnmatch_lines(["configfile: setup.cfg", "* 1 passed in *"]) @@ -122,12 +121,10 @@ def test_tox_ini_wrong_version(self, pytester: Pytester) -> None: def test_ini_names(self, pytester: Pytester, name, section) -> None: pytester.path.joinpath(name).write_text( textwrap.dedent( - """ + f""" [{section}] minversion = 3.36 - """.format( - section=section - ) + """ ), encoding="utf-8", ) @@ -135,15 +132,45 @@ def test_ini_names(self, pytester: Pytester, name, section) -> None: assert config.getini("minversion") == "3.36" def test_pyproject_toml(self, pytester: Pytester) -> None: - pytester.makepyprojecttoml( + pyproject_toml = pytester.makepyprojecttoml( """ [tool.pytest.ini_options] minversion = "1.0" """ ) config = pytester.parseconfig() + assert config.inipath == pyproject_toml assert config.getini("minversion") == "1.0" + def test_empty_pyproject_toml(self, pytester: Pytester) -> None: + """An empty pyproject.toml is considered as config if no other option is found.""" + pyproject_toml = pytester.makepyprojecttoml("") + config = pytester.parseconfig() + assert config.inipath == pyproject_toml + + def test_empty_pyproject_toml_found_many(self, pytester: Pytester) -> None: + """ + In case we find multiple pyproject.toml files in our search, without a [tool.pytest.ini_options] + table and without finding other candidates, the closest to where we started wins. + """ + pytester.makefile( + ".toml", + **{ + "pyproject": "", + "foo/pyproject": "", + "foo/bar/pyproject": "", + }, + ) + config = pytester.parseconfig(pytester.path / "foo/bar") + assert config.inipath == pytester.path / "foo/bar/pyproject.toml" + + def test_pytest_ini_trumps_pyproject_toml(self, pytester: Pytester) -> None: + """A pytest.ini always take precedence over a pyproject.toml file.""" + pytester.makepyprojecttoml("[tool.pytest.ini_options]") + pytest_ini = pytester.makefile(".ini", pytest="") + config = pytester.parseconfig() + assert config.inipath == pytest_ini + def test_toxini_before_lower_pytestini(self, pytester: Pytester) -> None: sub = pytester.mkdir("sub") sub.joinpath("tox.ini").write_text( @@ -187,7 +214,7 @@ def test_toml_parse_error(self, pytester: Pytester) -> None: def test_confcutdir_default_without_configfile(self, pytester: Pytester) -> None: # If --confcutdir is not specified, and there is no configfile, default - # to the roothpath. + # to the rootpath. sub = pytester.mkdir("sub") os.chdir(sub) config = pytester.parseconfigure() @@ -475,7 +502,7 @@ def my_dists(): pytester.makepyfile(myplugin1_module="# my plugin module") pytester.syspathinsert() - monkeypatch.setattr(importlib_metadata, "distributions", my_dists) + monkeypatch.setattr(importlib.metadata, "distributions", my_dists) monkeypatch.delenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD", raising=False) pytester.makeini(ini_file_text) @@ -507,6 +534,24 @@ def pytest_load_initial_conftests(early_config, parser, args): result = pytester.runpytest("--foo=1") result.stdout.fnmatch_lines("* no tests ran in *") + def test_args_source_args(self, pytester: Pytester): + config = pytester.parseconfig("--", "test_filename.py") + assert config.args_source == Config.ArgsSource.ARGS + + def test_args_source_invocation_dir(self, pytester: Pytester): + config = pytester.parseconfig() + assert config.args_source == Config.ArgsSource.INVOCATION_DIR + + def test_args_source_testpaths(self, pytester: Pytester): + pytester.makeini( + """ + [pytest] + testpaths=* + """ + ) + config = pytester.parseconfig() + assert config.args_source == Config.ArgsSource.TESTPATHS + class TestConfigCmdlineParsing: def test_parsing_again_fails(self, pytester: Pytester) -> None: @@ -586,13 +631,13 @@ def test_absolute_win32_path(self, pytester: Pytester) -> None: class TestConfigAPI: def test_config_trace(self, pytester: Pytester) -> None: config = pytester.parseconfig() - values: List[str] = [] + values: list[str] = [] config.trace.root.setwriter(values.append) config.trace("hello") assert len(values) == 1 assert values[0] == "hello [config]\n" - def test_config_getoption(self, pytester: Pytester) -> None: + def test_config_getoption_declared_option_name(self, pytester: Pytester) -> None: pytester.makeconftest( """ def pytest_addoption(parser): @@ -604,6 +649,18 @@ def pytest_addoption(parser): assert config.getoption(x) == "this" pytest.raises(ValueError, config.getoption, "qweqwe") + config_novalue = pytester.parseconfig() + assert config_novalue.getoption("hello") is None + assert config_novalue.getoption("hello", default=1) is None + assert config_novalue.getoption("hello", default=1, skip=True) == 1 + + def test_config_getoption_undeclared_option_name(self, pytester: Pytester) -> None: + config = pytester.parseconfig() + with pytest.raises(ValueError): + config.getoption("x") + assert config.getoption("x", default=1) == 1 + assert config.getoption("x", default=1, skip=True) == 1 + def test_config_getoption_unicode(self, pytester: Pytester) -> None: pytester.makeconftest( """ @@ -631,29 +688,16 @@ def pytest_addoption(parser): with pytest.raises(pytest.skip.Exception): config.getvalueorskip("hello") - def test_getoption(self, pytester: Pytester) -> None: - config = pytester.parseconfig() - with pytest.raises(ValueError): - config.getvalue("x") - assert config.getoption("x", 1) == 1 - def test_getconftest_pathlist(self, pytester: Pytester, tmp_path: Path) -> None: somepath = tmp_path.joinpath("x", "y", "z") p = tmp_path.joinpath("conftest.py") p.write_text(f"mylist = {['.', str(somepath)]}", encoding="utf-8") config = pytester.parseconfigure(p) - assert ( - config._getconftest_pathlist("notexist", path=tmp_path, rootpath=tmp_path) - is None - ) - pl = ( - config._getconftest_pathlist("mylist", path=tmp_path, rootpath=tmp_path) - or [] - ) - print(pl) - assert len(pl) == 2 - assert pl[0] == tmp_path - assert pl[1] == somepath + assert config._getconftest_pathlist("notexist", path=tmp_path) is None + assert config._getconftest_pathlist("mylist", path=tmp_path) == [ + tmp_path, + somepath, + ] @pytest.mark.parametrize("maybe_type", ["not passed", "None", '"string"']) def test_addini(self, pytester: Pytester, maybe_type: str) -> None: @@ -797,15 +841,90 @@ def pytest_addoption(parser): ) if str_val != "no-ini": pytester.makeini( - """ + f""" [pytest] - strip=%s + strip={str_val} """ - % str_val ) config = pytester.parseconfig() assert config.getini("strip") is bool_val + @pytest.mark.parametrize("str_val, int_val", [("10", 10), ("no-ini", 2)]) + def test_addini_int(self, pytester: Pytester, str_val: str, int_val: bool) -> None: + pytester.makeconftest( + """ + def pytest_addoption(parser): + parser.addini("ini_param", "", type="int", default=2) + """ + ) + if str_val != "no-ini": + pytester.makeini( + f""" + [pytest] + ini_param={str_val} + """ + ) + config = pytester.parseconfig() + assert config.getini("ini_param") == int_val + + def test_addini_int_invalid(self, pytester: Pytester) -> None: + pytester.makeconftest( + """ + def pytest_addoption(parser): + parser.addini("ini_param", "", type="int", default=2) + """ + ) + pytester.makepyprojecttoml( + """ + [tool.pytest.ini_options] + ini_param=["foo"] + """ + ) + config = pytester.parseconfig() + with pytest.raises( + TypeError, match="Expected an int string for option ini_param" + ): + _ = config.getini("ini_param") + + @pytest.mark.parametrize("str_val, float_val", [("10.5", 10.5), ("no-ini", 2.2)]) + def test_addini_float( + self, pytester: Pytester, str_val: str, float_val: bool + ) -> None: + pytester.makeconftest( + """ + def pytest_addoption(parser): + parser.addini("ini_param", "", type="float", default=2.2) + """ + ) + if str_val != "no-ini": + pytester.makeini( + f""" + [pytest] + ini_param={str_val} + """ + ) + config = pytester.parseconfig() + assert config.getini("ini_param") == float_val + + def test_addini_float_invalid(self, pytester: Pytester) -> None: + pytester.makeconftest( + """ + def pytest_addoption(parser): + parser.addini("ini_param", "", type="float", default=2.2) + """ + ) + pytester.makepyprojecttoml( + """ + [tool.pytest.ini_options] + ini_param=["foo"] + """ + ) + config = pytester.parseconfig() + with pytest.raises( + TypeError, match="Expected a float string for option ini_param" + ): + _ = config.getini("ini_param") + def test_addinivalue_line_existing(self, pytester: Pytester) -> None: pytester.makeconftest( """ @@ -846,6 +965,67 @@ def pytest_addoption(parser): assert len(values) == 2 assert values == ["456", "123"] + def test_addini_default_values(self, pytester: Pytester) -> None: + """Tests the default values for configuration based on + config type + """ + pytester.makeconftest( + """ + def pytest_addoption(parser): + parser.addini("linelist1", "", type="linelist") + parser.addini("paths1", "", type="paths") + parser.addini("pathlist1", "", type="pathlist") + parser.addini("args1", "", type="args") + parser.addini("bool1", "", type="bool") + parser.addini("string1", "", type="string") + parser.addini("none_1", "", type="linelist", default=None) + parser.addini("none_2", "", default=None) + parser.addini("no_type", "") + """ + ) + + config = pytester.parseconfig() + # default for linelist, paths, pathlist and args is [] + value = config.getini("linelist1") + assert value == [] + value = config.getini("paths1") + assert value == [] + value = config.getini("pathlist1") + assert value == [] + value = config.getini("args1") + assert value == [] + # default for bool is False + value = config.getini("bool1") + assert value is False + # default for string is "" + value = config.getini("string1") + assert value == "" + # should return None if None is explicitly set as default value + # irrespective of the type argument + value = config.getini("none_1") + assert value is None + value = config.getini("none_2") + assert value is None + # in case no type is provided and no default set + # treat it as string and default value will be "" + value = config.getini("no_type") + assert value == "" + + @pytest.mark.parametrize( + "type, expected", + [ + pytest.param(None, "", id="None"), + pytest.param("string", "", id="string"), + pytest.param("paths", [], id="paths"), + pytest.param("pathlist", [], id="pathlist"), + pytest.param("args", [], id="args"), + pytest.param("linelist", [], id="linelist"), + pytest.param("bool", False, id="bool"), + ], + ) + def test_get_ini_default_for_type(self, type: Any, expected: Any) -> None: + assert get_ini_default_for_type(type) == expected + def test_confcutdir_check_isdir(self, pytester: Pytester) -> None: """Give an error if --confcutdir is not a valid directory (#2078)""" exp_match = r"^--confcutdir must be a directory, given: " @@ -880,6 +1060,37 @@ def test_confcutdir_check_isdir(self, pytester: Pytester) -> None: def test_iter_rewritable_modules(self, names, expected) -> None: assert list(_iter_rewritable_modules(names)) == expected + def test_add_cleanup(self, pytester: Pytester) -> None: + config = Config.fromdictargs({}, []) + config._do_configure() + report = [] + + class MyError(BaseException): + pass + + @config.add_cleanup + def cleanup_last(): + report.append("cleanup_last") + + @config.add_cleanup + def raise_2(): + report.append("raise_2") + raise MyError("raise_2") + + @config.add_cleanup + def raise_1(): + report.append("raise_1") + raise MyError("raise_1") + + @config.add_cleanup + def cleanup_first(): + report.append("cleanup_first") + + with pytest.raises(MyError, match=r"raise_2"): + config._ensure_unconfigure() + + assert report == ["cleanup_first", "raise_1", "raise_2", "cleanup_last"] + class TestConfigFromdictargs: def test_basic_behavior(self, _sys_snapshot) -> None: @@ -896,7 +1107,7 @@ def test_basic_behavior(self, _sys_snapshot) -> None: def test_invocation_params_args(self, _sys_snapshot) -> None: """Show that fromdictargs can handle args in their "orig" format""" - option_dict: Dict[str, object] = {} + option_dict: dict[str, object] = {} args = ["-vvvv", "-s", "a", "b"] config = Config.fromdictargs(option_dict, args) @@ -1003,7 +1214,7 @@ class Dist: def my_dists(): return (Dist,) - monkeypatch.setattr(importlib_metadata, "distributions", my_dists) + monkeypatch.setattr(importlib.metadata, "distributions", my_dists) pytester.makeconftest( """ pytest_plugins = "mytestplugin", @@ -1036,7 +1247,7 @@ class Distribution: def distributions(): return (Distribution(),) - monkeypatch.setattr(importlib_metadata, "distributions", distributions) + monkeypatch.setattr(importlib.metadata, "distributions", distributions) with pytest.raises(ImportError): pytester.parseconfig() @@ -1063,7 +1274,7 @@ class Distribution: def distributions(): return (Distribution(),) - monkeypatch.setattr(importlib_metadata, "distributions", distributions) + monkeypatch.setattr(importlib.metadata, "distributions", distributions) pytester.parseconfig() @@ -1091,7 +1302,7 @@ class Distribution: def distributions(): return (Distribution(),) - monkeypatch.setattr(importlib_metadata, "distributions", distributions) + monkeypatch.setattr(importlib.metadata, "distributions", distributions) args = ("-p", "no:mytestplugin") if block_it else () config = pytester.parseconfig(*args) config.pluginmanager.import_plugin("mytestplugin") @@ -1104,14 +1315,13 @@ def distributions(): ) -@pytest.mark.parametrize( - "parse_args,should_load", [(("-p", "mytestplugin"), True), ((), False)] -) +@pytest.mark.parametrize("disable_plugin_method", ["env_var", "flag", ""]) +@pytest.mark.parametrize("enable_plugin_method", ["env_var", "flag", ""]) def test_disable_plugin_autoload( pytester: Pytester, monkeypatch: MonkeyPatch, - parse_args: Union[Tuple[str, str], Tuple[()]], - should_load: bool, + enable_plugin_method: str, + disable_plugin_method: str, ) -> None: class DummyEntryPoint: project_name = name = "mytestplugin" @@ -1132,23 +1342,60 @@ class PseudoPlugin: attrs_used = [] def __getattr__(self, name): - assert name == "__loader__" + assert name in ("__loader__", "__spec__") self.attrs_used.append(name) return object() def distributions(): return (Distribution(),) - monkeypatch.setenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD", "1") - monkeypatch.setattr(importlib_metadata, "distributions", distributions) - monkeypatch.setitem(sys.modules, "mytestplugin", PseudoPlugin()) # type: ignore[misc] + parse_args: list[str] = [] + + if disable_plugin_method == "env_var": + monkeypatch.setenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD", "1") + elif disable_plugin_method == "flag": + monkeypatch.delenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD") + parse_args.append("--disable-plugin-autoload") + else: + assert disable_plugin_method == "" + monkeypatch.delenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD") + + if enable_plugin_method == "env_var": + monkeypatch.setenv("PYTEST_PLUGINS", "mytestplugin") + elif enable_plugin_method == "flag": + parse_args.extend(["-p", "mytestplugin"]) + else: + assert enable_plugin_method == "" + + monkeypatch.setattr(importlib.metadata, "distributions", distributions) + monkeypatch.setitem(sys.modules, "mytestplugin", PseudoPlugin()) config = pytester.parseconfig(*parse_args) + has_loaded = config.pluginmanager.get_plugin("mytestplugin") is not None - assert has_loaded == should_load - if should_load: - assert PseudoPlugin.attrs_used == ["__loader__"] - else: - assert PseudoPlugin.attrs_used == [] + # it should load if it's enabled, or we haven't disabled autoloading + assert has_loaded == (bool(enable_plugin_method) or not disable_plugin_method) + + # The reason for the discrepancy between 'has_loaded' and __loader__ being accessed + # appears to be the monkeypatching of importlib.metadata.distributions; where + # files being empty means that _mark_plugins_for_rewrite doesn't find the plugin. + # But enable_method==flag ends up in mark_rewrite being called and __loader__ + # being accessed. + assert ("__loader__" in PseudoPlugin.attrs_used) == ( + has_loaded + and not (enable_plugin_method in ("env_var", "") and not disable_plugin_method) + ) + + # __spec__ is accessed in AssertionRewritingHook.exec_module, which would be + # eventually called if we did a full pytest run; but it's only accessed with + # enable_plugin_method=="env_var" because that will early-load it. + # Except when autoloads aren't disabled, in which case PytestPluginManager.import_plugin + # bails out before importing it.. because it knows it'll be loaded later? + # The above seems a bit weird, but I *think* it's true. + if platform.python_implementation() != "PyPy": + assert ("__spec__" in PseudoPlugin.attrs_used) == bool( + enable_plugin_method == "env_var" and disable_plugin_method + ) + # __spec__ is present when testing locally on pypy, but not in CI ???? def test_plugin_loading_order(pytester: Pytester) -> None: @@ -1159,8 +1406,7 @@ def test_terminal_plugin(request): import myplugin assert myplugin.terminal_plugin == [False, True] """, - **{ - "myplugin": """ + myplugin=""" terminal_plugin = [] def pytest_configure(config): @@ -1169,25 +1415,13 @@ def pytest_configure(config): def pytest_sessionstart(session): config = session.config terminal_plugin.append(bool(config.pluginmanager.get_plugin("terminalreporter"))) - """ - }, + """, ) pytester.syspathinsert() result = pytester.runpytest("-p", "myplugin", str(p1)) assert result.ret == 0 -def test_cmdline_processargs_simple(pytester: Pytester) -> None: - pytester.makeconftest( - """ - def pytest_cmdline_preparse(args): - args.append("-h") - """ - ) - result = pytester.runpytest() - result.stdout.fnmatch_lines(["*pytest*", "*-h*"]) - - def test_invalid_options_show_extra_information(pytester: Pytester) -> None: """Display extra information when pytest exits due to unrecognized options in the command-line.""" @@ -1201,8 +1435,8 @@ def test_invalid_options_show_extra_information(pytester: Pytester) -> None: result.stderr.fnmatch_lines( [ "*error: unrecognized arguments: --invalid-option*", - "* inifile: %s*" % pytester.path.joinpath("tox.ini"), - "* rootdir: %s*" % pytester.path, + "* inifile: {}*".format(pytester.path.joinpath("tox.ini")), + f"* rootdir: {pytester.path}*", ] ) @@ -1217,7 +1451,7 @@ def test_invalid_options_show_extra_information(pytester: Pytester) -> None: ], ) def test_consider_args_after_options_for_rootdir( - pytester: Pytester, args: List[str] + pytester: Pytester, args: list[str] ) -> None: """ Consider all arguments in the command-line for rootdir @@ -1317,7 +1551,7 @@ def pytest_load_initial_conftests(self): hookimpls = [ ( hookimpl.function.__module__, - "wrapper" if hookimpl.hookwrapper else "nonwrapper", + "wrapper" if (hookimpl.wrapper or hookimpl.hookwrapper) else "nonwrapper", ) for hookimpl in hc.get_hookimpls() ] @@ -1325,7 +1559,6 @@ def pytest_load_initial_conftests(self): ("_pytest.config", "nonwrapper"), (m.__module__, "nonwrapper"), ("_pytest.legacypath", "nonwrapper"), - ("_pytest.python_path", "nonwrapper"), ("_pytest.capture", "wrapper"), ("_pytest.warnings", "wrapper"), ] @@ -1334,8 +1567,8 @@ def pytest_load_initial_conftests(self): def test_get_plugin_specs_as_list() -> None: def exp_match(val: object) -> str: return ( - "Plugins may be specified as a sequence or a ','-separated string of plugin names. Got: %s" - % re.escape(repr(val)) + f"Plugins may be specified as a sequence or a ','-separated string " + f"of plugin names. Got: {re.escape(repr(val))}" ) with pytest.raises(pytest.UsageError, match=exp_match({"foo"})): @@ -1371,16 +1604,16 @@ class pytest_something: class TestRootdir: def test_simple_noini(self, tmp_path: Path, monkeypatch: MonkeyPatch) -> None: - assert get_common_ancestor([tmp_path]) == tmp_path + assert get_common_ancestor(Path.cwd(), [tmp_path]) == tmp_path a = tmp_path / "a" a.mkdir() - assert get_common_ancestor([a, tmp_path]) == tmp_path - assert get_common_ancestor([tmp_path, a]) == tmp_path + assert get_common_ancestor(Path.cwd(), [a, tmp_path]) == tmp_path + assert get_common_ancestor(Path.cwd(), [tmp_path, a]) == tmp_path monkeypatch.chdir(tmp_path) - assert get_common_ancestor([]) == tmp_path + assert get_common_ancestor(Path.cwd(), []) == tmp_path no_path = tmp_path / "does-not-exist" - assert get_common_ancestor([no_path]) == tmp_path - assert get_common_ancestor([no_path / "a"]) == tmp_path + assert get_common_ancestor(Path.cwd(), [no_path]) == tmp_path + assert get_common_ancestor(Path.cwd(), [no_path / "a"]) == tmp_path @pytest.mark.parametrize( "name, contents", @@ -1402,10 +1635,20 @@ def test_with_ini(self, tmp_path: Path, name: str, contents: str) -> None: b = a / "b" b.mkdir() for args in ([str(tmp_path)], [str(a)], [str(b)]): - rootpath, parsed_inipath, _ = determine_setup(None, args) + rootpath, parsed_inipath, _ = determine_setup( + inifile=None, + args=args, + rootdir_cmd_arg=None, + invocation_dir=Path.cwd(), + ) assert rootpath == tmp_path assert parsed_inipath == inipath - rootpath, parsed_inipath, ini_config = determine_setup(None, [str(b), str(a)]) + rootpath, parsed_inipath, ini_config = determine_setup( + inifile=None, + args=[str(b), str(a)], + rootdir_cmd_arg=None, + invocation_dir=Path.cwd(), + ) assert rootpath == tmp_path assert parsed_inipath == inipath assert ini_config == {"x": "10"} @@ -1417,7 +1660,12 @@ def test_pytestini_overrides_empty_other(self, tmp_path: Path, name: str) -> Non a = tmp_path / "a" a.mkdir() (a / name).touch() - rootpath, parsed_inipath, _ = determine_setup(None, [str(a)]) + rootpath, parsed_inipath, _ = determine_setup( + inifile=None, + args=[str(a)], + rootdir_cmd_arg=None, + invocation_dir=Path.cwd(), + ) assert rootpath == tmp_path assert parsed_inipath == inipath @@ -1426,14 +1674,24 @@ def test_setuppy_fallback(self, tmp_path: Path) -> None: a.mkdir() (a / "setup.cfg").touch() (tmp_path / "setup.py").touch() - rootpath, inipath, inicfg = determine_setup(None, [str(a)]) + rootpath, inipath, inicfg = determine_setup( + inifile=None, + args=[str(a)], + rootdir_cmd_arg=None, + invocation_dir=Path.cwd(), + ) assert rootpath == tmp_path assert inipath is None assert inicfg == {} def test_nothing(self, tmp_path: Path, monkeypatch: MonkeyPatch) -> None: monkeypatch.chdir(tmp_path) - rootpath, inipath, inicfg = determine_setup(None, [str(tmp_path)]) + rootpath, inipath, inicfg = determine_setup( + inifile=None, + args=[str(tmp_path)], + rootdir_cmd_arg=None, + invocation_dir=Path.cwd(), + ) assert rootpath == tmp_path assert inipath is None assert inicfg == {} @@ -1455,7 +1713,12 @@ def test_with_specific_inifile( p = tmp_path / name p.touch() p.write_text(contents, encoding="utf-8") - rootpath, inipath, ini_config = determine_setup(str(p), [str(tmp_path)]) + rootpath, inipath, ini_config = determine_setup( + inifile=str(p), + args=[str(tmp_path)], + rootdir_cmd_arg=None, + invocation_dir=Path.cwd(), + ) assert rootpath == tmp_path assert inipath == p assert ini_config == {"x": "10"} @@ -1469,14 +1732,24 @@ def test_explicit_config_file_sets_rootdir( monkeypatch.chdir(tmp_path) # No config file is explicitly given: rootdir is determined to be cwd. - rootpath, found_inipath, *_ = determine_setup(None, [str(tests_dir)]) + rootpath, found_inipath, *_ = determine_setup( + inifile=None, + args=[str(tests_dir)], + rootdir_cmd_arg=None, + invocation_dir=Path.cwd(), + ) assert rootpath == tmp_path assert found_inipath is None # Config file is explicitly given: rootdir is determined to be inifile's directory. inipath = tmp_path / "pytest.ini" inipath.touch() - rootpath, found_inipath, *_ = determine_setup(str(inipath), [str(tests_dir)]) + rootpath, found_inipath, *_ = determine_setup( + inifile=str(inipath), + args=[str(tests_dir)], + rootdir_cmd_arg=None, + invocation_dir=Path.cwd(), + ) assert rootpath == tmp_path assert found_inipath == inipath @@ -1488,7 +1761,12 @@ def test_with_arg_outside_cwd_without_inifile( a.mkdir() b = tmp_path / "b" b.mkdir() - rootpath, inifile, _ = determine_setup(None, [str(a), str(b)]) + rootpath, inifile, _ = determine_setup( + inifile=None, + args=[str(a), str(b)], + rootdir_cmd_arg=None, + invocation_dir=Path.cwd(), + ) assert rootpath == tmp_path assert inifile is None @@ -1499,7 +1777,12 @@ def test_with_arg_outside_cwd_with_inifile(self, tmp_path: Path) -> None: b.mkdir() inipath = a / "pytest.ini" inipath.touch() - rootpath, parsed_inipath, _ = determine_setup(None, [str(a), str(b)]) + rootpath, parsed_inipath, _ = determine_setup( + inifile=None, + args=[str(a), str(b)], + rootdir_cmd_arg=None, + invocation_dir=Path.cwd(), + ) assert rootpath == a assert inipath == parsed_inipath @@ -1508,7 +1791,12 @@ def test_with_non_dir_arg( self, dirs: Sequence[str], tmp_path: Path, monkeypatch: MonkeyPatch ) -> None: monkeypatch.chdir(tmp_path) - rootpath, inipath, _ = determine_setup(None, dirs) + rootpath, inipath, _ = determine_setup( + inifile=None, + args=dirs, + rootdir_cmd_arg=None, + invocation_dir=Path.cwd(), + ) assert rootpath == tmp_path assert inipath is None @@ -1519,7 +1807,12 @@ def test_with_existing_file_in_subdir( a.mkdir() (a / "exists").touch() monkeypatch.chdir(tmp_path) - rootpath, inipath, _ = determine_setup(None, ["a/exist"]) + rootpath, inipath, _ = determine_setup( + inifile=None, + args=["a/exist"], + rootdir_cmd_arg=None, + invocation_dir=Path.cwd(), + ) assert rootpath == tmp_path assert inipath is None @@ -1533,7 +1826,12 @@ def test_with_config_also_in_parent_directory( (tmp_path / "myproject" / "tests").mkdir() monkeypatch.chdir(tmp_path / "myproject") - rootpath, inipath, _ = determine_setup(None, ["tests/"]) + rootpath, inipath, _ = determine_setup( + inifile=None, + args=["tests/"], + rootdir_cmd_arg=None, + invocation_dir=Path.cwd(), + ) assert rootpath == tmp_path / "myproject" assert inipath == tmp_path / "myproject" / "setup.cfg" @@ -1545,11 +1843,9 @@ def test_override_ini_names(self, pytester: Pytester, name: str) -> None: section = "[pytest]" if name != "setup.cfg" else "[tool:pytest]" pytester.path.joinpath(name).write_text( textwrap.dedent( - """ + f""" {section} - custom = 1.0""".format( - section=section - ) + custom = 1.0""" ), encoding="utf-8", ) @@ -1588,7 +1884,7 @@ def pytest_addoption(parser): ) pytester.makepyfile( r""" - def test_overriden(pytestconfig): + def test_overridden(pytestconfig): config_paths = pytestconfig.getini("paths") print(config_paths) for cpf in config_paths: @@ -1685,10 +1981,10 @@ def test_addopts_before_initini( self, monkeypatch: MonkeyPatch, _config_for_test, _sys_snapshot ) -> None: cache_dir = ".custom_cache" - monkeypatch.setenv("PYTEST_ADDOPTS", "-o cache_dir=%s" % cache_dir) + monkeypatch.setenv("PYTEST_ADDOPTS", f"-o cache_dir={cache_dir}") config = _config_for_test config._preparse([], addopts=True) - assert config._override_ini == ["cache_dir=%s" % cache_dir] + assert config._override_ini == [f"cache_dir={cache_dir}"] def test_addopts_from_env_not_concatenated( self, monkeypatch: MonkeyPatch, _config_for_test @@ -1714,8 +2010,8 @@ def test_addopts_from_ini_not_concatenated(self, pytester: Pytester) -> None: result = pytester.runpytest("cache_dir=ignored") result.stderr.fnmatch_lines( [ - "%s: error: argument -o/--override-ini: expected one argument (via addopts config)" - % (pytester._request.config._parser.optparser.prog,) + f"{pytester._request.config._parser.optparser.prog}: error: " + f"argument -o/--override-ini: expected one argument (via addopts config)" ] ) assert result.ret == _pytest.config.ExitCode.USAGE_ERROR @@ -1752,6 +2048,18 @@ def test(): assert "ERROR:" not in result.stderr.str() result.stdout.fnmatch_lines(["collected 1 item", "*= 1 passed in *="]) + def test_override_ini_without_config_file(self, pytester: Pytester) -> None: + pytester.makepyfile(**{"src/override_ini_without_config_file.py": ""}) + pytester.makepyfile( + **{ + "tests/test_override_ini_without_config_file.py": ( + "import override_ini_without_config_file\ndef test(): pass" + ), + } + ) + result = pytester.runpytest("--override-ini", "pythonpath=src") + assert result.parseoutcomes() == {"passed": 1} + def test_help_via_addopts(pytester: Pytester) -> None: pytester.makeini( @@ -1803,8 +2111,8 @@ def pytest_addoption(parser): result.stderr.fnmatch_lines( [ "ERROR: usage: *", - "%s: error: argument --invalid-option-should-allow-for-help: expected one argument" - % (pytester._request.config._parser.optparser.prog,), + f"{pytester._request.config._parser.optparser.prog}: error: " + f"argument --invalid-option-should-allow-for-help: expected one argument", ] ) # Does not display full/default help. @@ -1867,7 +2175,8 @@ class DummyPlugin: plugins = config.invocation_params.plugins assert len(plugins) == 2 assert plugins[0] is plugin - assert type(plugins[1]).__name__ == "Collect" # installed by pytester.inline_run() + # Installed by pytester.inline_run(). + assert type(plugins[1]).__name__ == "PytesterHelperPlugin" # args cannot be None with pytest.raises(TypeError): @@ -1883,24 +2192,15 @@ class DummyPlugin: ], ) def test_config_blocked_default_plugins(pytester: Pytester, plugin: str) -> None: - if plugin == "debugging": - # Fixed in xdist (after 1.27.0). - # https://github.com/pytest-dev/pytest-xdist/pull/422 - try: - import xdist # noqa: F401 - except ImportError: - pass - else: - pytest.skip("does not work with xdist currently") - p = pytester.makepyfile("def test(): pass") - result = pytester.runpytest(str(p), "-pno:%s" % plugin) + result = pytester.runpytest(str(p), f"-pno:{plugin}") if plugin == "python": assert result.ret == ExitCode.USAGE_ERROR result.stderr.fnmatch_lines( [ - "ERROR: found no collectors for */test_config_blocked_default_plugins.py", + "ERROR: not found: */test_config_blocked_default_plugins.py", + "(no match in any of **", ] ) return @@ -1910,7 +2210,7 @@ def test_config_blocked_default_plugins(pytester: Pytester, plugin: str) -> None result.stdout.fnmatch_lines(["* 1 passed in *"]) p = pytester.makepyfile("def test(): assert 0") - result = pytester.runpytest(str(p), "-pno:%s" % plugin) + result = pytester.runpytest(str(p), f"-pno:{plugin}") assert result.ret == ExitCode.TESTS_FAILED if plugin != "terminal": result.stdout.fnmatch_lines(["* 1 failed in *"]) @@ -1972,7 +2272,6 @@ def test_pytest_plugins_in_non_top_level_conftest_unsupported_pyargs( self, pytester: Pytester, use_pyargs: bool ) -> None: """When using --pyargs, do not emit the warning about non-top-level conftest warnings (#4039, #4044)""" - files = { "src/pkg/__init__.py": "", "src/pkg/conftest.py": "", @@ -1987,9 +2286,7 @@ def test_pytest_plugins_in_non_top_level_conftest_unsupported_pyargs( args = ("--pyargs", "pkg") if use_pyargs else () res = pytester.runpytest(*args) assert res.ret == (0 if use_pyargs else 2) - msg = ( - msg - ) = "Defining 'pytest_plugins' in a non-top-level conftest is no longer supported" + msg = "Defining 'pytest_plugins' in a non-top-level conftest is no longer supported" if use_pyargs: assert msg not in res.stdout.str() else: @@ -2052,9 +2349,7 @@ def test_conftest_import_error_repr(tmp_path: Path) -> None: try: raise RuntimeError("some error") except Exception as exc: - assert exc.__traceback__ is not None - exc_info = (type(exc), exc, exc.__traceback__) - raise ConftestImportFailure(path, exc_info) from exc + raise ConftestImportFailure(path, cause=exc) from exc def test_strtobool() -> None: @@ -2093,7 +2388,7 @@ def test_strtobool() -> None: ], ) def test_parse_warning_filter( - arg: str, escape: bool, expected: Tuple[str, str, Type[Warning], str, int] + arg: str, escape: bool, expected: tuple[str, str, type[Warning], str, int] ) -> None: assert parse_warning_filter(arg, escape=escape) == expected @@ -2170,3 +2465,76 @@ def test_debug_help(self, pytester: Pytester) -> None: "*Default: pytestdebug.log.", ] ) + + +class TestVerbosity: + SOME_OUTPUT_TYPE = Config.VERBOSITY_ASSERTIONS + SOME_OUTPUT_VERBOSITY_LEVEL = 5 + + class VerbosityIni: + def pytest_addoption(self, parser: Parser) -> None: + Config._add_verbosity_ini( + parser, TestVerbosity.SOME_OUTPUT_TYPE, help="some help text" + ) + + def test_level_matches_verbose_when_not_specified( + self, pytester: Pytester, tmp_path: Path + ) -> None: + tmp_path.joinpath("pytest.ini").write_text( + textwrap.dedent( + """\ + [pytest] + addopts = --verbose + """ + ), + encoding="utf-8", + ) + pytester.plugins = [TestVerbosity.VerbosityIni()] + + config = pytester.parseconfig(tmp_path) + + assert ( + config.get_verbosity(TestVerbosity.SOME_OUTPUT_TYPE) + == config.option.verbose + ) + + def test_level_matches_verbose_when_not_known_type( + self, pytester: Pytester, tmp_path: Path + ) -> None: + tmp_path.joinpath("pytest.ini").write_text( + textwrap.dedent( + """\ + [pytest] + addopts = --verbose + """ + ), + encoding="utf-8", + ) + pytester.plugins = [TestVerbosity.VerbosityIni()] + + config = pytester.parseconfig(tmp_path) + + assert config.get_verbosity("some fake verbosity type") == config.option.verbose + + def test_level_matches_specified_override( + self, pytester: Pytester, tmp_path: Path + ) -> None: + setting_name = f"verbosity_{TestVerbosity.SOME_OUTPUT_TYPE}" + tmp_path.joinpath("pytest.ini").write_text( + textwrap.dedent( + f"""\ + [pytest] + addopts = --verbose + {setting_name} = {TestVerbosity.SOME_OUTPUT_VERBOSITY_LEVEL} + """ + ), + encoding="utf-8", + ) + pytester.plugins = [TestVerbosity.VerbosityIni()] + + config = pytester.parseconfig(tmp_path) + + assert ( + config.get_verbosity(TestVerbosity.SOME_OUTPUT_TYPE) + == TestVerbosity.SOME_OUTPUT_VERBOSITY_LEVEL + ) diff --git a/testing/test_conftest.py b/testing/test_conftest.py index 4278315078f..bd083574ffc 100644 --- a/testing/test_conftest.py +++ b/testing/test_conftest.py @@ -1,21 +1,20 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + +from collections.abc import Generator +from collections.abc import Sequence import os -import textwrap from pathlib import Path +import textwrap from typing import cast -from typing import Dict -from typing import Generator -from typing import List -from typing import Optional -from typing import Sequence -from typing import Union -import pytest from _pytest.config import ExitCode from _pytest.config import PytestPluginManager from _pytest.monkeypatch import MonkeyPatch from _pytest.pathlib import symlink_or_skip from _pytest.pytester import Pytester from _pytest.tmpdir import TempPathFactory +import pytest def ConftestWithSetinitial(path) -> PytestPluginManager: @@ -26,8 +25,8 @@ def ConftestWithSetinitial(path) -> PytestPluginManager: def conftest_setinitial( conftest: PytestPluginManager, - args: Sequence[Union[str, Path]], - confcutdir: Optional[Path] = None, + args: Sequence[str | Path], + confcutdir: Path | None = None, ) -> None: conftest._set_initial_conftests( args=args, @@ -35,16 +34,16 @@ def conftest_setinitial( noconftest=False, rootpath=Path(args[0]), confcutdir=confcutdir, + invocation_dir=Path.cwd(), importmode="prepend", + consider_namespace_packages=False, ) @pytest.mark.usefixtures("_sys_snapshot") class TestConftestValueAccessGlobal: @pytest.fixture(scope="module", params=["global", "inpackage"]) - def basedir( - self, request, tmp_path_factory: TempPathFactory - ) -> Generator[Path, None, None]: + def basedir(self, request, tmp_path_factory: TempPathFactory) -> Generator[Path]: tmp_path = tmp_path_factory.mktemp("basedir", numbered=True) tmp_path.joinpath("adir/b").mkdir(parents=True) tmp_path.joinpath("adir/conftest.py").write_text( @@ -62,62 +61,67 @@ def basedir( def test_basic_init(self, basedir: Path) -> None: conftest = PytestPluginManager() p = basedir / "adir" - assert ( - conftest._rget_with_confmod("a", p, importmode="prepend", rootpath=basedir)[ - 1 - ] - == 1 + conftest._loadconftestmodules( + p, importmode="prepend", rootpath=basedir, consider_namespace_packages=False ) + assert conftest._rget_with_confmod("a", p)[1] == 1 - def test_immediate_initialiation_and_incremental_are_the_same( + def test_immediate_initialization_and_incremental_are_the_same( self, basedir: Path ) -> None: conftest = PytestPluginManager() assert not len(conftest._dirpath2confmods) - conftest._getconftestmodules( - basedir, importmode="prepend", rootpath=Path(basedir) + conftest._loadconftestmodules( + basedir, + importmode="prepend", + rootpath=basedir, + consider_namespace_packages=False, ) snap1 = len(conftest._dirpath2confmods) assert snap1 == 1 - conftest._getconftestmodules( - basedir / "adir", importmode="prepend", rootpath=basedir + conftest._loadconftestmodules( + basedir / "adir", + importmode="prepend", + rootpath=basedir, + consider_namespace_packages=False, ) assert len(conftest._dirpath2confmods) == snap1 + 1 - conftest._getconftestmodules( - basedir / "b", importmode="prepend", rootpath=basedir + conftest._loadconftestmodules( + basedir / "b", + importmode="prepend", + rootpath=basedir, + consider_namespace_packages=False, ) assert len(conftest._dirpath2confmods) == snap1 + 2 def test_value_access_not_existing(self, basedir: Path) -> None: conftest = ConftestWithSetinitial(basedir) with pytest.raises(KeyError): - conftest._rget_with_confmod( - "a", basedir, importmode="prepend", rootpath=Path(basedir) - ) + conftest._rget_with_confmod("a", basedir) def test_value_access_by_path(self, basedir: Path) -> None: conftest = ConftestWithSetinitial(basedir) adir = basedir / "adir" - assert ( - conftest._rget_with_confmod( - "a", adir, importmode="prepend", rootpath=basedir - )[1] - == 1 + conftest._loadconftestmodules( + adir, + importmode="prepend", + rootpath=basedir, + consider_namespace_packages=False, ) - assert ( - conftest._rget_with_confmod( - "a", adir / "b", importmode="prepend", rootpath=basedir - )[1] - == 1.5 + assert conftest._rget_with_confmod("a", adir)[1] == 1 + conftest._loadconftestmodules( + adir / "b", + importmode="prepend", + rootpath=basedir, + consider_namespace_packages=False, ) + assert conftest._rget_with_confmod("a", adir / "b")[1] == 1.5 def test_value_access_with_confmod(self, basedir: Path) -> None: startdir = basedir / "adir" / "b" startdir.joinpath("xx").mkdir() conftest = ConftestWithSetinitial(startdir) - mod, value = conftest._rget_with_confmod( - "a", startdir, importmode="prepend", rootpath=Path(basedir) - ) + mod, value = conftest._rget_with_confmod("a", startdir) assert value == 1.5 assert mod.__file__ is not None path = Path(mod.__file__) @@ -143,9 +147,7 @@ def test_doubledash_considered(pytester: Pytester) -> None: conf.joinpath("conftest.py").touch() conftest = PytestPluginManager() conftest_setinitial(conftest, [conf.name, conf.name]) - values = conftest._getconftestmodules( - conf, importmode="prepend", rootpath=pytester.path - ) + values = conftest._getconftestmodules(conf) assert len(values) == 1 @@ -168,7 +170,12 @@ def test_conftest_global_import(pytester: Pytester) -> None: import pytest from _pytest.config import PytestPluginManager conf = PytestPluginManager() - mod = conf._importconftest(Path("conftest.py"), importmode="prepend", rootpath=Path.cwd()) + mod = conf._importconftest( + Path("conftest.py"), + importmode="prepend", + rootpath=Path.cwd(), + consider_namespace_packages=False, + ) assert mod.x == 3 import conftest assert conftest is mod, (conftest, mod) @@ -176,7 +183,12 @@ def test_conftest_global_import(pytester: Pytester) -> None: sub.mkdir() subconf = sub / "conftest.py" subconf.write_text("y=4", encoding="utf-8") - mod2 = conf._importconftest(subconf, importmode="prepend", rootpath=Path.cwd()) + mod2 = conf._importconftest( + subconf, + importmode="prepend", + rootpath=Path.cwd(), + consider_namespace_packages=False, + ) assert mod != mod2 assert mod2.y == 4 import conftest @@ -192,26 +204,35 @@ def test_conftestcutdir(pytester: Pytester) -> None: p = pytester.mkdir("x") conftest = PytestPluginManager() conftest_setinitial(conftest, [pytester.path], confcutdir=p) - values = conftest._getconftestmodules( - p, importmode="prepend", rootpath=pytester.path + conftest._loadconftestmodules( + p, + importmode="prepend", + rootpath=pytester.path, + consider_namespace_packages=False, ) + values = conftest._getconftestmodules(p) assert len(values) == 0 - values = conftest._getconftestmodules( - conf.parent, importmode="prepend", rootpath=pytester.path + conftest._loadconftestmodules( + conf.parent, + importmode="prepend", + rootpath=pytester.path, + consider_namespace_packages=False, ) + values = conftest._getconftestmodules(conf.parent) assert len(values) == 0 assert not conftest.has_plugin(str(conf)) # but we can still import a conftest directly - conftest._importconftest(conf, importmode="prepend", rootpath=pytester.path) - values = conftest._getconftestmodules( - conf.parent, importmode="prepend", rootpath=pytester.path + conftest._importconftest( + conf, + importmode="prepend", + rootpath=pytester.path, + consider_namespace_packages=False, ) + values = conftest._getconftestmodules(conf.parent) assert values[0].__file__ is not None assert values[0].__file__.startswith(str(conf)) # and all sub paths get updated properly - values = conftest._getconftestmodules( - p, importmode="prepend", rootpath=pytester.path - ) + values = conftest._getconftestmodules(p) assert len(values) == 1 assert values[0].__file__ is not None assert values[0].__file__.startswith(str(conf)) @@ -221,9 +242,7 @@ def test_conftestcutdir_inplace_considered(pytester: Pytester) -> None: conf = pytester.makeconftest("") conftest = PytestPluginManager() conftest_setinitial(conftest, [conf.parent], confcutdir=conf.parent) - values = conftest._getconftestmodules( - conf.parent, importmode="prepend", rootpath=pytester.path - ) + values = conftest._getconftestmodules(conf.parent) assert len(values) == 1 assert values[0].__file__ is not None assert values[0].__file__.startswith(str(conf)) @@ -257,7 +276,7 @@ def pytest_addoption(parser): ), encoding="utf-8", ) - result = pytester.runpytest("-h", "--confcutdir=%s" % x, x) + result = pytester.runpytest("-h", f"--confcutdir={x}", x) result.stdout.fnmatch_lines(["*--xyz*"]) result.stdout.no_fnmatch_line("*warning: could not load initial*") @@ -357,7 +376,7 @@ def fixture(): """ ), } - pytester.makepyfile(**{"real/%s" % k: v for k, v in source.items()}) + pytester.makepyfile(**{f"real/{k}": v for k, v in source.items()}) # Create a build directory that contains symlinks to actual files # but doesn't symlink actual directories. @@ -373,13 +392,13 @@ def fixture(): @pytest.mark.skipif( os.path.normcase("x") != os.path.normcase("X"), - reason="only relevant for case insensitive file systems", + reason="only relevant for case-insensitive file systems", ) def test_conftest_badcase(pytester: Pytester) -> None: """Check conftest.py loading when directory casing is wrong (#5792).""" pytester.path.joinpath("JenkinsRoot/test").mkdir(parents=True) source = {"setup.py": "", "test/__init__.py": "", "test/conftest.py": ""} - pytester.makepyfile(**{"JenkinsRoot/%s" % k: v for k, v in source.items()}) + pytester.makepyfile(**{f"JenkinsRoot/{k}": v for k, v in source.items()}) os.chdir(pytester.path.joinpath("jenkinsroot/test")) result = pytester.runpytest() @@ -427,16 +446,19 @@ def test_conftest_import_order(pytester: Pytester, monkeypatch: MonkeyPatch) -> ct2 = sub / "conftest.py" ct2.write_text("", encoding="utf-8") - def impct(p, importmode, root): + def impct(p, importmode, root, consider_namespace_packages): return p conftest = PytestPluginManager() conftest._confcutdir = pytester.path monkeypatch.setattr(conftest, "_importconftest", impct) - mods = cast( - List[Path], - conftest._getconftestmodules(sub, importmode="prepend", rootpath=pytester.path), + conftest._loadconftestmodules( + sub, + importmode="prepend", + rootpath=pytester.path, + consider_namespace_packages=False, ) + mods = cast(list[Path], conftest._getconftestmodules(sub)) expected = [ct1, ct2] assert mods == expected @@ -510,7 +532,7 @@ def pytest_addoption(parser): class TestConftestVisibility: - def _setup_tree(self, pytester: Pytester) -> Dict[str, Path]: # for issue616 + def _setup_tree(self, pytester: Pytester) -> dict[str, Path]: # for issue616 # example mostly taken from: # https://mail.python.org/pipermail/pytest-dev/2014-September/002617.html runner = pytester.mkdir("empty") @@ -612,9 +634,9 @@ def test_parsefactories_relative_node_ids( ) -> None: """#616""" dirs = self._setup_tree(pytester) - print("pytest run in cwd: %s" % (dirs[chdir].relative_to(pytester.path))) - print("pytestarg : %s" % testarg) - print("expected pass : %s" % expect_ntests_passed) + print(f"pytest run in cwd: {dirs[chdir].relative_to(pytester.path)}") + print(f"pytestarg : {testarg}") + print(f"expected pass : {expect_ntests_passed}") os.chdir(dirs[chdir]) reprec = pytester.inline_run( testarg, @@ -673,13 +695,13 @@ def out_of_reach(): pass args = [str(src)] if confcutdir: - args = ["--confcutdir=%s" % root.joinpath(confcutdir)] + args = [f"--confcutdir={root.joinpath(confcutdir)}"] result = pytester.runpytest(*args) match = "" if passed: - match += "*%d passed*" % passed + match += f"*{passed} passed*" if error: - match += "*%d error*" % error + match += f"*{error} error*" result.stdout.fnmatch_lines(match) diff --git a/testing/test_debugging.py b/testing/test_debugging.py index eecc1e39fd3..08ebf600253 100644 --- a/testing/test_debugging.py +++ b/testing/test_debugging.py @@ -1,15 +1,13 @@ -import os +# mypy: allow-untyped-defs +from __future__ import annotations + import sys -from typing import List import _pytest._code -import pytest from _pytest.debugging import _validate_usepdb_cls from _pytest.monkeypatch import MonkeyPatch from _pytest.pytester import Pytester - - -_ENVIRON_PYTHONBREAKPOINT = os.environ.get("PYTHONBREAKPOINT", "") +import pytest @pytest.fixture(autouse=True) @@ -32,13 +30,13 @@ def runpdb_and_get_stdout(pytester: Pytester, source: str): def runpdb_and_get_report(pytester: Pytester, source: str): result = runpdb(pytester, source) - reports = result.reprec.getreports("pytest_runtest_logreport") # type: ignore[attr-defined] + reports = result.reprec.getreports("pytest_runtest_logreport") assert len(reports) == 3, reports # setup/call/teardown return reports[1] @pytest.fixture -def custom_pdb_calls() -> List[str]: +def custom_pdb_calls() -> list[str]: called = [] # install dummy debugger class and track which methods were called on it @@ -54,6 +52,16 @@ def reset(self): def interaction(self, *args): called.append("interaction") + # Methods which we copy docstrings to. + def do_debug(self, *args): # pragma: no cover + pass + + def do_continue(self, *args): # pragma: no cover + pass + + def do_quit(self, *args): # pragma: no cover + pass + _pytest._CustomPdb = _CustomPdb # type: ignore return called @@ -77,6 +85,16 @@ def set_trace(self, frame): print("**CustomDebugger**") called.append("set_trace") + # Methods which we copy docstrings to. + def do_debug(self, *args): # pragma: no cover + pass + + def do_continue(self, *args): # pragma: no cover + pass + + def do_quit(self, *args): # pragma: no cover + pass + _pytest._CustomDebugger = _CustomDebugger # type: ignore yield called del _pytest._CustomDebugger # type: ignore @@ -105,7 +123,10 @@ def test_func(): ) assert rep.failed assert len(pdblist) == 1 - tb = _pytest._code.Traceback(pdblist[0][0]) + if sys.version_info < (3, 13): + tb = _pytest._code.Traceback(pdblist[0][0]) + else: + tb = _pytest._code.Traceback(pdblist[0][0].__traceback__) assert tb[-1].name == "test_func" def test_pdb_on_xfail(self, pytester: Pytester, pdblist) -> None: @@ -224,7 +245,7 @@ def test_not_called_due_to_quit(): pass """ ) - child = pytester.spawn_pytest("--pdb %s" % p1) + child = pytester.spawn_pytest(f"--pdb {p1}") child.expect("captured stdout") child.expect("get rekt") child.expect("captured stderr") @@ -249,7 +270,7 @@ def test_1(): assert False """ ) - child = pytester.spawn_pytest("--pdb %s" % p1) + child = pytester.spawn_pytest(f"--pdb {p1}") child.expect("Pdb") output = child.before.decode("utf8") child.sendeof() @@ -286,7 +307,7 @@ def test_1(): assert False """ ) - child = pytester.spawn_pytest("--show-capture=all --pdb -p no:logging %s" % p1) + child = pytester.spawn_pytest(f"--show-capture=all --pdb -p no:logging {p1}") child.expect("get rekt") output = child.before.decode("utf8") assert "captured log" not in output @@ -306,7 +327,7 @@ def test_1(): pytest.raises(ValueError, globalfunc) """ ) - child = pytester.spawn_pytest("--pdb %s" % p1) + child = pytester.spawn_pytest(f"--pdb {p1}") child.expect(".*def test_1") child.expect(".*pytest.raises.*globalfunc") child.expect("Pdb") @@ -323,7 +344,7 @@ def test_pdb_interaction_on_collection_issue181(self, pytester: Pytester) -> Non xxx """ ) - child = pytester.spawn_pytest("--pdb %s" % p1) + child = pytester.spawn_pytest(f"--pdb {p1}") # child.expect(".*import pytest.*") child.expect("Pdb") child.sendline("c") @@ -338,7 +359,7 @@ def pytest_runtest_protocol(): """ ) p1 = pytester.makepyfile("def test_func(): pass") - child = pytester.spawn_pytest("--pdb %s" % p1) + child = pytester.spawn_pytest(f"--pdb {p1}") child.expect("Pdb") # INTERNALERROR is only displayed once via terminal reporter. @@ -372,7 +393,7 @@ def test_pdb_prevent_ConftestImportFailure_hiding_exception( result = pytester.runpytest_subprocess("--pdb", ".") result.stdout.fnmatch_lines(["-> import unknown"]) - @pytest.mark.xfail(reason="#10042") + @pytest.mark.xfail(reason="#10042", strict=False) def test_pdb_interaction_capturing_simple(self, pytester: Pytester) -> None: p1 = pytester.makepyfile( """ @@ -464,7 +485,7 @@ def test_1(capsys, caplog): assert 0 """ ) - child = pytester.spawn_pytest("--pdb %s" % str(p1)) + child = pytester.spawn_pytest(f"--pdb {p1!s}") child.send("caplog.record_tuples\n") child.expect_exact( "[('test_pdb_with_caplog_on_pdb_invocation', 30, 'some_warning')]" @@ -504,7 +525,7 @@ def function_1(): ''' """ ) - child = pytester.spawn_pytest("--doctest-modules --pdb %s" % p1) + child = pytester.spawn_pytest(f"--doctest-modules --pdb {p1}") child.expect("Pdb") assert "UNEXPECTED EXCEPTION: AssertionError()" in child.before.decode("utf8") @@ -531,7 +552,7 @@ def function_1(): ) # NOTE: does not use pytest.set_trace, but Python's patched pdb, # therefore "-s" is required. - child = pytester.spawn_pytest("--doctest-modules --pdb -s %s" % p1) + child = pytester.spawn_pytest(f"--doctest-modules --pdb -s {p1}") child.expect("Pdb") child.sendline("q") rest = child.read().decode("utf8") @@ -541,7 +562,7 @@ def function_1(): assert "BdbQuit" not in rest assert "UNEXPECTED EXCEPTION" not in rest - @pytest.mark.xfail(reason="#10042") + @pytest.mark.xfail(reason="#10042", strict=False) def test_pdb_interaction_capturing_twice(self, pytester: Pytester) -> None: p1 = pytester.makepyfile( """ @@ -577,7 +598,7 @@ def test_1(): assert "1 failed" in rest self.flush(child) - @pytest.mark.xfail(reason="#10042") + @pytest.mark.xfail(reason="#10042", strict=False) def test_pdb_with_injected_do_debug(self, pytester: Pytester) -> None: """Simulates pdbpp, which injects Pdb into do_debug, and uses self.__class__ in do_continue. @@ -624,7 +645,7 @@ def test_1(): pytest.fail("expected_failure") """ ) - child = pytester.spawn_pytest("--pdbcls=mytest:CustomPdb %s" % str(p1)) + child = pytester.spawn_pytest(f"--pdbcls=mytest:CustomPdb {p1!s}") child.expect(r"PDB set_trace \(IO-capturing turned off\)") child.expect(r"\n\(Pdb") child.sendline("debug foo()") @@ -661,7 +682,7 @@ def test_1(): pytest.set_trace() """ ) - child = pytester.spawn_pytest("-s %s" % p1) + child = pytester.spawn_pytest(f"-s {p1}") child.expect(r">>> PDB set_trace >>>") child.expect("Pdb") child.sendline("c") @@ -770,9 +791,13 @@ def test_pdb_used_outside_test(self, pytester: Pytester) -> None: x = 5 """ ) + if sys.version_info[:2] >= (3, 13): + break_line = "pytest.set_trace()" + else: + break_line = "x = 5" child = pytester.spawn(f"{sys.executable} {p1}") - child.expect("x = 5") - child.expect("Pdb") + child.expect_exact(break_line) + child.expect_exact("Pdb") child.sendeof() self.flush(child) @@ -787,9 +812,13 @@ def test_foo(a): pass """ ) + if sys.version_info[:2] >= (3, 13): + break_line = "pytest.set_trace()" + else: + break_line = "x = 5" child = pytester.spawn_pytest(str(p1)) - child.expect("x = 5") - child.expect("Pdb") + child.expect_exact(break_line) + child.expect_exact("Pdb") child.sendeof() self.flush(child) @@ -857,7 +886,7 @@ def test_post_mortem(): self.flush(child) def test_pdb_custom_cls( - self, pytester: Pytester, custom_pdb_calls: List[str] + self, pytester: Pytester, custom_pdb_calls: list[str] ) -> None: p1 = pytester.makepyfile("""xxx """) result = pytester.runpytest_inprocess( @@ -883,7 +912,7 @@ def test_pdb_validate_usepdb_cls(self): assert _validate_usepdb_cls("pdb:DoesNotExist") == ("pdb", "DoesNotExist") def test_pdb_custom_cls_without_pdb( - self, pytester: Pytester, custom_pdb_calls: List[str] + self, pytester: Pytester, custom_pdb_calls: list[str] ) -> None: p1 = pytester.makepyfile("""xxx """) result = pytester.runpytest_inprocess("--pdbcls=_pytest:_CustomPdb", p1) @@ -917,12 +946,73 @@ def test_foo(): """ ) monkeypatch.setenv("PYTHONPATH", str(pytester.path)) - child = pytester.spawn_pytest("--pdbcls=custom_pdb:CustomPdb %s" % str(p1)) + child = pytester.spawn_pytest(f"--pdbcls=custom_pdb:CustomPdb {p1!s}") child.expect("__init__") child.expect("custom set_trace>") self.flush(child) + @pytest.mark.skipif( + sys.version_info < (3, 13), + reason="Navigating exception chains was introduced in 3.13", + ) + def test_pdb_exception_chain_navigation(self, pytester: Pytester) -> None: + p1 = pytester.makepyfile( + """ + def inner_raise(): + is_inner = True + raise RuntimeError("Woops") + + def outer_raise(): + is_inner = False + try: + inner_raise() + except RuntimeError: + raise RuntimeError("Woopsie") + + def test_1(): + outer_raise() + assert True + """ + ) + child = pytester.spawn_pytest(f"--pdb {p1}") + child.expect("Pdb") + child.sendline("is_inner") + child.expect_exact("False") + child.sendline("exceptions 0") + child.sendline("is_inner") + child.expect_exact("True") + child.sendeof() + self.flush(child) + + def test_pdb_wrapped_commands_docstrings(self, pytester: Pytester) -> None: + p1 = pytester.makepyfile( + """ + def test_1(): + assert False + """ + ) + + child = pytester.spawn_pytest(f"--pdb {p1}") + child.expect("Pdb") + + # Verify no undocumented commands + child.sendline("help") + child.expect("Documented commands") + assert "Undocumented commands" not in child.before.decode() + + child.sendline("help continue") + child.expect("Continue execution") + child.expect("Pdb") + + child.sendline("help debug") + child.expect("Enter a recursive debugger") + child.expect("Pdb") + + child.sendline("c") + child.sendeof() + self.flush(child) + class TestDebuggingBreakpoints: @pytest.mark.parametrize("arg", ["--pdb", ""]) @@ -958,7 +1048,10 @@ def test_nothing(): pass result = pytester.runpytest_subprocess(*args) result.stdout.fnmatch_lines(["*1 passed in *"]) - def test_pdb_custom_cls(self, pytester: Pytester, custom_debugger_hook) -> None: + def test_pdb_custom_cls( + self, pytester: Pytester, custom_debugger_hook, monkeypatch: MonkeyPatch + ) -> None: + monkeypatch.delenv("PYTHONBREAKPOINT", raising=False) p1 = pytester.makepyfile( """ def test_nothing(): @@ -1002,11 +1095,10 @@ def test_nothing(): pass result = pytester.runpytest_subprocess(*args) result.stdout.fnmatch_lines(["*1 passed in *"]) - @pytest.mark.skipif( - not _ENVIRON_PYTHONBREAKPOINT == "", - reason="Requires breakpoint() default value", - ) - def test_sys_breakpoint_interception(self, pytester: Pytester) -> None: + def test_sys_breakpoint_interception( + self, pytester: Pytester, monkeypatch: MonkeyPatch + ) -> None: + monkeypatch.delenv("PYTHONBREAKPOINT", raising=False) p1 = pytester.makepyfile( """ def test_1(): @@ -1022,7 +1114,7 @@ def test_1(): assert "reading from stdin while output" not in rest TestPDB.flush(child) - @pytest.mark.xfail(reason="#10042") + @pytest.mark.xfail(reason="#10042", strict=False) def test_pdb_not_altered(self, pytester: Pytester) -> None: p1 = pytester.makepyfile( """ @@ -1123,7 +1215,7 @@ def test_func_kw(myparam, request, func="func_kw"): def test_trace_after_runpytest(pytester: Pytester) -> None: - """Test that debugging's pytest_configure is re-entrant.""" + """Test that debugging's pytest_configure is reentrant.""" p1 = pytester.makepyfile( """ from _pytest.debugging import pytestPDB @@ -1154,7 +1246,7 @@ def test_inner(): def test_quit_with_swallowed_SystemExit(pytester: Pytester) -> None: - """Test that debugging's pytest_configure is re-entrant.""" + """Test that debugging's pytest_configure is reentrant.""" p1 = pytester.makepyfile( """ def call_pdb_set_trace(): @@ -1182,11 +1274,11 @@ def test_2(): @pytest.mark.parametrize("fixture", ("capfd", "capsys")) -@pytest.mark.xfail(reason="#10042") +@pytest.mark.xfail(reason="#10042", strict=False) def test_pdb_suspends_fixture_capturing(pytester: Pytester, fixture: str) -> None: """Using "-s" with pytest should suspend/resume fixture capturing.""" p1 = pytester.makepyfile( - """ + f""" def test_inner({fixture}): import sys @@ -1201,9 +1293,7 @@ def test_inner({fixture}): out, err = {fixture}.readouterr() assert out =="out_inner_before\\nout_inner_after\\n" assert err =="err_inner_before\\nerr_inner_after\\n" - """.format( - fixture=fixture - ) + """ ) child = pytester.spawn_pytest(str(p1) + " -s") @@ -1211,8 +1301,7 @@ def test_inner({fixture}): child.expect("Pdb") before = child.before.decode("utf8") assert ( - "> PDB set_trace (IO-capturing turned off for fixture %s) >" % (fixture) - in before + f"> PDB set_trace (IO-capturing turned off for fixture {fixture}) >" in before ) # Test that capturing is really suspended. @@ -1228,7 +1317,7 @@ def test_inner({fixture}): TestPDB.flush(child) assert child.exitstatus == 0 assert "= 1 passed in" in rest - assert "> PDB continue (IO-capturing resumed for fixture %s) >" % (fixture) in rest + assert f"> PDB continue (IO-capturing resumed for fixture {fixture}) >" in rest def test_pdbcls_via_local_module(pytester: Pytester) -> None: @@ -1247,6 +1336,16 @@ def set_trace(self, *args): def runcall(self, *args, **kwds): print("runcall_called", args, kwds) + + # Methods which we copy the docstring over. + def do_debug(self, *args): + pass + + def do_continue(self, *args): + pass + + def do_quit(self, *args): + pass """, ) result = pytester.runpytest( @@ -1274,9 +1373,12 @@ def runcall(self, *args, **kwds): result.stdout.fnmatch_lines(["*runcall_called*", "* 1 passed in *"]) +@pytest.mark.xfail( + sys.version_info >= (3, 14), + reason="C-D now quits the test session, rather than failing the test. See https://github.com/python/cpython/issues/124703", +) def test_raises_bdbquit_with_eoferror(pytester: Pytester) -> None: """It is not guaranteed that DontReadFromInput's read is called.""" - p1 = pytester.makepyfile( """ def input_without_read(*args, **kwargs): @@ -1289,6 +1391,7 @@ def test(monkeypatch): """ ) result = pytester.runpytest(str(p1)) + result.assert_outcomes(failed=1) result.stdout.fnmatch_lines(["E *BdbQuit", "*= 1 failed in*"]) assert result.ret == 1 @@ -1314,6 +1417,16 @@ def __init__(self, *args, **kwargs): def set_trace(self, *args): print("set_trace_called", args) + + # Methods which we copy the docstring over. + def do_debug(self, *args): + pass + + def do_continue(self, *args): + pass + + def do_quit(self, *args): + pass """, ) result = pytester.runpytest(str(p1), "--pdbcls=mypdb:MyPdb", syspathinsert=True) diff --git a/testing/test_doctest.py b/testing/test_doctest.py index dfe569987ca..f4ed976c839 100644 --- a/testing/test_doctest.py +++ b/testing/test_doctest.py @@ -1,11 +1,12 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + +from collections.abc import Callable import inspect +from pathlib import Path import sys import textwrap -from pathlib import Path -from typing import Callable -from typing import Optional -import pytest from _pytest.doctest import _get_checker from _pytest.doctest import _is_main_py from _pytest.doctest import _is_mocked @@ -15,6 +16,7 @@ from _pytest.doctest import DoctestModule from _pytest.doctest import DoctestTextfile from _pytest.pytester import Pytester +import pytest class TestDoctests: @@ -114,14 +116,14 @@ def test_simple_doctestfile(self, pytester: Pytester): reprec.assertoutcome(failed=1) def test_importmode(self, pytester: Pytester): - p = pytester.makepyfile( + pytester.makepyfile( **{ - "namespacepkg/innerpkg/__init__.py": "", - "namespacepkg/innerpkg/a.py": """ + "src/namespacepkg/innerpkg/__init__.py": "", + "src/namespacepkg/innerpkg/a.py": """ def some_func(): return 42 """, - "namespacepkg/innerpkg/b.py": """ + "src/namespacepkg/innerpkg/b.py": """ from namespacepkg.innerpkg.a import some_func def my_func(): ''' @@ -132,7 +134,11 @@ def my_func(): """, } ) - reprec = pytester.inline_run(p, "--doctest-modules", "--import-mode=importlib") + # For 'namespacepkg' to be considered a namespace package, its containing directory + # needs to be reachable from sys.path: + # https://packaging.python.org/en/latest/guides/packaging-namespace-packages + pytester.syspathinsert(pytester.path / "src") + reprec = pytester.inline_run("--doctest-modules", "--import-mode=importlib") reprec.assertoutcome(passed=1) def test_new_pattern(self, pytester: Pytester): @@ -182,19 +188,15 @@ def test_multiple_patterns(self, pytester: Pytester): def test_encoding(self, pytester, test_string, encoding): """Test support for doctest_encoding ini option.""" pytester.makeini( - """ + f""" [pytest] - doctest_encoding={} - """.format( - encoding - ) - ) - doctest = """ - >>> "{}" - {} - """.format( - test_string, repr(test_string) + doctest_encoding={encoding} + """ ) + doctest = f""" + >>> "{test_string}" + {test_string!r} + """ fn = pytester.path / "test_encoding.txt" fn.write_text(doctest, encoding=encoding) @@ -221,13 +223,12 @@ def test_doctest_unexpected_exception(self, pytester: Pytester): "002 >>> 0 / i", "UNEXPECTED EXCEPTION: ZeroDivisionError*", "Traceback (most recent call last):", - ' File "*/doctest.py", line *, in __run', - " *", *( - (" *^^^^*",) - if (3, 11, 0, "beta", 4) > sys.version_info >= (3, 11) + (' File "*/doctest.py", line *, in __run', " *") + if sys.version_info <= (3, 14) else () ), + *((" *^^^^*", " *", " *") if sys.version_info[:2] == (3, 13) else ()), ' File "", line 1, in ', "ZeroDivisionError: division by zero", "*/test_doctest_unexpected_exception.txt:2: UnexpectedException", @@ -384,7 +385,7 @@ def some_property(self): "*= FAILURES =*", "*_ [[]doctest[]] test_doctest_linedata_on_property.Sample.some_property _*", "004 ", - "005 >>> Sample().some_property", + "005 *>>> Sample().some_property", "Expected:", " 'another thing'", "Got:", @@ -395,7 +396,7 @@ def some_property(self): ] ) - def test_doctest_no_linedata_on_overriden_property(self, pytester: Pytester): + def test_doctest_no_linedata_on_overridden_property(self, pytester: Pytester): pytester.makepyfile( """ class Sample(object): @@ -413,7 +414,7 @@ def some_property(self): result.stdout.fnmatch_lines( [ "*= FAILURES =*", - "*_ [[]doctest[]] test_doctest_no_linedata_on_overriden_property.Sample.some_property _*", + "*_ [[]doctest[]] test_doctest_no_linedata_on_overridden_property.Sample.some_property _*", "EXAMPLE LOCATION UNKNOWN, not showing all tests of that example", "[?][?][?] >>> Sample().some_property", "Expected:", @@ -421,7 +422,7 @@ def some_property(self): "Got:", " 'something'", "", - "*/test_doctest_no_linedata_on_overriden_property.py:None: DocTestFailure", + "*/test_doctest_no_linedata_on_overridden_property.py:None: DocTestFailure", "*= 1 failed in *", ] ) @@ -482,6 +483,24 @@ def test_doctestmodule(self, pytester: Pytester): reprec = pytester.inline_run(p, "--doctest-modules") reprec.assertoutcome(failed=1) + def test_doctest_cached_property(self, pytester: Pytester): + p = pytester.makepyfile( + """ + import functools + + class Foo: + @functools.cached_property + def foo(self): + ''' + >>> assert False, "Tacos!" + ''' + ... + """ + ) + result = pytester.runpytest(p, "--doctest-modules") + result.assert_outcomes(failed=1) + assert "Tacos!" in result.stdout.str() + def test_doctestmodule_external_and_issue116(self, pytester: Pytester): p = pytester.mkpydir("hello") p.joinpath("__init__.py").write_text( @@ -714,7 +733,7 @@ def foo(): >>> name = 'с' # not letter 'c' but instead Cyrillic 's'. 'anything' """ - ''' + ''' # noqa: RUF001 ) result = pytester.runpytest("--doctest-modules") result.stdout.fnmatch_lines(["Got nothing", "* 1 failed in*"]) @@ -863,6 +882,25 @@ def test_foo(): result = pytester.runpytest(p, "--doctest-modules") result.stdout.fnmatch_lines(["*collected 1 item*"]) + def test_setup_module(self, pytester: Pytester) -> None: + """Regression test for #12011 - setup_module not executed when running + with `--doctest-modules`.""" + pytester.makepyfile( + """ + CONSTANT = 0 + + def setup_module(): + global CONSTANT + CONSTANT = 1 + + def test(): + assert CONSTANT == 1 + """ + ) + result = pytester.runpytest("--doctest-modules") + assert result.ret == 0 + result.assert_outcomes(passed=1) + class TestLiterals: @pytest.mark.parametrize("config_mode", ["ini", "comment"]) @@ -883,23 +921,19 @@ def test_allow_unicode(self, pytester, config_mode): comment = "#doctest: +ALLOW_UNICODE" pytester.maketxtfile( - test_doc=""" + test_doc=f""" >>> b'12'.decode('ascii') {comment} '12' - """.format( - comment=comment - ) + """ ) pytester.makepyfile( - foo=""" + foo=f""" def foo(): ''' >>> b'12'.decode('ascii') {comment} '12' ''' - """.format( - comment=comment - ) + """ ) reprec = pytester.inline_run("--doctest-modules") reprec.assertoutcome(passed=2) @@ -922,23 +956,19 @@ def test_allow_bytes(self, pytester, config_mode): comment = "#doctest: +ALLOW_BYTES" pytester.maketxtfile( - test_doc=""" + test_doc=f""" >>> b'foo' {comment} 'foo' - """.format( - comment=comment - ) + """ ) pytester.makepyfile( - foo=""" + foo=f""" def foo(): ''' >>> b'foo' {comment} 'foo' ''' - """.format( - comment=comment - ) + """ ) reprec = pytester.inline_run("--doctest-modules") reprec.assertoutcome(passed=2) @@ -1015,7 +1045,7 @@ def test_number_precision(self, pytester, config_mode): comment = "#doctest: +NUMBER" pytester.maketxtfile( - test_doc=""" + test_doc=f""" Scalars: @@ -1067,9 +1097,7 @@ def test_number_precision(self, pytester, config_mode): >>> 'abc' {comment} 'abc' >>> None {comment} - """.format( - comment=comment - ) + """ ) reprec = pytester.inline_run() reprec.assertoutcome(passed=1) @@ -1097,12 +1125,10 @@ def test_number_precision(self, pytester, config_mode): ) def test_number_non_matches(self, pytester, expression, output): pytester.maketxtfile( - test_doc=""" + test_doc=f""" >>> {expression} #doctest: +NUMBER {output} - """.format( - expression=expression, output=output - ) + """ ) reprec = pytester.inline_run() reprec.assertoutcome(passed=0, failed=1) @@ -1134,7 +1160,7 @@ def makeit(doctest): pytester.maketxtfile(doctest) else: assert mode == "module" - pytester.makepyfile('"""\n%s"""' % doctest) + pytester.makepyfile(f'"""\n{doctest}"""') return makeit @@ -1283,15 +1309,13 @@ def test_fixture_scopes(self, pytester, scope, enable_doctest): See #1057 and #1100. """ pytester.makeconftest( - """ + f""" import pytest @pytest.fixture(autouse=True, scope="{scope}") def auto(request): return 99 - """.format( - scope=scope - ) + """ ) pytester.makepyfile( test_1=''' @@ -1307,7 +1331,7 @@ def test_bar(): params = ("--doctest-modules",) if enable_doctest else () passes = 3 if enable_doctest else 2 result = pytester.runpytest(*params) - result.stdout.fnmatch_lines(["*=== %d passed in *" % passes]) + result.stdout.fnmatch_lines([f"*=== {passes} passed in *"]) @pytest.mark.parametrize("scope", SCOPES) @pytest.mark.parametrize("autouse", [True, False]) @@ -1319,15 +1343,13 @@ def test_fixture_module_doctest_scopes( See #1057 and #1100. """ pytester.makeconftest( - """ + f""" import pytest @pytest.fixture(autouse={autouse}, scope="{scope}") def auto(request): return 99 - """.format( - scope=scope, autouse=autouse - ) + """ ) if use_fixture_in_doctest: pytester.maketxtfile( @@ -1353,7 +1375,7 @@ def test_auto_use_request_attributes(self, pytester, scope): behave as expected when requested for a doctest item. """ pytester.makeconftest( - """ + f""" import pytest @pytest.fixture(autouse=True, scope="{scope}") @@ -1365,9 +1387,7 @@ def auto(request): if "{scope}" == 'function': assert request.function is None return 99 - """.format( - scope=scope - ) + """ ) pytester.maketxtfile( test_doc=""" @@ -1379,6 +1399,38 @@ def auto(request): str(result.stdout.no_fnmatch_line("*FAILURES*")) result.stdout.fnmatch_lines(["*=== 1 passed in *"]) + @pytest.mark.parametrize("scope", [*SCOPES, "package"]) + def test_auto_use_defined_in_same_module( + self, pytester: Pytester, scope: str + ) -> None: + """Autouse fixtures defined in the same module as the doctest get picked + up properly. + + Regression test for #11929. + """ + pytester.makepyfile( + f""" + import pytest + + AUTO = "the fixture did not run" + + @pytest.fixture(autouse=True, scope="{scope}") + def auto(request): + global AUTO + AUTO = "the fixture ran" + + def my_doctest(): + '''My doctest. + + >>> my_doctest() + 'the fixture ran' + ''' + return AUTO + """ + ) + result = pytester.runpytest("--doctest-modules") + result.assert_outcomes(passed=1) + class TestDoctestNamespaceFixture: SCOPES = ["module", "session", "class", "function"] @@ -1390,16 +1442,14 @@ def test_namespace_doctestfile(self, pytester, scope): simple text file doctest """ pytester.makeconftest( - """ + f""" import pytest import contextlib @pytest.fixture(autouse=True, scope="{scope}") def add_contextlib(doctest_namespace): doctest_namespace['cl'] = contextlib - """.format( - scope=scope - ) + """ ) p = pytester.maketxtfile( """ @@ -1417,16 +1467,14 @@ def test_namespace_pyfile(self, pytester, scope): simple Python file docstring doctest """ pytester.makeconftest( - """ + f""" import pytest import contextlib @pytest.fixture(autouse=True, scope="{scope}") def add_contextlib(doctest_namespace): doctest_namespace['cl'] = contextlib - """.format( - scope=scope - ) + """ ) p = pytester.makepyfile( """ @@ -1529,16 +1577,14 @@ def test_doctest_report_invalid(self, pytester: Pytester): def test_doctest_mock_objects_dont_recurse_missbehaved(mock_module, pytester: Pytester): pytest.importorskip(mock_module) pytester.makepyfile( - """ + f""" from {mock_module} import call class Example(object): ''' >>> 1 + 1 2 ''' - """.format( - mock_module=mock_module - ) + """ ) result = pytester.runpytest("--doctest-modules") result.stdout.fnmatch_lines(["* 1 passed *"]) @@ -1553,7 +1599,7 @@ def __getattr__(self, _): "stop", [None, _is_mocked, lambda f: None, lambda f: False, lambda f: True] ) def test_warning_on_unwrap_of_broken_object( - stop: Optional[Callable[[object], object]] + stop: Callable[[object], object] | None, ) -> None: bad_instance = Broken() assert inspect.unwrap.__module__ == "inspect" @@ -1587,7 +1633,7 @@ def test_is_setup_py_different_encoding(tmp_path: Path, mod: str) -> None: setup_py = tmp_path.joinpath("setup.py") contents = ( "# -*- coding: cp1252 -*-\n" - 'from {} import setup; setup(name="foo", description="€")\n'.format(mod) + f'from {mod} import setup; setup(name="foo", description="€")\n' ) setup_py.write_bytes(contents.encode("cp1252")) assert _is_setup_py(setup_py) diff --git a/testing/test_entry_points.py b/testing/test_entry_points.py index 5d003127363..543f3252b22 100644 --- a/testing/test_entry_points.py +++ b/testing/test_entry_points.py @@ -1,7 +1,10 @@ -from _pytest.compat import importlib_metadata +# mypy: allow-untyped-defs +from __future__ import annotations + +import importlib.metadata def test_pytest_entry_points_are_identical(): - dist = importlib_metadata.distribution("pytest") + dist = importlib.metadata.distribution("pytest") entry_map = {ep.name: ep for ep in dist.entry_points} assert entry_map["pytest"].value == entry_map["py.test"].value diff --git a/testing/test_error_diffs.py b/testing/test_error_diffs.py index eb781210872..741a6ca82d0 100644 --- a/testing/test_error_diffs.py +++ b/testing/test_error_diffs.py @@ -4,8 +4,11 @@ See https://github.com/pytest-dev/pytest/issues/3333 for details. """ -import pytest + +from __future__ import annotations + from _pytest.pytester import Pytester +import pytest TESTCASES = [ @@ -21,10 +24,14 @@ def test_this(): E assert [1, 4, 3] == [1, 2, 3] E At index 1 diff: 4 != 2 E Full diff: - E - [1, 2, 3] + E [ + E 1, + E - 2, E ? ^ - E + [1, 4, 3] + E + 4, E ? ^ + E 3, + E ] """, id="Compare lists, one item differs", ), @@ -40,9 +47,11 @@ def test_this(): E assert [1, 2, 3] == [1, 2] E Left contains one more item: 3 E Full diff: - E - [1, 2] - E + [1, 2, 3] - E ? +++ + E [ + E 1, + E 2, + E + 3, + E ] """, id="Compare lists, one extra item", ), @@ -59,9 +68,11 @@ def test_this(): E At index 1 diff: 3 != 2 E Right contains one more item: 3 E Full diff: - E - [1, 2, 3] - E ? --- - E + [1, 3] + E [ + E 1, + E - 2, + E 3, + E ] """, id="Compare lists, one item missing", ), @@ -77,10 +88,14 @@ def test_this(): E assert (1, 4, 3) == (1, 2, 3) E At index 1 diff: 4 != 2 E Full diff: - E - (1, 2, 3) + E ( + E 1, + E - 2, E ? ^ - E + (1, 4, 3) + E + 4, E ? ^ + E 3, + E ) """, id="Compare tuples", ), @@ -99,10 +114,12 @@ def test_this(): E Extra items in the right set: E 2 E Full diff: - E - {1, 2, 3} - E ? ^ ^ - E + {1, 3, 4} - E ? ^ ^ + E { + E 1, + E - 2, + E 3, + E + 4, + E } """, id="Compare sets", ), @@ -123,10 +140,13 @@ def test_this(): E Right contains 1 more item: E {2: 'eggs'} E Full diff: - E - {1: 'spam', 2: 'eggs'} - E ? ^ - E + {1: 'spam', 3: 'eggs'} - E ? ^ + E { + E 1: 'spam', + E - 2: 'eggs', + E ? ^ + E + 3: 'eggs', + E ? ^ + E } """, id="Compare dicts with differing keys", ), @@ -145,10 +165,11 @@ def test_this(): E Differing items: E {2: 'eggs'} != {2: 'bacon'} E Full diff: - E - {1: 'spam', 2: 'bacon'} - E ? ^^^^^ - E + {1: 'spam', 2: 'eggs'} - E ? ^^^^ + E { + E 1: 'spam', + E - 2: 'bacon', + E + 2: 'eggs', + E } """, id="Compare dicts with differing values", ), @@ -169,10 +190,11 @@ def test_this(): E Right contains 1 more item: E {3: 'bacon'} E Full diff: - E - {1: 'spam', 3: 'bacon'} - E ? ^ ^^^^^ - E + {1: 'spam', 2: 'eggs'} - E ? ^ ^^^^ + E { + E 1: 'spam', + E - 3: 'bacon', + E + 2: 'eggs', + E } """, id="Compare dicts with differing items", ), diff --git a/testing/test_faulthandler.py b/testing/test_faulthandler.py index 5b7911f21f8..d89cb274b78 100644 --- a/testing/test_faulthandler.py +++ b/testing/test_faulthandler.py @@ -1,8 +1,11 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + import io import sys -import pytest from _pytest.pytester import Pytester +import pytest def test_enabled(pytester: Pytester) -> None: @@ -68,6 +71,7 @@ def test_disabled(): assert result.ret == 0 +@pytest.mark.keep_ci_var @pytest.mark.parametrize( "enabled", [ @@ -100,7 +104,7 @@ def test_timeout(): result = pytester.runpytest_subprocess(*args) tb_output = "most recent call first" if enabled: - result.stderr.fnmatch_lines(["*%s*" % tb_output]) + result.stderr.fnmatch_lines([f"*{tb_output}*"]) else: assert tb_output not in result.stderr.str() result.stdout.fnmatch_lines(["*1 passed*"]) @@ -113,6 +117,7 @@ def test_cancel_timeout_on_hook(monkeypatch, hook_name) -> None: to timeout before entering pdb (pytest-dev/pytest-faulthandler#12) or any other interactive exception (pytest-dev/pytest-faulthandler#14).""" import faulthandler + from _pytest import faulthandler as faulthandler_plugin called = [] diff --git a/testing/test_findpaths.py b/testing/test_findpaths.py index 8287de603ed..9532f1eef75 100644 --- a/testing/test_findpaths.py +++ b/testing/test_findpaths.py @@ -1,13 +1,16 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + import os from pathlib import Path from textwrap import dedent -import pytest from _pytest.config import UsageError from _pytest.config.findpaths import get_common_ancestor from _pytest.config.findpaths import get_dirs_from_args from _pytest.config.findpaths import is_fs_root from _pytest.config.findpaths import load_config_dict_from_file +import pytest class TestLoadConfigDictFromFile: @@ -109,18 +112,19 @@ def test_has_ancestor(self, tmp_path: Path) -> None: fn2 = tmp_path / "foo" / "zaz" / "test_2.py" fn2.parent.mkdir(parents=True) fn2.touch() - assert get_common_ancestor([fn1, fn2]) == tmp_path / "foo" - assert get_common_ancestor([fn1.parent, fn2]) == tmp_path / "foo" - assert get_common_ancestor([fn1.parent, fn2.parent]) == tmp_path / "foo" - assert get_common_ancestor([fn1, fn2.parent]) == tmp_path / "foo" + cwd = Path.cwd() + assert get_common_ancestor(cwd, [fn1, fn2]) == tmp_path / "foo" + assert get_common_ancestor(cwd, [fn1.parent, fn2]) == tmp_path / "foo" + assert get_common_ancestor(cwd, [fn1.parent, fn2.parent]) == tmp_path / "foo" + assert get_common_ancestor(cwd, [fn1, fn2.parent]) == tmp_path / "foo" def test_single_dir(self, tmp_path: Path) -> None: - assert get_common_ancestor([tmp_path]) == tmp_path + assert get_common_ancestor(Path.cwd(), [tmp_path]) == tmp_path def test_single_file(self, tmp_path: Path) -> None: fn = tmp_path / "foo.py" fn.touch() - assert get_common_ancestor([fn]) == tmp_path + assert get_common_ancestor(Path.cwd(), [fn]) == tmp_path def test_get_dirs_from_args(tmp_path): diff --git a/testing/test_helpconfig.py b/testing/test_helpconfig.py index ba89d0c4acf..dc7e709b65d 100644 --- a/testing/test_helpconfig.py +++ b/testing/test_helpconfig.py @@ -1,19 +1,24 @@ -import pytest +# mypy: allow-untyped-defs +from __future__ import annotations + from _pytest.config import ExitCode from _pytest.pytester import Pytester +import pytest def test_version_verbose(pytester: Pytester, pytestconfig, monkeypatch) -> None: monkeypatch.delenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD") + monkeypatch.delenv("PYTEST_PLUGINS", raising=False) result = pytester.runpytest("--version", "--version") assert result.ret == 0 result.stdout.fnmatch_lines([f"*pytest*{pytest.__version__}*imported from*"]) if pytestconfig.pluginmanager.list_plugin_distinfo(): - result.stdout.fnmatch_lines(["*setuptools registered plugins:", "*at*"]) + result.stdout.fnmatch_lines(["*registered third-party plugins:", "*at*"]) def test_version_less_verbose(pytester: Pytester, pytestconfig, monkeypatch) -> None: monkeypatch.delenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD") + monkeypatch.delenv("PYTEST_PLUGINS", raising=False) result = pytester.runpytest("--version") assert result.ret == 0 result.stdout.fnmatch_lines([f"pytest {pytest.__version__}"]) diff --git a/testing/test_junitxml.py b/testing/test_junitxml.py index 690830329c0..504e4969039 100644 --- a/testing/test_junitxml.py +++ b/testing/test_junitxml.py @@ -1,18 +1,17 @@ -import os -import platform +from __future__ import annotations + from datetime import datetime +from datetime import timezone +import os from pathlib import Path +import platform +from typing import Any from typing import cast -from typing import List -from typing import Optional -from typing import Tuple from typing import TYPE_CHECKING -from typing import Union from xml.dom import minidom import xmlschema -import pytest from _pytest.config import Config from _pytest.junitxml import bin_xml_escape from _pytest.junitxml import LogXML @@ -22,6 +21,8 @@ from _pytest.reports import BaseReport from _pytest.reports import TestReport from _pytest.stash import Stash +import _pytest.timing +import pytest @pytest.fixture(scope="session") @@ -38,17 +39,17 @@ def __init__(self, pytester: Pytester, schema: xmlschema.XMLSchema) -> None: self.schema = schema def __call__( - self, *args: Union[str, "os.PathLike[str]"], family: Optional[str] = "xunit1" - ) -> Tuple[RunResult, "DomNode"]: + self, *args: str | os.PathLike[str], family: str | None = "xunit1" + ) -> tuple[RunResult, DomDocument]: if family: - args = ("-o", "junit_family=" + family) + args + args = ("-o", "junit_family=" + family, *args) xml_path = self.pytester.path.joinpath("junit.xml") - result = self.pytester.runpytest("--junitxml=%s" % xml_path, *args) + result = self.pytester.runpytest(f"--junitxml={xml_path}", *args) if family == "xunit2": with xml_path.open(encoding="utf-8") as f: self.schema.validate(f) xmldoc = minidom.parse(str(xml_path)) - return result, DomNode(xmldoc) + return result, DomDocument(xmldoc) @pytest.fixture @@ -62,78 +63,129 @@ def run_and_parse(pytester: Pytester, schema: xmlschema.XMLSchema) -> RunAndPars return RunAndParse(pytester, schema) -def assert_attr(node, **kwargs): +def assert_attr(node: minidom.Element, **kwargs: object) -> None: __tracebackhide__ = True - def nodeval(node, name): + def nodeval(node: minidom.Element, name: str) -> str | None: anode = node.getAttributeNode(name) - if anode is not None: - return anode.value + return anode.value if anode is not None else None expected = {name: str(value) for name, value in kwargs.items()} on_node = {name: nodeval(node, name) for name in expected} assert on_node == expected -class DomNode: - def __init__(self, dom): - self.__node = dom +class DomDocument: + def __init__(self, dom: minidom.Document): + self._node = dom - def __repr__(self): - return self.__node.toxml() + _node: minidom.Document | minidom.Element - def find_first_by_tag(self, tag): + def find_first_by_tag(self, tag: str) -> DomNode | None: return self.find_nth_by_tag(tag, 0) - def _by_tag(self, tag): - return self.__node.getElementsByTagName(tag) + def get_first_by_tag(self, tag: str) -> DomNode: + maybe = self.find_first_by_tag(tag) + if maybe is None: + raise LookupError(tag) + else: + return maybe + + def find_nth_by_tag(self, tag: str, n: int) -> DomNode | None: + items = self._node.getElementsByTagName(tag) + try: + nth = items[n] + except IndexError: + return None + else: + return DomNode(nth) + + def find_by_tag(self, tag: str) -> list[DomNode]: + return [DomNode(x) for x in self._node.getElementsByTagName(tag)] @property - def children(self): - return [type(self)(x) for x in self.__node.childNodes] + def children(self) -> list[DomNode]: + return [DomNode(x) for x in self._node.childNodes] @property - def get_unique_child(self): + def get_unique_child(self) -> DomNode: children = self.children assert len(children) == 1 return children[0] - def find_nth_by_tag(self, tag, n): - items = self._by_tag(tag) - try: - nth = items[n] - except IndexError: - pass - else: - return type(self)(nth) + def toxml(self) -> str: + return self._node.toxml() + + +class DomNode(DomDocument): + _node: minidom.Element - def find_by_tag(self, tag): - t = type(self) - return [t(x) for x in self.__node.getElementsByTagName(tag)] + def __init__(self, dom: minidom.Element): + self._node = dom - def __getitem__(self, key): - node = self.__node.getAttributeNode(key) + def __repr__(self) -> str: + return self.toxml() + + def __getitem__(self, key: str) -> str: + node = self._node.getAttributeNode(key) if node is not None: - return node.value + return cast(str, node.value) + else: + raise KeyError(key) - def assert_attr(self, **kwargs): + def assert_attr(self, **kwargs: object) -> None: __tracebackhide__ = True - return assert_attr(self.__node, **kwargs) - - def toxml(self): - return self.__node.toxml() + return assert_attr(self._node, **kwargs) @property - def text(self): - return self.__node.childNodes[0].wholeText + def text(self) -> str: + return cast(str, self._node.childNodes[0].wholeText) @property - def tag(self): - return self.__node.tagName + def tag(self) -> str: + return self._node.tagName - @property - def next_sibling(self): - return type(self)(self.__node.nextSibling) + +class TestJunitHelpers: + """minimal test to increase coverage for methods that are used in debugging""" + + @pytest.fixture + def document(self) -> DomDocument: + doc = minidom.parseString(""" + + + + +""") + return DomDocument(doc) + + def test_uc_root(self, document: DomDocument) -> None: + assert document.get_unique_child.tag == "root" + + def test_node_assert_attr(self, document: DomDocument) -> None: + item = document.get_first_by_tag("item") + + item.assert_attr(name="a") + + with pytest.raises(AssertionError): + item.assert_attr(missing="foo") + + def test_node_getitem(self, document: DomDocument) -> None: + item = document.get_first_by_tag("item") + assert item["name"] == "a" + + with pytest.raises(KeyError, match="missing"): + item["missing"] + + def test_node_get_first_lookup(self, document: DomDocument) -> None: + with pytest.raises(LookupError, match="missing"): + document.get_first_by_tag("missing") + + def test_node_repr(self, document: DomDocument) -> None: + item = document.get_first_by_tag("item") + + assert repr(item) == item.toxml() + assert item.toxml() == '' parametrize_families = pytest.mark.parametrize("xunit_family", ["xunit1", "xunit2"]) @@ -163,7 +215,7 @@ def test_xpass(): ) result, dom = run_and_parse(family=xunit_family) assert result.ret - node = dom.find_first_by_tag("testsuite") + node = dom.get_first_by_tag("testsuite") node.assert_attr(name="pytest", errors=0, failures=1, skipped=2, tests=5) @parametrize_families @@ -192,7 +244,7 @@ def test_xpass(): ) result, dom = run_and_parse(family=xunit_family) assert result.ret - node = dom.find_first_by_tag("testsuite") + node = dom.get_first_by_tag("testsuite") node.assert_attr(name="pytest", errors=1, failures=2, skipped=1, tests=5) @parametrize_families @@ -206,7 +258,7 @@ def test_pass(): """ ) result, dom = run_and_parse(family=xunit_family) - node = dom.find_first_by_tag("testsuite") + node = dom.get_first_by_tag("testsuite") node.assert_attr(hostname=platform.node()) @parametrize_families @@ -219,14 +271,17 @@ def test_pass(): pass """ ) - start_time = datetime.now() + start_time = datetime.now(timezone.utc) result, dom = run_and_parse(family=xunit_family) - node = dom.find_first_by_tag("testsuite") - timestamp = datetime.strptime(node["timestamp"], "%Y-%m-%dT%H:%M:%S.%f") - assert start_time <= timestamp < datetime.now() + node = dom.get_first_by_tag("testsuite") + timestamp = datetime.fromisoformat(node["timestamp"]) + assert start_time <= timestamp < datetime.now(timezone.utc) def test_timing_function( - self, pytester: Pytester, run_and_parse: RunAndParse, mock_timing + self, + pytester: Pytester, + run_and_parse: RunAndParse, + mock_timing: _pytest.timing.MockTiming, ) -> None: pytester.makepyfile( """ @@ -240,9 +295,10 @@ def test_sleep(): """ ) result, dom = run_and_parse() - node = dom.find_first_by_tag("testsuite") - tnode = node.find_first_by_tag("testcase") + node = dom.get_first_by_tag("testsuite") + tnode = node.get_first_by_tag("testcase") val = tnode["time"] + assert val is not None assert float(val) == 7.0 @pytest.mark.parametrize("duration_report", ["call", "total"]) @@ -256,7 +312,7 @@ def test_junit_duration_report( # mock LogXML.node_reporter so it always sets a known duration to each test report object original_node_reporter = LogXML.node_reporter - def node_reporter_wrapper(s, report): + def node_reporter_wrapper(s: Any, report: TestReport) -> Any: report.duration = 1.0 reporter = original_node_reporter(s, report) return reporter @@ -270,8 +326,8 @@ def test_foo(): """ ) result, dom = run_and_parse("-o", f"junit_duration_report={duration_report}") - node = dom.find_first_by_tag("testsuite") - tnode = node.find_first_by_tag("testcase") + node = dom.get_first_by_tag("testsuite") + tnode = node.get_first_by_tag("testcase") val = float(tnode["time"]) if duration_report == "total": assert val == 3.0 @@ -296,11 +352,11 @@ def test_function(arg): ) result, dom = run_and_parse(family=xunit_family) assert result.ret - node = dom.find_first_by_tag("testsuite") + node = dom.get_first_by_tag("testsuite") node.assert_attr(errors=1, tests=1) - tnode = node.find_first_by_tag("testcase") + tnode = node.get_first_by_tag("testcase") tnode.assert_attr(classname="test_setup_error", name="test_function") - fnode = tnode.find_first_by_tag("error") + fnode = tnode.get_first_by_tag("error") fnode.assert_attr(message='failed on setup with "ValueError: Error reason"') assert "ValueError" in fnode.toxml() @@ -322,10 +378,10 @@ def test_function(arg): ) result, dom = run_and_parse(family=xunit_family) assert result.ret - node = dom.find_first_by_tag("testsuite") - tnode = node.find_first_by_tag("testcase") + node = dom.get_first_by_tag("testsuite") + tnode = node.get_first_by_tag("testcase") tnode.assert_attr(classname="test_teardown_error", name="test_function") - fnode = tnode.find_first_by_tag("error") + fnode = tnode.get_first_by_tag("error") fnode.assert_attr(message='failed on teardown with "ValueError: Error reason"') assert "ValueError" in fnode.toxml() @@ -347,15 +403,15 @@ def test_function(arg): ) result, dom = run_and_parse(family=xunit_family) assert result.ret - node = dom.find_first_by_tag("testsuite") + node = dom.get_first_by_tag("testsuite") node.assert_attr(errors=1, failures=1, tests=1) first, second = dom.find_by_tag("testcase") assert first assert second assert first != second - fnode = first.find_first_by_tag("failure") + fnode = first.get_first_by_tag("failure") fnode.assert_attr(message="Exception: Call Exception") - snode = second.find_first_by_tag("error") + snode = second.get_first_by_tag("error") snode.assert_attr( message='failed on teardown with "Exception: Teardown Exception"' ) @@ -373,11 +429,11 @@ def test_skip(): ) result, dom = run_and_parse(family=xunit_family) assert result.ret == 0 - node = dom.find_first_by_tag("testsuite") + node = dom.get_first_by_tag("testsuite") node.assert_attr(skipped=1) - tnode = node.find_first_by_tag("testcase") + tnode = node.get_first_by_tag("testcase") tnode.assert_attr(classname="test_skip_contains_name_reason", name="test_skip") - snode = tnode.find_first_by_tag("skipped") + snode = tnode.get_first_by_tag("skipped") snode.assert_attr(type="pytest.skip", message="hello23") @parametrize_families @@ -394,13 +450,13 @@ def test_skip(): ) result, dom = run_and_parse(family=xunit_family) assert result.ret == 0 - node = dom.find_first_by_tag("testsuite") + node = dom.get_first_by_tag("testsuite") node.assert_attr(skipped=1) - tnode = node.find_first_by_tag("testcase") + tnode = node.get_first_by_tag("testcase") tnode.assert_attr( classname="test_mark_skip_contains_name_reason", name="test_skip" ) - snode = tnode.find_first_by_tag("skipped") + snode = tnode.get_first_by_tag("skipped") snode.assert_attr(type="pytest.skip", message="hello24") @parametrize_families @@ -418,13 +474,13 @@ def test_skip(): ) result, dom = run_and_parse(family=xunit_family) assert result.ret == 0 - node = dom.find_first_by_tag("testsuite") + node = dom.get_first_by_tag("testsuite") node.assert_attr(skipped=1) - tnode = node.find_first_by_tag("testcase") + tnode = node.get_first_by_tag("testcase") tnode.assert_attr( classname="test_mark_skipif_contains_name_reason", name="test_skip" ) - snode = tnode.find_first_by_tag("skipped") + snode = tnode.get_first_by_tag("skipped") snode.assert_attr(type="pytest.skip", message="hello25") @parametrize_families @@ -441,7 +497,7 @@ def test_skip(): ) result, dom = run_and_parse(family=xunit_family) assert result.ret == 0 - node_xml = dom.find_first_by_tag("testsuite").toxml() + node_xml = dom.get_first_by_tag("testsuite").toxml() assert "bar!" not in node_xml @parametrize_families @@ -457,9 +513,9 @@ def test_method(self): ) result, dom = run_and_parse(family=xunit_family) assert result.ret - node = dom.find_first_by_tag("testsuite") + node = dom.get_first_by_tag("testsuite") node.assert_attr(failures=1) - tnode = node.find_first_by_tag("testcase") + tnode = node.get_first_by_tag("testcase") tnode.assert_attr( classname="test_classname_instance.TestClass", name="test_method" ) @@ -472,9 +528,9 @@ def test_classname_nested_dir( p.write_text("def test_func(): 0/0", encoding="utf-8") result, dom = run_and_parse(family=xunit_family) assert result.ret - node = dom.find_first_by_tag("testsuite") + node = dom.get_first_by_tag("testsuite") node.assert_attr(failures=1) - tnode = node.find_first_by_tag("testcase") + tnode = node.get_first_by_tag("testcase") tnode.assert_attr(classname="sub.test_hello", name="test_func") @parametrize_families @@ -485,11 +541,11 @@ def test_internal_error( pytester.makepyfile("def test_function(): pass") result, dom = run_and_parse(family=xunit_family) assert result.ret - node = dom.find_first_by_tag("testsuite") + node = dom.get_first_by_tag("testsuite") node.assert_attr(errors=1, tests=1) - tnode = node.find_first_by_tag("testcase") + tnode = node.get_first_by_tag("testcase") tnode.assert_attr(classname="pytest", name="internal") - fnode = tnode.find_first_by_tag("error") + fnode = tnode.get_first_by_tag("error") fnode.assert_attr(message="internal error") assert "Division" in fnode.toxml() @@ -500,9 +556,9 @@ def test_internal_error( def test_failure_function( self, pytester: Pytester, - junit_logging, + junit_logging: str, run_and_parse: RunAndParse, - xunit_family, + xunit_family: str, ) -> None: pytester.makepyfile( """ @@ -519,50 +575,50 @@ def test_fail(): ) result, dom = run_and_parse( - "-o", "junit_logging=%s" % junit_logging, family=xunit_family + "-o", f"junit_logging={junit_logging}", family=xunit_family ) assert result.ret, "Expected ret > 0" - node = dom.find_first_by_tag("testsuite") + node = dom.get_first_by_tag("testsuite") node.assert_attr(failures=1, tests=1) - tnode = node.find_first_by_tag("testcase") + tnode = node.get_first_by_tag("testcase") tnode.assert_attr(classname="test_failure_function", name="test_fail") - fnode = tnode.find_first_by_tag("failure") + fnode = tnode.get_first_by_tag("failure") fnode.assert_attr(message="ValueError: 42") assert "ValueError" in fnode.toxml(), "ValueError not included" if junit_logging in ["log", "all"]: - logdata = tnode.find_first_by_tag("system-out") + logdata = tnode.get_first_by_tag("system-out") log_xml = logdata.toxml() assert logdata.tag == "system-out", "Expected tag: system-out" assert "info msg" not in log_xml, "Unexpected INFO message" assert "warning msg" in log_xml, "Missing WARN message" if junit_logging in ["system-out", "out-err", "all"]: - systemout = tnode.find_first_by_tag("system-out") + systemout = tnode.get_first_by_tag("system-out") systemout_xml = systemout.toxml() assert systemout.tag == "system-out", "Expected tag: system-out" assert "info msg" not in systemout_xml, "INFO message found in system-out" - assert ( - "hello-stdout" in systemout_xml - ), "Missing 'hello-stdout' in system-out" + assert "hello-stdout" in systemout_xml, ( + "Missing 'hello-stdout' in system-out" + ) if junit_logging in ["system-err", "out-err", "all"]: - systemerr = tnode.find_first_by_tag("system-err") + systemerr = tnode.get_first_by_tag("system-err") systemerr_xml = systemerr.toxml() assert systemerr.tag == "system-err", "Expected tag: system-err" assert "info msg" not in systemerr_xml, "INFO message found in system-err" - assert ( - "hello-stderr" in systemerr_xml - ), "Missing 'hello-stderr' in system-err" - assert ( - "warning msg" not in systemerr_xml - ), "WARN message found in system-err" + assert "hello-stderr" in systemerr_xml, ( + "Missing 'hello-stderr' in system-err" + ) + assert "warning msg" not in systemerr_xml, ( + "WARN message found in system-err" + ) if junit_logging == "no": assert not tnode.find_by_tag("log"), "Found unexpected content: log" - assert not tnode.find_by_tag( - "system-out" - ), "Found unexpected content: system-out" - assert not tnode.find_by_tag( - "system-err" - ), "Found unexpected content: system-err" + assert not tnode.find_by_tag("system-out"), ( + "Found unexpected content: system-out" + ) + assert not tnode.find_by_tag("system-err"), ( + "Found unexpected content: system-err" + ) @parametrize_families def test_failure_verbose_message( @@ -576,9 +632,9 @@ def test_fail(): """ ) result, dom = run_and_parse(family=xunit_family) - node = dom.find_first_by_tag("testsuite") - tnode = node.find_first_by_tag("testcase") - fnode = tnode.find_first_by_tag("failure") + node = dom.get_first_by_tag("testsuite") + tnode = node.get_first_by_tag("testcase") + fnode = tnode.get_first_by_tag("failure") fnode.assert_attr(message="AssertionError: An error\nassert 0") @parametrize_families @@ -598,17 +654,17 @@ def test_func(arg1): "-o", "junit_logging=system-out", family=xunit_family ) assert result.ret - node = dom.find_first_by_tag("testsuite") + node = dom.get_first_by_tag("testsuite") node.assert_attr(failures=3, tests=3) - - for index, char in enumerate("<&'"): - tnode = node.find_nth_by_tag("testcase", index) + tnodes = node.find_by_tag("testcase") + assert len(tnodes) == 3 + for tnode, char in zip(tnodes, "<&'"): tnode.assert_attr( - classname="test_failure_escape", name="test_func[%s]" % char + classname="test_failure_escape", name=f"test_func[{char}]" ) - sysout = tnode.find_first_by_tag("system-out") + sysout = tnode.get_first_by_tag("system-out") text = sysout.text - assert "%s\n" % char in text + assert f"{char}\n" in text @parametrize_families def test_junit_prefixing( @@ -625,11 +681,11 @@ def test_hello(self): ) result, dom = run_and_parse("--junitprefix=xyz", family=xunit_family) assert result.ret - node = dom.find_first_by_tag("testsuite") + node = dom.get_first_by_tag("testsuite") node.assert_attr(failures=1, tests=2) - tnode = node.find_first_by_tag("testcase") + tnode = node.get_first_by_tag("testcase") tnode.assert_attr(classname="xyz.test_junit_prefixing", name="test_func") - tnode = node.find_nth_by_tag("testcase", 1) + tnode = node.find_by_tag("testcase")[1] tnode.assert_attr( classname="xyz.test_junit_prefixing.TestHello", name="test_hello" ) @@ -647,11 +703,11 @@ def test_xfail(): ) result, dom = run_and_parse(family=xunit_family) assert not result.ret - node = dom.find_first_by_tag("testsuite") + node = dom.get_first_by_tag("testsuite") node.assert_attr(skipped=1, tests=1) - tnode = node.find_first_by_tag("testcase") + tnode = node.get_first_by_tag("testcase") tnode.assert_attr(classname="test_xfailure_function", name="test_xfail") - fnode = tnode.find_first_by_tag("skipped") + fnode = tnode.get_first_by_tag("skipped") fnode.assert_attr(type="pytest.xfail", message="42") @parametrize_families @@ -668,11 +724,11 @@ def test_xfail(): ) result, dom = run_and_parse(family=xunit_family) assert not result.ret - node = dom.find_first_by_tag("testsuite") + node = dom.get_first_by_tag("testsuite") node.assert_attr(skipped=1, tests=1) - tnode = node.find_first_by_tag("testcase") + tnode = node.get_first_by_tag("testcase") tnode.assert_attr(classname="test_xfailure_marker", name="test_xfail") - fnode = tnode.find_first_by_tag("skipped") + fnode = tnode.get_first_by_tag("skipped") fnode.assert_attr(type="pytest.xfail", message="42") @pytest.mark.parametrize( @@ -693,18 +749,18 @@ def test_fail(): assert 0 """ ) - result, dom = run_and_parse("-o", "junit_logging=%s" % junit_logging) - node = dom.find_first_by_tag("testsuite") - tnode = node.find_first_by_tag("testcase") - if junit_logging in ["system-err", "out-err", "all"]: - assert len(tnode.find_by_tag("system-err")) == 1 - else: - assert len(tnode.find_by_tag("system-err")) == 0 + result, dom = run_and_parse("-o", f"junit_logging={junit_logging}") + node = dom.get_first_by_tag("testsuite") + tnode = node.get_first_by_tag("testcase") - if junit_logging in ["log", "system-out", "out-err", "all"]: - assert len(tnode.find_by_tag("system-out")) == 1 - else: - assert len(tnode.find_by_tag("system-out")) == 0 + has_err_logging = junit_logging in ["system-err", "out-err", "all"] + expected_err_output_len = 1 if has_err_logging else 0 + assert len(tnode.find_by_tag("system-err")) == expected_err_output_len + + has_out_logigng = junit_logging in ("log", "system-out", "out-err", "all") + expected_out_output_len = 1 if has_out_logigng else 0 + + assert len(tnode.find_by_tag("system-out")) == expected_out_output_len @parametrize_families def test_xfailure_xpass( @@ -720,9 +776,9 @@ def test_xpass(): ) result, dom = run_and_parse(family=xunit_family) # assert result.ret - node = dom.find_first_by_tag("testsuite") + node = dom.get_first_by_tag("testsuite") node.assert_attr(skipped=0, tests=1) - tnode = node.find_first_by_tag("testcase") + tnode = node.get_first_by_tag("testcase") tnode.assert_attr(classname="test_xfailure_xpass", name="test_xpass") @parametrize_families @@ -739,11 +795,11 @@ def test_xpass(): ) result, dom = run_and_parse(family=xunit_family) # assert result.ret - node = dom.find_first_by_tag("testsuite") + node = dom.get_first_by_tag("testsuite") node.assert_attr(skipped=0, tests=1) - tnode = node.find_first_by_tag("testcase") + tnode = node.get_first_by_tag("testcase") tnode.assert_attr(classname="test_xfailure_xpass_strict", name="test_xpass") - fnode = tnode.find_first_by_tag("failure") + fnode = tnode.get_first_by_tag("failure") fnode.assert_attr(message="[XPASS(strict)] This needs to fail!") @parametrize_families @@ -753,28 +809,27 @@ def test_collect_error( pytester.makepyfile("syntax error") result, dom = run_and_parse(family=xunit_family) assert result.ret - node = dom.find_first_by_tag("testsuite") + node = dom.get_first_by_tag("testsuite") node.assert_attr(errors=1, tests=1) - tnode = node.find_first_by_tag("testcase") - fnode = tnode.find_first_by_tag("error") + tnode = node.get_first_by_tag("testcase") + fnode = tnode.get_first_by_tag("error") fnode.assert_attr(message="collection failure") assert "SyntaxError" in fnode.toxml() def test_unicode(self, pytester: Pytester, run_and_parse: RunAndParse) -> None: value = "hx\xc4\x85\xc4\x87\n" pytester.makepyfile( - """\ + f"""\ # coding: latin1 def test_hello(): - print(%r) + print({value!r}) assert 0 """ - % value ) result, dom = run_and_parse() assert result.ret == 1 - tnode = dom.find_first_by_tag("testcase") - fnode = tnode.find_first_by_tag("failure") + tnode = dom.get_first_by_tag("testcase") + fnode = tnode.get_first_by_tag("failure") assert "hx" in fnode.toxml() def test_assertion_binchars( @@ -804,18 +859,18 @@ def test_pass(): print('hello-stdout') """ ) - result, dom = run_and_parse("-o", "junit_logging=%s" % junit_logging) - node = dom.find_first_by_tag("testsuite") - pnode = node.find_first_by_tag("testcase") + result, dom = run_and_parse("-o", f"junit_logging={junit_logging}") + node = dom.get_first_by_tag("testsuite") + pnode = node.get_first_by_tag("testcase") if junit_logging == "no": - assert not node.find_by_tag( - "system-out" - ), "system-out should not be generated" + assert not node.find_by_tag("system-out"), ( + "system-out should not be generated" + ) if junit_logging == "system-out": - systemout = pnode.find_first_by_tag("system-out") - assert ( - "hello-stdout" in systemout.toxml() - ), "'hello-stdout' should be in system-out" + systemout = pnode.get_first_by_tag("system-out") + assert "hello-stdout" in systemout.toxml(), ( + "'hello-stdout' should be in system-out" + ) @pytest.mark.parametrize("junit_logging", ["no", "system-err"]) def test_pass_captures_stderr( @@ -828,18 +883,18 @@ def test_pass(): sys.stderr.write('hello-stderr') """ ) - result, dom = run_and_parse("-o", "junit_logging=%s" % junit_logging) - node = dom.find_first_by_tag("testsuite") - pnode = node.find_first_by_tag("testcase") + result, dom = run_and_parse("-o", f"junit_logging={junit_logging}") + node = dom.get_first_by_tag("testsuite") + pnode = node.get_first_by_tag("testcase") if junit_logging == "no": - assert not node.find_by_tag( - "system-err" - ), "system-err should not be generated" + assert not node.find_by_tag("system-err"), ( + "system-err should not be generated" + ) if junit_logging == "system-err": - systemerr = pnode.find_first_by_tag("system-err") - assert ( - "hello-stderr" in systemerr.toxml() - ), "'hello-stderr' should be in system-err" + systemerr = pnode.get_first_by_tag("system-err") + assert "hello-stderr" in systemerr.toxml(), ( + "'hello-stderr' should be in system-err" + ) @pytest.mark.parametrize("junit_logging", ["no", "system-out"]) def test_setup_error_captures_stdout( @@ -857,18 +912,18 @@ def test_function(arg): pass """ ) - result, dom = run_and_parse("-o", "junit_logging=%s" % junit_logging) - node = dom.find_first_by_tag("testsuite") - pnode = node.find_first_by_tag("testcase") + result, dom = run_and_parse("-o", f"junit_logging={junit_logging}") + node = dom.get_first_by_tag("testsuite") + pnode = node.get_first_by_tag("testcase") if junit_logging == "no": - assert not node.find_by_tag( - "system-out" - ), "system-out should not be generated" + assert not node.find_by_tag("system-out"), ( + "system-out should not be generated" + ) if junit_logging == "system-out": - systemout = pnode.find_first_by_tag("system-out") - assert ( - "hello-stdout" in systemout.toxml() - ), "'hello-stdout' should be in system-out" + systemout = pnode.get_first_by_tag("system-out") + assert "hello-stdout" in systemout.toxml(), ( + "'hello-stdout' should be in system-out" + ) @pytest.mark.parametrize("junit_logging", ["no", "system-err"]) def test_setup_error_captures_stderr( @@ -887,18 +942,18 @@ def test_function(arg): pass """ ) - result, dom = run_and_parse("-o", "junit_logging=%s" % junit_logging) - node = dom.find_first_by_tag("testsuite") - pnode = node.find_first_by_tag("testcase") + result, dom = run_and_parse("-o", f"junit_logging={junit_logging}") + node = dom.get_first_by_tag("testsuite") + pnode = node.get_first_by_tag("testcase") if junit_logging == "no": - assert not node.find_by_tag( - "system-err" - ), "system-err should not be generated" + assert not node.find_by_tag("system-err"), ( + "system-err should not be generated" + ) if junit_logging == "system-err": - systemerr = pnode.find_first_by_tag("system-err") - assert ( - "hello-stderr" in systemerr.toxml() - ), "'hello-stderr' should be in system-err" + systemerr = pnode.get_first_by_tag("system-err") + assert "hello-stderr" in systemerr.toxml(), ( + "'hello-stderr' should be in system-err" + ) @pytest.mark.parametrize("junit_logging", ["no", "system-out"]) def test_avoid_double_stdout( @@ -918,15 +973,15 @@ def test_function(arg): sys.stdout.write('hello-stdout call') """ ) - result, dom = run_and_parse("-o", "junit_logging=%s" % junit_logging) - node = dom.find_first_by_tag("testsuite") - pnode = node.find_first_by_tag("testcase") + result, dom = run_and_parse("-o", f"junit_logging={junit_logging}") + node = dom.get_first_by_tag("testsuite") + pnode = node.get_first_by_tag("testcase") if junit_logging == "no": - assert not node.find_by_tag( - "system-out" - ), "system-out should not be generated" + assert not node.find_by_tag("system-out"), ( + "system-out should not be generated" + ) if junit_logging == "system-out": - systemout = pnode.find_first_by_tag("system-out") + systemout = pnode.get_first_by_tag("system-out") assert "hello-stdout call" in systemout.toxml() assert "hello-stdout teardown" in systemout.toxml() @@ -940,18 +995,18 @@ def test_mangle_test_address() -> None: def test_dont_configure_on_workers(tmp_path: Path) -> None: - gotten: List[object] = [] + gotten: list[object] = [] class FakeConfig: if TYPE_CHECKING: workerinput = None - def __init__(self): + def __init__(self) -> None: self.pluginmanager = self self.option = self self.stash = Stash() - def getini(self, name): + def getini(self, name: str) -> str: return "pytest" junitprefix = None @@ -990,18 +1045,18 @@ def repr_failure(self, excinfo): pytester.path.joinpath("myfile.xyz").write_text("hello", encoding="utf-8") result, dom = run_and_parse(family=xunit_family) assert result.ret - node = dom.find_first_by_tag("testsuite") + node = dom.get_first_by_tag("testsuite") node.assert_attr(errors=0, failures=1, skipped=0, tests=1) - tnode = node.find_first_by_tag("testcase") + tnode = node.get_first_by_tag("testcase") tnode.assert_attr(name="myfile.xyz") - fnode = tnode.find_first_by_tag("failure") + fnode = tnode.get_first_by_tag("failure") fnode.assert_attr(message="custom item runtest failed") assert "custom item runtest failed" in fnode.toxml() @pytest.mark.parametrize("junit_logging", ["no", "system-out"]) def test_nullbyte(pytester: Pytester, junit_logging: str) -> None: - # A null byte can not occur in XML (see section 2.2 of the spec) + # A null byte cannot occur in XML (see section 2.2 of the spec) pytester.makepyfile( """ import sys @@ -1012,7 +1067,7 @@ def test_print_nullbyte(): """ ) xmlf = pytester.path.joinpath("junit.xml") - pytester.runpytest("--junitxml=%s" % xmlf, "-o", "junit_logging=%s" % junit_logging) + pytester.runpytest(f"--junitxml={xmlf}", "-o", f"junit_logging={junit_logging}") text = xmlf.read_text(encoding="utf-8") assert "\x00" not in text if junit_logging == "system-out": @@ -1034,7 +1089,7 @@ def test_print_nullbyte(): """ ) xmlf = pytester.path.joinpath("junit.xml") - pytester.runpytest("--junitxml=%s" % xmlf, "-o", "junit_logging=%s" % junit_logging) + pytester.runpytest(f"--junitxml={xmlf}", "-o", f"junit_logging={junit_logging}") text = xmlf.read_text(encoding="utf-8") if junit_logging == "system-out": assert "#x0" in text @@ -1070,9 +1125,9 @@ def test_invalid_xml_escape() -> None: for i in invalid: got = bin_xml_escape(chr(i)) if i <= 0xFF: - expected = "#x%02X" % i + expected = f"#x{i:02X}" else: - expected = "#x%04X" % i + expected = f"#x{i:04X}" assert got == expected for i in valid: assert chr(i) == bin_xml_escape(chr(i)) @@ -1135,7 +1190,7 @@ def test_func(char): ) result, dom = run_and_parse() assert result.ret == 0 - node = dom.find_first_by_tag("testcase") + node = dom.get_first_by_tag("testcase") node.assert_attr(name="test_func[\\x00]") @@ -1152,7 +1207,7 @@ def test_func(param): ) result, dom = run_and_parse() assert result.ret == 0 - node = dom.find_first_by_tag("testcase") + node = dom.get_first_by_tag("testcase") node.assert_attr(classname="test_double_colon_split_function_issue469") node.assert_attr(name="test_func[double::colon]") @@ -1171,7 +1226,7 @@ def test_func(self, param): ) result, dom = run_and_parse() assert result.ret == 0 - node = dom.find_first_by_tag("testcase") + node = dom.get_first_by_tag("testcase") node.assert_attr(classname="test_double_colon_split_method_issue469.TestClass") node.assert_attr(name="test_func[double::colon]") @@ -1183,7 +1238,7 @@ def test_unicode_issue368(pytester: Pytester) -> None: class Report(BaseReport): longrepr = ustr - sections: List[Tuple[str, str]] = [] + sections: list[tuple[str, str]] = [] nodeid = "something" location = "tests/filename.py", 42, "TestClass.method" when = "teardown" @@ -1201,7 +1256,7 @@ class Report(BaseReport): node_reporter.append_skipped(test_report) test_report.longrepr = "filename", 1, "Skipped: 卡嘣嘣" node_reporter.append_skipped(test_report) - test_report.wasxfail = ustr # type: ignore[attr-defined] + test_report.wasxfail = ustr node_reporter.append_skipped(test_report) log.pytest_sessionfinish() @@ -1219,15 +1274,45 @@ def test_record(record_property, other): """ ) result, dom = run_and_parse() - node = dom.find_first_by_tag("testsuite") - tnode = node.find_first_by_tag("testcase") - psnode = tnode.find_first_by_tag("properties") + node = dom.get_first_by_tag("testsuite") + tnode = node.get_first_by_tag("testcase") + psnode = tnode.get_first_by_tag("properties") pnodes = psnode.find_by_tag("property") pnodes[0].assert_attr(name="bar", value="1") pnodes[1].assert_attr(name="foo", value="<1") result.stdout.fnmatch_lines(["*= 1 passed in *"]) +def test_record_property_on_test_and_teardown_failure( + pytester: Pytester, run_and_parse: RunAndParse +) -> None: + pytester.makepyfile( + """ + import pytest + + @pytest.fixture + def other(record_property): + record_property("bar", 1) + yield + assert 0 + + def test_record(record_property, other): + record_property("foo", "<1") + assert 0 + """ + ) + result, dom = run_and_parse() + node = dom.get_first_by_tag("testsuite") + tnodes = node.find_by_tag("testcase") + for tnode in tnodes: + psnode = tnode.get_first_by_tag("properties") + assert psnode, f"testcase didn't had expected properties:\n{tnode}" + pnodes = psnode.find_by_tag("property") + pnodes[0].assert_attr(name="bar", value="1") + pnodes[1].assert_attr(name="foo", value="<1") + result.stdout.fnmatch_lines(["*= 1 failed, 1 error *"]) + + def test_record_property_same_name( pytester: Pytester, run_and_parse: RunAndParse ) -> None: @@ -1239,9 +1324,9 @@ def test_record_with_same_name(record_property): """ ) result, dom = run_and_parse() - node = dom.find_first_by_tag("testsuite") - tnode = node.find_first_by_tag("testcase") - psnode = tnode.find_first_by_tag("properties") + node = dom.get_first_by_tag("testsuite") + tnode = node.get_first_by_tag("testcase") + psnode = tnode.get_first_by_tag("properties") pnodes = psnode.find_by_tag("property") pnodes[0].assert_attr(name="foo", value="bar") pnodes[1].assert_attr(name="foo", value="baz") @@ -1252,12 +1337,10 @@ def test_record_fixtures_without_junitxml( pytester: Pytester, fixture_name: str ) -> None: pytester.makepyfile( - """ + f""" def test_record({fixture_name}): {fixture_name}("foo", "bar") - """.format( - fixture_name=fixture_name - ) + """ ) result = pytester.runpytest() assert result.ret == 0 @@ -1283,8 +1366,8 @@ def test_record(record_xml_attribute, other): """ ) result, dom = run_and_parse() - node = dom.find_first_by_tag("testsuite") - tnode = node.find_first_by_tag("testcase") + node = dom.get_first_by_tag("testsuite") + tnode = node.get_first_by_tag("testcase") tnode.assert_attr(bar="1") tnode.assert_attr(foo="<1") result.stdout.fnmatch_lines( @@ -1305,7 +1388,7 @@ def test_record_fixtures_xunit2( """ ) pytester.makepyfile( - """ + f""" import pytest @pytest.fixture @@ -1313,9 +1396,7 @@ def other({fixture_name}): {fixture_name}("bar", 1) def test_record({fixture_name}, other): {fixture_name}("foo", "<1"); - """.format( - fixture_name=fixture_name - ) + """ ) result, dom = run_and_parse(family=None) @@ -1325,10 +1406,8 @@ def test_record({fixture_name}, other): "*test_record_fixtures_xunit2.py:6:*record_xml_attribute is an experimental feature" ) expected_lines = [ - "*test_record_fixtures_xunit2.py:6:*{fixture_name} is incompatible " - "with junit_family 'xunit2' (use 'legacy' or 'xunit1')".format( - fixture_name=fixture_name - ) + f"*test_record_fixtures_xunit2.py:6:*{fixture_name} is incompatible " + "with junit_family 'xunit2' (use 'legacy' or 'xunit1')" ] result.stdout.fnmatch_lines(expected_lines) @@ -1350,7 +1429,7 @@ def test_x(i): """ ) _, dom = run_and_parse("-n2") - suite_node = dom.find_first_by_tag("testsuite") + suite_node = dom.get_first_by_tag("testsuite") failed = [] for case_node in suite_node.find_by_tag("testcase"): if case_node.find_first_by_tag("failure"): @@ -1372,6 +1451,7 @@ def test_x(): _, dom = run_and_parse(family=xunit_family) root = dom.get_unique_child assert root.tag == "testsuites" + root.assert_attr(name="pytest tests") suite_node = root.get_unique_child assert suite_node.tag == "testsuite" @@ -1445,7 +1525,12 @@ def test_pass(): result.stdout.no_fnmatch_line("*INTERNALERROR*") - items = sorted("%(classname)s %(name)s" % x for x in dom.find_by_tag("testcase")) + items = sorted( + f"{x['classname']} {x['name']}" + # dom is a DomNode not a mapping, it's not possible to ** it. + for x in dom.find_by_tag("testcase") + ) + import pprint pprint.pprint(items) @@ -1466,7 +1551,7 @@ def test_global_properties(pytester: Pytester, xunit_family: str) -> None: log = LogXML(str(path), None, family=xunit_family) class Report(BaseReport): - sections: List[Tuple[str, str]] = [] + sections: list[tuple[str, str]] = [] nodeid = "test_node_id" log.pytest_sessionstart() @@ -1502,7 +1587,7 @@ def test_url_property(pytester: Pytester) -> None: class Report(BaseReport): longrepr = "FooBarBaz" - sections: List[Tuple[str, str]] = [] + sections: list[tuple[str, str]] = [] nodeid = "something" location = "tests/filename.py", 42, "TestClass.method" url = test_url @@ -1516,9 +1601,9 @@ class Report(BaseReport): test_case = minidom.parse(str(path)).getElementsByTagName("testcase")[0] - assert ( - test_case.getAttribute("url") == test_url - ), "The URL did not get written to the xml" + assert test_case.getAttribute("url") == test_url, ( + "The URL did not get written to the xml" + ) @parametrize_families @@ -1536,10 +1621,11 @@ def test_func2(record_testsuite_property): ) result, dom = run_and_parse(family=xunit_family) assert result.ret == 0 - node = dom.find_first_by_tag("testsuite") - properties_node = node.find_first_by_tag("properties") - p1_node = properties_node.find_nth_by_tag("property", 0) - p2_node = properties_node.find_nth_by_tag("property", 1) + node = dom.get_first_by_tag("testsuite") + properties_node = node.get_first_by_tag("properties") + p1_node, p2_node = properties_node.find_by_tag( + "property", + )[:2] p1_node.assert_attr(name="stats", value="all good") p2_node.assert_attr(name="stats", value="10") @@ -1580,13 +1666,11 @@ def test_set_suite_name( ) -> None: if suite_name: pytester.makeini( - """ + f""" [pytest] junit_suite_name={suite_name} - junit_family={family} - """.format( - suite_name=suite_name, family=xunit_family - ) + junit_family={xunit_family} + """ ) expected = suite_name else: @@ -1601,7 +1685,7 @@ def test_func(): ) result, dom = run_and_parse(family=xunit_family) assert result.ret == 0 - node = dom.find_first_by_tag("testsuite") + node = dom.get_first_by_tag("testsuite") node.assert_attr(name=expected) @@ -1617,12 +1701,29 @@ def test_skip(): """ ) _, dom = run_and_parse() - node = dom.find_first_by_tag("testcase") - snode = node.find_first_by_tag("skipped") + node = dom.get_first_by_tag("testcase") + snode = node.get_first_by_tag("skipped") assert "1 <> 2" in snode.text snode.assert_attr(message="1 <> 2") +def test_bin_escaped_skipreason(pytester: Pytester, run_and_parse: RunAndParse) -> None: + """Escape special characters from mark.skip reason (#11842).""" + pytester.makepyfile( + """ + import pytest + @pytest.mark.skip("\33[31;1mred\33[0m") + def test_skip(): + pass + """ + ) + _, dom = run_and_parse() + node = dom.get_first_by_tag("testcase") + snode = node.get_first_by_tag("skipped") + assert "#x1B[31;1mred#x1B[0m" in snode.text + snode.assert_attr(message="#x1B[31;1mred#x1B[0m") + + def test_escaped_setup_teardown_error( pytester: Pytester, run_and_parse: RunAndParse ) -> None: @@ -1639,8 +1740,8 @@ def test_esc(my_setup): """ ) _, dom = run_and_parse() - node = dom.find_first_by_tag("testcase") - snode = node.find_first_by_tag("error") + node = dom.get_first_by_tag("testcase") + snode = node.get_first_by_tag("error") assert "#x1B[31mred#x1B[m" in snode["message"] assert "#x1B[31mred#x1B[m" in snode.text @@ -1650,14 +1751,12 @@ def test_logging_passing_tests_disabled_does_not_log_test_output( pytester: Pytester, run_and_parse: RunAndParse, xunit_family: str ) -> None: pytester.makeini( - """ + f""" [pytest] junit_log_passing_tests=False junit_logging=system-out - junit_family={family} - """.format( - family=xunit_family - ) + junit_family={xunit_family} + """ ) pytester.makepyfile( """ @@ -1673,7 +1772,7 @@ def test_func(): ) result, dom = run_and_parse(family=xunit_family) assert result.ret == 0 - node = dom.find_first_by_tag("testcase") + node = dom.get_first_by_tag("testcase") assert len(node.find_by_tag("system-err")) == 0 assert len(node.find_by_tag("system-out")) == 0 @@ -1687,13 +1786,11 @@ def test_logging_passing_tests_disabled_logs_output_for_failing_test_issue5430( xunit_family: str, ) -> None: pytester.makeini( - """ + f""" [pytest] junit_log_passing_tests=False - junit_family={family} - """.format( - family=xunit_family - ) + junit_family={xunit_family} + """ ) pytester.makepyfile( """ @@ -1707,10 +1804,10 @@ def test_func(): """ ) result, dom = run_and_parse( - "-o", "junit_logging=%s" % junit_logging, family=xunit_family + "-o", f"junit_logging={junit_logging}", family=xunit_family ) assert result.ret == 1 - node = dom.find_first_by_tag("testcase") + node = dom.get_first_by_tag("testcase") if junit_logging == "system-out": assert len(node.find_by_tag("system-err")) == 0 assert len(node.find_by_tag("system-out")) == 1 diff --git a/testing/test_legacypath.py b/testing/test_legacypath.py index fbfd88b7384..72854e4e5c0 100644 --- a/testing/test_legacypath.py +++ b/testing/test_legacypath.py @@ -1,9 +1,13 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + from pathlib import Path -import pytest from _pytest.compat import LEGACY_PATH +from _pytest.fixtures import TopRequest from _pytest.legacypath import TempdirFactory from _pytest.legacypath import Testdir +import pytest def test_item_fspath(pytester: pytest.Pytester) -> None: @@ -77,7 +81,7 @@ def test_1(tmpdir): assert os.path.realpath(str(tmpdir)) == str(tmpdir) """ ) - result = pytester.runpytest("-s", p, "--basetemp=%s/bt" % linktemp) + result = pytester.runpytest("-s", p, f"--basetemp={linktemp}/bt") assert not result.ret @@ -90,7 +94,8 @@ def test_cache_makedir(cache: pytest.Cache) -> None: def test_fixturerequest_getmodulepath(pytester: pytest.Pytester) -> None: modcol = pytester.getmodulecol("def test_somefunc(): pass") (item,) = pytester.genitems([modcol]) - req = pytest.FixtureRequest(item, _ispytest=True) + assert isinstance(item, pytest.Function) + req = TopRequest(item, _ispytest=True) assert req.path == modcol.path assert req.fspath == modcol.fspath # type: ignore[attr-defined] @@ -105,7 +110,7 @@ def test_session_scoped_unavailable_attributes(self, session_request): AttributeError, match="path not available in session-scoped context", ): - session_request.fspath + _ = session_request.fspath @pytest.mark.parametrize("config_type", ["ini", "pyproject"]) @@ -152,7 +157,7 @@ def pytest_addoption(parser): ) pytester.makepyfile( r""" - def test_overriden(pytestconfig): + def test_overridden(pytestconfig): config_paths = pytestconfig.getini("paths") print(config_paths) for cpf in config_paths: diff --git a/testing/test_link_resolve.py b/testing/test_link_resolve.py index 1ac3afd09e8..0557dae669d 100644 --- a/testing/test_link_resolve.py +++ b/testing/test_link_resolve.py @@ -1,10 +1,13 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + +from contextlib import contextmanager import os.path +from pathlib import Path +from string import ascii_lowercase import subprocess import sys import textwrap -from contextlib import contextmanager -from pathlib import Path -from string import ascii_lowercase from _pytest.pytester import Pytester diff --git a/testing/test_main.py b/testing/test_main.py index 71597626790..94eac02ce63 100644 --- a/testing/test_main.py +++ b/testing/test_main.py @@ -1,16 +1,18 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + import argparse import os -import re -import sys from pathlib import Path -from typing import Optional +import re -import pytest from _pytest.config import ExitCode from _pytest.config import UsageError +from _pytest.main import CollectionArgument from _pytest.main import resolve_collection_argument from _pytest.main import validate_basetemp from _pytest.pytester import Pytester +import pytest @pytest.mark.parametrize( @@ -24,19 +26,17 @@ def test_wrap_session_notify_exception(ret_exc, pytester: Pytester) -> None: returncode, exc = ret_exc c1 = pytester.makeconftest( - """ + f""" import pytest def pytest_sessionstart(): - raise {exc}("boom") + raise {exc.__name__}("boom") def pytest_internalerror(excrepr, excinfo): returncode = {returncode!r} if returncode is not False: pytest.exit("exiting after %s..." % excinfo.typename, returncode={returncode!r}) - """.format( - returncode=returncode, exc=exc.__name__ - ) + """ ) result = pytester.runpytest() if returncode: @@ -45,32 +45,18 @@ def pytest_internalerror(excrepr, excinfo): assert result.ret == ExitCode.INTERNAL_ERROR assert result.stdout.lines[0] == "INTERNALERROR> Traceback (most recent call last):" - end_lines = ( - result.stdout.lines[-4:] - if (3, 11, 0, "beta", 4) > sys.version_info >= (3, 11) - else result.stdout.lines[-3:] - ) + end_lines = result.stdout.lines[-3:] if exc == SystemExit: assert end_lines == [ f'INTERNALERROR> File "{c1}", line 4, in pytest_sessionstart', 'INTERNALERROR> raise SystemExit("boom")', - *( - ("INTERNALERROR> ^^^^^^^^^^^^^^^^^^^^^^^^",) - if (3, 11, 0, "beta", 4) > sys.version_info >= (3, 11) - else () - ), "INTERNALERROR> SystemExit: boom", ] else: assert end_lines == [ f'INTERNALERROR> File "{c1}", line 4, in pytest_sessionstart', 'INTERNALERROR> raise ValueError("boom")', - *( - ("INTERNALERROR> ^^^^^^^^^^^^^^^^^^^^^^^^",) - if (3, 11, 0, "beta", 4) > sys.version_info >= (3, 11) - else () - ), "INTERNALERROR> ValueError: boom", ] if returncode is False: @@ -81,16 +67,14 @@ def pytest_internalerror(excrepr, excinfo): @pytest.mark.parametrize("returncode", (None, 42)) def test_wrap_session_exit_sessionfinish( - returncode: Optional[int], pytester: Pytester + returncode: int | None, pytester: Pytester ) -> None: pytester.makeconftest( - """ + f""" import pytest def pytest_sessionfinish(): pytest.exit(reason="exit_pytest_sessionfinish", returncode={returncode}) - """.format( - returncode=returncode - ) + """ ) result = pytester.runpytest() if returncode: @@ -136,26 +120,43 @@ def invocation_path(self, pytester: Pytester) -> Path: def test_file(self, invocation_path: Path) -> None: """File and parts.""" - assert resolve_collection_argument(invocation_path, "src/pkg/test.py") == ( - invocation_path / "src/pkg/test.py", - [], + assert resolve_collection_argument( + invocation_path, "src/pkg/test.py" + ) == CollectionArgument( + path=invocation_path / "src/pkg/test.py", + parts=[], + module_name=None, ) - assert resolve_collection_argument(invocation_path, "src/pkg/test.py::") == ( - invocation_path / "src/pkg/test.py", - [""], + assert resolve_collection_argument( + invocation_path, "src/pkg/test.py::" + ) == CollectionArgument( + path=invocation_path / "src/pkg/test.py", + parts=[""], + module_name=None, ) assert resolve_collection_argument( invocation_path, "src/pkg/test.py::foo::bar" - ) == (invocation_path / "src/pkg/test.py", ["foo", "bar"]) + ) == CollectionArgument( + path=invocation_path / "src/pkg/test.py", + parts=["foo", "bar"], + module_name=None, + ) assert resolve_collection_argument( invocation_path, "src/pkg/test.py::foo::bar::" - ) == (invocation_path / "src/pkg/test.py", ["foo", "bar", ""]) + ) == CollectionArgument( + path=invocation_path / "src/pkg/test.py", + parts=["foo", "bar", ""], + module_name=None, + ) def test_dir(self, invocation_path: Path) -> None: """Directory and parts.""" - assert resolve_collection_argument(invocation_path, "src/pkg") == ( - invocation_path / "src/pkg", - [], + assert resolve_collection_argument( + invocation_path, "src/pkg" + ) == CollectionArgument( + path=invocation_path / "src/pkg", + parts=[], + module_name=None, ) with pytest.raises( @@ -172,13 +173,24 @@ def test_pypath(self, invocation_path: Path) -> None: """Dotted name and parts.""" assert resolve_collection_argument( invocation_path, "pkg.test", as_pypath=True - ) == (invocation_path / "src/pkg/test.py", []) + ) == CollectionArgument( + path=invocation_path / "src/pkg/test.py", + parts=[], + module_name="pkg.test", + ) assert resolve_collection_argument( invocation_path, "pkg.test::foo::bar", as_pypath=True - ) == (invocation_path / "src/pkg/test.py", ["foo", "bar"]) - assert resolve_collection_argument(invocation_path, "pkg", as_pypath=True) == ( - invocation_path / "src/pkg", - [], + ) == CollectionArgument( + path=invocation_path / "src/pkg/test.py", + parts=["foo", "bar"], + module_name="pkg.test", + ) + assert resolve_collection_argument( + invocation_path, "pkg", as_pypath=True + ) == CollectionArgument( + path=invocation_path / "src/pkg", + parts=[], + module_name="pkg", ) with pytest.raises( @@ -189,10 +201,13 @@ def test_pypath(self, invocation_path: Path) -> None: ) def test_parametrized_name_with_colons(self, invocation_path: Path) -> None: - ret = resolve_collection_argument( + assert resolve_collection_argument( invocation_path, "src/pkg/test.py::test[a::b]" + ) == CollectionArgument( + path=invocation_path / "src/pkg/test.py", + parts=["test[a::b]"], + module_name=None, ) - assert ret == (invocation_path / "src/pkg/test.py", ["test[a::b]"]) def test_does_not_exist(self, invocation_path: Path) -> None: """Given a file/module that does not exist raises UsageError.""" @@ -212,9 +227,12 @@ def test_does_not_exist(self, invocation_path: Path) -> None: def test_absolute_paths_are_resolved_correctly(self, invocation_path: Path) -> None: """Absolute paths resolve back to absolute paths.""" full_path = str(invocation_path / "src") - assert resolve_collection_argument(invocation_path, full_path) == ( - Path(os.path.abspath("src")), - [], + assert resolve_collection_argument( + invocation_path, full_path + ) == CollectionArgument( + path=Path(os.path.abspath("src")), + parts=[], + module_name=None, ) # ensure full paths given in the command-line without the drive letter resolve @@ -222,7 +240,11 @@ def test_absolute_paths_are_resolved_correctly(self, invocation_path: Path) -> N drive, full_path_without_drive = os.path.splitdrive(full_path) assert resolve_collection_argument( invocation_path, full_path_without_drive - ) == (Path(os.path.abspath("src")), []) + ) == CollectionArgument( + path=Path(os.path.abspath("src")), + parts=[], + module_name=None, + ) def test_module_full_path_without_drive(pytester: Pytester) -> None: @@ -262,3 +284,34 @@ def test(fix): "* 1 passed in *", ] ) + + +def test_very_long_cmdline_arg(pytester: Pytester) -> None: + """ + Regression test for #11394. + + Note: we could not manage to actually reproduce the error with this code, we suspect + GitHub runners are configured to support very long paths, however decided to leave + the test in place in case this ever regresses in the future. + """ + pytester.makeconftest( + """ + import pytest + + def pytest_addoption(parser): + parser.addoption("--long-list", dest="long_list", action="store", default="all", help="List of things") + + @pytest.fixture(scope="module") + def specified_feeds(request): + list_string = request.config.getoption("--long-list") + return list_string.split(',') + """ + ) + pytester.makepyfile( + """ + def test_foo(specified_feeds): + assert len(specified_feeds) == 100_000 + """ + ) + result = pytester.runpytest("--long-list", ",".join(["helloworld"] * 100_000)) + result.stdout.fnmatch_lines("* 1 passed *") diff --git a/testing/test_mark.py b/testing/test_mark.py index e2d1a40c38a..1e51f9db18f 100644 --- a/testing/test_mark.py +++ b/testing/test_mark.py @@ -1,23 +1,24 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + import os import sys -from typing import List -from typing import Optional from unittest import mock -import pytest from _pytest.config import ExitCode from _pytest.mark import MarkGenerator from _pytest.mark.structures import EMPTY_PARAMETERSET_OPTION from _pytest.nodes import Collector from _pytest.nodes import Node from _pytest.pytester import Pytester +import pytest class TestMark: @pytest.mark.parametrize("attr", ["mark", "param"]) def test_pytest_exists_in_namespace_all(self, attr: str) -> None: module = sys.modules["pytest"] - assert attr in module.__all__ # type: ignore + assert attr in module.__all__ def test_pytest_mark_notcallable(self) -> None: mark = MarkGenerator(_ispytest=True) @@ -33,7 +34,7 @@ class SomeClass: assert pytest.mark.foo(some_function) is some_function marked_with_args = pytest.mark.foo.with_args(some_function) - assert marked_with_args is not some_function # type: ignore[comparison-overlap] + assert marked_with_args is not some_function assert pytest.mark.foo(SomeClass) is SomeClass assert pytest.mark.foo.with_args(SomeClass) is not SomeClass # type: ignore[comparison-overlap] @@ -41,7 +42,7 @@ class SomeClass: def test_pytest_mark_name_starts_with_underscore(self) -> None: mark = MarkGenerator(_ispytest=True) with pytest.raises(AttributeError): - mark._some_name + _ = mark._some_name def test_marked_class_run_twice(pytester: Pytester) -> None: @@ -213,7 +214,7 @@ def test_hello(): ], ) def test_mark_option( - expr: str, expected_passed: List[Optional[str]], pytester: Pytester + expr: str, expected_passed: list[str | None], pytester: Pytester ) -> None: pytester.makepyfile( """ @@ -232,12 +233,60 @@ def test_two(): assert passed_str == expected_passed +@pytest.mark.parametrize( + ("expr", "expected_passed"), + [ + ("car(color='red')", ["test_one"]), + ("car(color='red') or car(color='blue')", ["test_one", "test_two"]), + ("car and not car(temp=5)", ["test_one", "test_three"]), + ("car(temp=4)", ["test_one"]), + ("car(temp=4) or car(temp=5)", ["test_one", "test_two"]), + ("car(temp=4) and car(temp=5)", []), + ("car(temp=-5)", ["test_three"]), + ("car(ac=True)", ["test_one"]), + ("car(ac=False)", ["test_two"]), + ("car(ac=None)", ["test_three"]), # test NOT_NONE_SENTINEL + ], + ids=str, +) +def test_mark_option_with_kwargs( + expr: str, expected_passed: list[str | None], pytester: Pytester +) -> None: + pytester.makepyfile( + """ + import pytest + @pytest.mark.car + @pytest.mark.car(ac=True) + @pytest.mark.car(temp=4) + @pytest.mark.car(color="red") + def test_one(): + pass + @pytest.mark.car + @pytest.mark.car(ac=False) + @pytest.mark.car(temp=5) + @pytest.mark.car(color="blue") + def test_two(): + pass + @pytest.mark.car + @pytest.mark.car(ac=None) + @pytest.mark.car(temp=-5) + def test_three(): + pass + + """ + ) + rec = pytester.inline_run("-m", expr) + passed, skipped, fail = rec.listoutcomes() + passed_str = [x.nodeid.split("::")[-1] for x in passed] + assert passed_str == expected_passed + + @pytest.mark.parametrize( ("expr", "expected_passed"), [("interface", ["test_interface"]), ("not interface", ["test_nointer"])], ) def test_mark_option_custom( - expr: str, expected_passed: List[str], pytester: Pytester + expr: str, expected_passed: list[str], pytester: Pytester ) -> None: pytester.makeconftest( """ @@ -275,7 +324,7 @@ def test_nointer(): ], ) def test_keyword_option_custom( - expr: str, expected_passed: List[str], pytester: Pytester + expr: str, expected_passed: list[str], pytester: Pytester ) -> None: pytester.makepyfile( """ @@ -313,7 +362,7 @@ def test_keyword_option_considers_mark(pytester: Pytester) -> None: ], ) def test_keyword_option_parametrize( - expr: str, expected_passed: List[str], pytester: Pytester + expr: str, expected_passed: list[str], pytester: Pytester ) -> None: pytester.makepyfile( """ @@ -371,6 +420,10 @@ def test_func(arg): "not or", "at column 5: expected not OR left parenthesis OR identifier; got or", ), + ( + "nonexistent_mark(non_supported='kwarg')", + "Keyword expressions do not support call parameters", + ), ], ) def test_keyword_option_wrong_arguments( @@ -806,12 +859,12 @@ def test_2(self): pytester.makepyfile( conftest=""" import pytest - @pytest.hookimpl(hookwrapper=True) + @pytest.hookimpl(wrapper=True) def pytest_pycollect_makeitem(name): - outcome = yield + item = yield if name == "TestClass": - item = outcome.get_result() item.extra_keyword_matches.add("xxx") + return item """ ) reprec = pytester.inline_run(p.parent, "-s", "-k", keyword) @@ -871,17 +924,30 @@ def test_one(): assert 1 deselected_tests = dlist[0].items assert len(deselected_tests) == 1 - def test_no_match_directories_outside_the_suite(self, pytester: Pytester) -> None: + def test_no_match_directories_outside_the_suite( + self, + pytester: Pytester, + monkeypatch: pytest.MonkeyPatch, + ) -> None: """`-k` should not match against directories containing the test suite (#7040).""" - test_contents = """ - def test_aaa(): pass - def test_ddd(): pass - """ + pytester.makefile( + **{ + "suite/pytest": """[pytest]""", + }, + ext=".ini", + ) pytester.makepyfile( - **{"ddd/tests/__init__.py": "", "ddd/tests/test_foo.py": test_contents} + **{ + "suite/ddd/tests/__init__.py": "", + "suite/ddd/tests/test_foo.py": """ + def test_aaa(): pass + def test_ddd(): pass + """, + } ) + monkeypatch.chdir(pytester.path / "suite") - def get_collected_names(*args): + def get_collected_names(*args: str) -> list[str]: _, rec = pytester.inline_genitems(*args) calls = rec.getcalls("pytest_collection_finish") assert len(calls) == 1 @@ -893,12 +959,6 @@ def get_collected_names(*args): # do not collect anything based on names outside the collection tree assert get_collected_names("-k", pytester._name) == [] - # "-k ddd" should only collect "test_ddd", but not - # 'test_aaa' just because one of its parent directories is named "ddd"; - # this was matched previously because Package.name would contain the full path - # to the package - assert get_collected_names("-k", "ddd") == ["test_ddd"] - class TestMarkDecorator: @pytest.mark.parametrize( @@ -922,20 +982,19 @@ def test_aliases(self) -> None: @pytest.mark.parametrize("mark", [None, "", "skip", "xfail"]) def test_parameterset_for_parametrize_marks( - pytester: Pytester, mark: Optional[str] + pytester: Pytester, mark: str | None ) -> None: if mark is not None: pytester.makeini( - """ + f""" [pytest] - {}={} - """.format( - EMPTY_PARAMETERSET_OPTION, mark - ) + {EMPTY_PARAMETERSET_OPTION}={mark} + """ ) config = pytester.parseconfig() - from _pytest.mark import pytest_configure, get_empty_parameterset_mark + from _pytest.mark import get_empty_parameterset_mark + from _pytest.mark import pytest_configure pytest_configure(config) result_mark = get_empty_parameterset_mark(config, ["a"], all) @@ -950,16 +1009,15 @@ def test_parameterset_for_parametrize_marks( def test_parameterset_for_fail_at_collect(pytester: Pytester) -> None: pytester.makeini( - """ + f""" [pytest] - {}=fail_at_collect - """.format( - EMPTY_PARAMETERSET_OPTION - ) + {EMPTY_PARAMETERSET_OPTION}=fail_at_collect + """ ) config = pytester.parseconfig() - from _pytest.mark import pytest_configure, get_empty_parameterset_mark + from _pytest.mark import get_empty_parameterset_mark + from _pytest.mark import pytest_configure pytest_configure(config) @@ -990,6 +1048,32 @@ def test(): assert result.ret == ExitCode.INTERRUPTED +def test_paramset_empty_no_idfunc( + pytester: Pytester, monkeypatch: pytest.MonkeyPatch +) -> None: + """An empty parameter set should not call the user provided id function (#13031).""" + p1 = pytester.makepyfile( + """ + import pytest + + def idfunc(value): + raise ValueError() + @pytest.mark.parametrize("param", [], ids=idfunc) + def test(param): + pass + """ + ) + result = pytester.runpytest(p1, "-v", "-rs") + result.stdout.fnmatch_lines( + [ + "* collected 1 item", + "test_paramset_empty_no_idfunc* SKIPPED *", + "SKIPPED [1] test_paramset_empty_no_idfunc.py:5: got empty parameter set for (param)", + "*= 1 skipped in *", + ] + ) + + def test_parameterset_for_parametrize_bad_markname(pytester: Pytester) -> None: with pytest.raises(pytest.UsageError): test_parameterset_for_parametrize_marks(pytester, "bad") @@ -1086,7 +1170,11 @@ def test_pytest_param_id_requires_string() -> None: with pytest.raises(TypeError) as excinfo: pytest.param(id=True) # type: ignore[arg-type] (msg,) = excinfo.value.args - assert msg == "Expected id to be a string, got : True" + expected = ( + "Expected id to be a string or a `pytest.HIDDEN_PARAM` sentinel, " + "got : True" + ) + assert msg == expected @pytest.mark.parametrize("s", (None, "hello world")) @@ -1130,6 +1218,76 @@ class C(A, B): all_marks = get_unpacked_marks(C) - assert all_marks == [xfail("c").mark, xfail("a").mark, xfail("b").mark] + assert all_marks == [xfail("b").mark, xfail("a").mark, xfail("c").mark] assert get_unpacked_marks(C, consider_mro=False) == [xfail("c").mark] + + +# @pytest.mark.issue("https://github.com/pytest-dev/pytest/issues/10447") +def test_mark_fixture_order_mro(pytester: Pytester): + """This ensures we walk marks of the mro starting with the base classes + the action at a distance fixtures are taken as minimal example from a real project + + """ + foo = pytester.makepyfile( + """ + import pytest + + @pytest.fixture + def add_attr1(request): + request.instance.attr1 = object() + + + @pytest.fixture + def add_attr2(request): + request.instance.attr2 = request.instance.attr1 + + + @pytest.mark.usefixtures('add_attr1') + class Parent: + pass + + + @pytest.mark.usefixtures('add_attr2') + class TestThings(Parent): + def test_attrs(self): + assert self.attr1 == self.attr2 + """ + ) + result = pytester.runpytest(foo) + result.assert_outcomes(passed=1) + + +def test_mark_parametrize_over_staticmethod(pytester: Pytester) -> None: + """Check that applying marks works as intended on classmethods and staticmethods. + + Regression test for #12863. + """ + pytester.makepyfile( + """ + import pytest + + class TestClass: + @pytest.mark.parametrize("value", [1, 2]) + @classmethod + def test_classmethod_wrapper(cls, value: int): + assert value in [1, 2] + + @classmethod + @pytest.mark.parametrize("value", [1, 2]) + def test_classmethod_wrapper_on_top(cls, value: int): + assert value in [1, 2] + + @pytest.mark.parametrize("value", [1, 2]) + @staticmethod + def test_staticmethod_wrapper(value: int): + assert value in [1, 2] + + @staticmethod + @pytest.mark.parametrize("value", [1, 2]) + def test_staticmethod_wrapper_on_top(value: int): + assert value in [1, 2] + """ + ) + result = pytester.runpytest() + result.assert_outcomes(passed=8) diff --git a/testing/test_mark_expression.py b/testing/test_mark_expression.py index f3643e7b409..884c4b5af88 100644 --- a/testing/test_mark_expression.py +++ b/testing/test_mark_expression.py @@ -1,12 +1,17 @@ -from typing import Callable +from __future__ import annotations -import pytest +from collections.abc import Callable +from typing import cast + +from _pytest.mark import MarkMatcher from _pytest.mark.expression import Expression +from _pytest.mark.expression import MatcherCall from _pytest.mark.expression import ParseError +import pytest def evaluate(input: str, matcher: Callable[[str], bool]) -> bool: - return Expression.compile(input).evaluate(matcher) + return Expression.compile(input).evaluate(cast(MatcherCall, matcher)) def test_empty_is_false() -> None: @@ -61,7 +66,7 @@ def test_basic(expr: str, expected: bool) -> None: ("not not not not not true", False), ), ) -def test_syntax_oddeties(expr: str, expected: bool) -> None: +def test_syntax_oddities(expr: str, expected: bool) -> None: matcher = {"true": True, "false": False}.__getitem__ assert evaluate(expr, matcher) is expected @@ -151,6 +156,8 @@ def test_syntax_errors(expr: str, column: int, message: str) -> None: "1234", "1234abcd", "1234and", + "1234or", + "1234not", "notandor", "not_and_or", "not[and]or", @@ -193,3 +200,120 @@ def test_valid_idents(ident: str) -> None: def test_invalid_idents(ident: str) -> None: with pytest.raises(ParseError): evaluate(ident, lambda ident: True) + + +@pytest.mark.parametrize( + "expr, expected_error_msg", + ( + ("mark(True=False)", "unexpected reserved python keyword `True`"), + ("mark(def=False)", "unexpected reserved python keyword `def`"), + ("mark(class=False)", "unexpected reserved python keyword `class`"), + ("mark(if=False)", "unexpected reserved python keyword `if`"), + ("mark(else=False)", "unexpected reserved python keyword `else`"), + ("mark(valid=False, def=1)", "unexpected reserved python keyword `def`"), + ("mark(1)", "not a valid python identifier 1"), + ("mark(var:=False", "not a valid python identifier var:"), + ("mark(1=2)", "not a valid python identifier 1"), + ("mark(/=2)", "not a valid python identifier /"), + ("mark(var==", "expected identifier; got ="), + ("mark(var)", "expected =; got right parenthesis"), + ("mark(var=none)", 'unexpected character/s "none"'), + ("mark(var=1.1)", 'unexpected character/s "1.1"'), + ("mark(var=')", """closing quote "'" is missing"""), + ('mark(var=")', 'closing quote """ is missing'), + ("""mark(var="')""", 'closing quote """ is missing'), + ("""mark(var='")""", """closing quote "'" is missing"""), + ( + r"mark(var='\hugo')", + r'escaping with "\\" not supported in marker expression', + ), + ("mark(empty_list=[])", r'unexpected character/s "\[\]"'), + ("'str'", "expected not OR left parenthesis OR identifier; got string literal"), + ), +) +def test_invalid_kwarg_name_or_value( + expr: str, expected_error_msg: str, mark_matcher: MarkMatcher +) -> None: + with pytest.raises(ParseError, match=expected_error_msg): + assert evaluate(expr, mark_matcher) + + +@pytest.fixture(scope="session") +def mark_matcher() -> MarkMatcher: + markers = [ + pytest.mark.number_mark(a=1, b=2, c=3, d=999_999).mark, + pytest.mark.builtin_matchers_mark(x=True, y=False, z=None).mark, + pytest.mark.str_mark( # pylint: disable-next=non-ascii-name + m="M", space="with space", empty="", aaאבגדcc="aaאבגדcc", אבגד="אבגד" + ).mark, + ] + + return MarkMatcher.from_markers(markers) + + +@pytest.mark.parametrize( + "expr, expected", + ( + # happy cases + ("number_mark(a=1)", True), + ("number_mark(b=2)", True), + ("number_mark(a=1,b=2)", True), + ("number_mark(a=1, b=2)", True), + ("number_mark(d=999999)", True), + ("number_mark(a = 1,b= 2, c = 3)", True), + # sad cases + ("number_mark(a=6)", False), + ("number_mark(b=6)", False), + ("number_mark(a=1,b=6)", False), + ("number_mark(a=6,b=2)", False), + ("number_mark(a = 1,b= 2, c = 6)", False), + ("number_mark(a='1')", False), + ), +) +def test_keyword_expressions_with_numbers( + expr: str, expected: bool, mark_matcher: MarkMatcher +) -> None: + assert evaluate(expr, mark_matcher) is expected + + +@pytest.mark.parametrize( + "expr, expected", + ( + ("builtin_matchers_mark(x=True)", True), + ("builtin_matchers_mark(x=False)", False), + ("builtin_matchers_mark(y=True)", False), + ("builtin_matchers_mark(y=False)", True), + ("builtin_matchers_mark(z=None)", True), + ("builtin_matchers_mark(z=False)", False), + ("builtin_matchers_mark(z=True)", False), + ("builtin_matchers_mark(z=0)", False), + ("builtin_matchers_mark(z=1)", False), + ), +) +def test_builtin_matchers_keyword_expressions( + expr: str, expected: bool, mark_matcher: MarkMatcher +) -> None: + assert evaluate(expr, mark_matcher) is expected + + +@pytest.mark.parametrize( + "expr, expected", + ( + ("str_mark(m='M')", True), + ('str_mark(m="M")', True), + ("str_mark(aaאבגדcc='aaאבגדcc')", True), + ("str_mark(אבגד='אבגד')", True), + ("str_mark(space='with space')", True), + ("str_mark(empty='')", True), + ('str_mark(empty="")', True), + ("str_mark(m='wrong')", False), + ("str_mark(aaאבגדcc='wrong')", False), + ("str_mark(אבגד='wrong')", False), + ("str_mark(m='')", False), + ('str_mark(m="")', False), + ), +) +def test_str_keyword_expressions( + expr: str, expected: bool, mark_matcher: MarkMatcher +) -> None: + assert evaluate(expr, mark_matcher) is expected diff --git a/testing/test_meta.py b/testing/test_meta.py index 9201bd21611..e7d836f7ace 100644 --- a/testing/test_meta.py +++ b/testing/test_meta.py @@ -3,16 +3,18 @@ This ensures all internal packages can be imported without needing the pytest namespace being set, which is critical for the initialization of xdist. """ + +from __future__ import annotations + import pkgutil import subprocess import sys -from typing import List import _pytest import pytest -def _modules() -> List[str]: +def _modules() -> list[str]: pytest_pkg: str = _pytest.__path__ # type: ignore return sorted( n diff --git a/testing/test_monkeypatch.py b/testing/test_monkeypatch.py index 8175b5f0fad..0e992e298ec 100644 --- a/testing/test_monkeypatch.py +++ b/testing/test_monkeypatch.py @@ -1,19 +1,22 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + +from collections.abc import Generator import os +from pathlib import Path import re import sys import textwrap -from pathlib import Path -from typing import Dict -from typing import Generator -from typing import Type -import pytest +import setuptools + from _pytest.monkeypatch import MonkeyPatch from _pytest.pytester import Pytester +import pytest @pytest.fixture -def mp() -> Generator[MonkeyPatch, None, None]: +def mp() -> Generator[MonkeyPatch]: cwd = os.getcwd() sys_path = list(sys.path) yield MonkeyPatch() @@ -134,7 +137,7 @@ def test_setitem() -> None: def test_setitem_deleted_meanwhile() -> None: - d: Dict[str, object] = {} + d: dict[str, object] = {} monkeypatch = MonkeyPatch() monkeypatch.setitem(d, "x", 2) del d["x"] @@ -159,7 +162,7 @@ def test_setenv_deleted_meanwhile(before: bool) -> None: def test_delitem() -> None: - d: Dict[str, object] = {"x": 1} + d: dict[str, object] = {"x": 1} monkeypatch = MonkeyPatch() monkeypatch.delitem(d, "x") assert "x" not in d @@ -359,7 +362,7 @@ class SampleInherit(Sample): [Sample, SampleInherit], ids=["new", "new-inherit"], ) -def test_issue156_undo_staticmethod(Sample: Type[Sample]) -> None: +def test_issue156_undo_staticmethod(Sample: type[Sample]) -> None: monkeypatch = MonkeyPatch() monkeypatch.setattr(Sample, "hello", None) @@ -414,7 +417,7 @@ def test_context() -> None: with monkeypatch.context() as m: m.setattr(functools, "partial", 3) assert not inspect.isclass(functools.partial) - assert inspect.isclass(functools.partial) + assert inspect.isclass(functools.partial) # type:ignore[unreachable] def test_context_classmethod() -> None: @@ -428,6 +431,10 @@ class A: @pytest.mark.filterwarnings(r"ignore:.*\bpkg_resources\b:DeprecationWarning") +@pytest.mark.skipif( + int(setuptools.__version__.split(".")[0]) >= 80, + reason="modern setuptools removing pkg_resources", +) def test_syspath_prepend_with_namespace_packages( pytester: Pytester, monkeypatch: MonkeyPatch ) -> None: @@ -441,7 +448,7 @@ def test_syspath_prepend_with_namespace_packages( lib = ns.joinpath(dirname) lib.mkdir() lib.joinpath("__init__.py").write_text( - "def check(): return %r" % dirname, encoding="utf-8" + f"def check(): return {dirname!r}", encoding="utf-8" ) monkeypatch.syspath_prepend("hello") diff --git a/testing/test_nodes.py b/testing/test_nodes.py index df1439e1c49..f5f21e9775c 100644 --- a/testing/test_nodes.py +++ b/testing/test_nodes.py @@ -1,39 +1,16 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + +from pathlib import Path import re import warnings -from pathlib import Path -from typing import cast -from typing import List -from typing import Type -import pytest from _pytest import nodes from _pytest.compat import legacy_path from _pytest.outcomes import OutcomeException from _pytest.pytester import Pytester from _pytest.warning_types import PytestWarning - - -@pytest.mark.parametrize( - ("nodeid", "expected"), - ( - ("", [""]), - ("a", ["", "a"]), - ("aa/b", ["", "aa", "aa/b"]), - ("a/b/c", ["", "a", "a/b", "a/b/c"]), - ("a/bbb/c::D", ["", "a", "a/bbb", "a/bbb/c", "a/bbb/c::D"]), - ("a/b/c::D::eee", ["", "a", "a/b", "a/b/c", "a/b/c::D", "a/b/c::D::eee"]), - ("::xx", ["", "::xx"]), - # / only considered until first :: - ("a/b/c::D/d::e", ["", "a", "a/b", "a/b/c", "a/b/c::D/d", "a/b/c::D/d::e"]), - # : alone is not a separator. - ("a/b::D:e:f::g", ["", "a", "a/b", "a/b::D:e:f", "a/b::D:e:f::g"]), - # / not considered if a part of a test name - ("a/b::c/d::e[/test]", ["", "a", "a/b", "a/b::c/d", "a/b::c/d::e[/test]"]), - ), -) -def test_iterparentnodeids(nodeid: str, expected: List[str]) -> None: - result = list(nodes.iterparentnodeids(nodeid)) - assert result == expected +import pytest def test_node_from_parent_disallowed_arguments() -> None: @@ -63,7 +40,6 @@ def test_subclassing_both_item_and_collector_deprecated( Verifies we warn on diamond inheritance as well as correctly managing legacy inheritance constructors with missing args as found in plugins. """ - # We do not expect any warnings messages to issued during class definition. with warnings.catch_warnings(): warnings.simplefilter("error") @@ -73,6 +49,12 @@ def __init__(self, fspath, parent): """Legacy ctor with legacy call # don't wana see""" super().__init__(fspath, parent) + def collect(self): + raise NotImplementedError() + + def runtest(self): + raise NotImplementedError() + with pytest.warns(PytestWarning) as rec: SoWrong.from_parent( request.session, fspath=legacy_path(tmp_path / "broken.txt") @@ -91,7 +73,7 @@ def __init__(self, fspath, parent): "warn_type, msg", [(DeprecationWarning, "deprecated"), (PytestWarning, "pytest")] ) def test_node_warn_is_no_longer_only_pytest_warnings( - pytester: Pytester, warn_type: Type[Warning], msg: str + pytester: Pytester, warn_type: type[Warning], msg: str ) -> None: items = pytester.getitems( """ @@ -120,24 +102,15 @@ def test__check_initialpaths_for_relpath() -> None: """Ensure that it handles dirs, and does not always use dirname.""" cwd = Path.cwd() - class FakeSession1: - _initialpaths = frozenset({cwd}) + initial_paths = frozenset({cwd}) - session = cast(pytest.Session, FakeSession1) - - assert nodes._check_initialpaths_for_relpath(session, cwd) == "" + assert nodes._check_initialpaths_for_relpath(initial_paths, cwd) == "" sub = cwd / "file" - - class FakeSession2: - _initialpaths = frozenset({cwd}) - - session = cast(pytest.Session, FakeSession2) - - assert nodes._check_initialpaths_for_relpath(session, sub) == "file" + assert nodes._check_initialpaths_for_relpath(initial_paths, sub) == "file" outside = Path("/outside-this-does-not-exist") - assert nodes._check_initialpaths_for_relpath(session, outside) is None + assert nodes._check_initialpaths_for_relpath(initial_paths, outside) is None def test_failure_with_changed_cwd(pytester: Pytester) -> None: diff --git a/testing/test_nose.py b/testing/test_nose.py deleted file mode 100644 index e838e79ddd5..00000000000 --- a/testing/test_nose.py +++ /dev/null @@ -1,519 +0,0 @@ -import pytest -from _pytest.pytester import Pytester - - -def setup_module(mod): - mod.nose = pytest.importorskip("nose") - - -def test_nose_setup(pytester: Pytester) -> None: - p = pytester.makepyfile( - """ - values = [] - from nose.tools import with_setup - - @with_setup(lambda: values.append(1), lambda: values.append(2)) - def test_hello(): - assert values == [1] - - def test_world(): - assert values == [1,2] - - test_hello.setup = lambda: values.append(1) - test_hello.teardown = lambda: values.append(2) - """ - ) - result = pytester.runpytest(p, "-p", "nose") - result.assert_outcomes(passed=2) - - -def test_setup_func_with_setup_decorator() -> None: - from _pytest.nose import call_optional - - values = [] - - class A: - @pytest.fixture(autouse=True) - def f(self): - values.append(1) - - call_optional(A(), "f", "A.f") - assert not values - - -def test_setup_func_not_callable() -> None: - from _pytest.nose import call_optional - - class A: - f = 1 - - call_optional(A(), "f", "A.f") - - -def test_nose_setup_func(pytester: Pytester) -> None: - p = pytester.makepyfile( - """ - from nose.tools import with_setup - - values = [] - - def my_setup(): - a = 1 - values.append(a) - - def my_teardown(): - b = 2 - values.append(b) - - @with_setup(my_setup, my_teardown) - def test_hello(): - print(values) - assert values == [1] - - def test_world(): - print(values) - assert values == [1,2] - - """ - ) - result = pytester.runpytest(p, "-p", "nose") - result.assert_outcomes(passed=2) - - -def test_nose_setup_func_failure(pytester: Pytester) -> None: - p = pytester.makepyfile( - """ - from nose.tools import with_setup - - values = [] - my_setup = lambda x: 1 - my_teardown = lambda x: 2 - - @with_setup(my_setup, my_teardown) - def test_hello(): - print(values) - assert values == [1] - - def test_world(): - print(values) - assert values == [1,2] - - """ - ) - result = pytester.runpytest(p, "-p", "nose") - result.stdout.fnmatch_lines(["*TypeError: ()*"]) - - -def test_nose_setup_func_failure_2(pytester: Pytester) -> None: - pytester.makepyfile( - """ - values = [] - - my_setup = 1 - my_teardown = 2 - - def test_hello(): - assert values == [] - - test_hello.setup = my_setup - test_hello.teardown = my_teardown - """ - ) - reprec = pytester.inline_run() - reprec.assertoutcome(passed=1) - - -def test_nose_setup_partial(pytester: Pytester) -> None: - pytest.importorskip("functools") - p = pytester.makepyfile( - """ - from functools import partial - - values = [] - - def my_setup(x): - a = x - values.append(a) - - def my_teardown(x): - b = x - values.append(b) - - my_setup_partial = partial(my_setup, 1) - my_teardown_partial = partial(my_teardown, 2) - - def test_hello(): - print(values) - assert values == [1] - - def test_world(): - print(values) - assert values == [1,2] - - test_hello.setup = my_setup_partial - test_hello.teardown = my_teardown_partial - """ - ) - result = pytester.runpytest(p, "-p", "nose") - result.stdout.fnmatch_lines(["*2 passed*"]) - - -def test_module_level_setup(pytester: Pytester) -> None: - pytester.makepyfile( - """ - from nose.tools import with_setup - items = {} - - def setup(): - items.setdefault("setup", []).append("up") - - def teardown(): - items.setdefault("setup", []).append("down") - - def setup2(): - items.setdefault("setup2", []).append("up") - - def teardown2(): - items.setdefault("setup2", []).append("down") - - def test_setup_module_setup(): - assert items["setup"] == ["up"] - - def test_setup_module_setup_again(): - assert items["setup"] == ["up"] - - @with_setup(setup2, teardown2) - def test_local_setup(): - assert items["setup"] == ["up"] - assert items["setup2"] == ["up"] - - @with_setup(setup2, teardown2) - def test_local_setup_again(): - assert items["setup"] == ["up"] - assert items["setup2"] == ["up", "down", "up"] - """ - ) - result = pytester.runpytest("-p", "nose") - result.stdout.fnmatch_lines(["*4 passed*"]) - - -def test_nose_style_setup_teardown(pytester: Pytester) -> None: - pytester.makepyfile( - """ - values = [] - - def setup_module(): - values.append(1) - - def teardown_module(): - del values[0] - - def test_hello(): - assert values == [1] - - def test_world(): - assert values == [1] - """ - ) - result = pytester.runpytest("-p", "nose") - result.stdout.fnmatch_lines(["*2 passed*"]) - - -def test_fixtures_nose_setup_issue8394(pytester: Pytester) -> None: - pytester.makepyfile( - """ - def setup_module(): - pass - - def teardown_module(): - pass - - def setup_function(func): - pass - - def teardown_function(func): - pass - - def test_world(): - pass - - class Test(object): - def setup_class(cls): - pass - - def teardown_class(cls): - pass - - def setup_method(self, meth): - pass - - def teardown_method(self, meth): - pass - - def test_method(self): pass - """ - ) - match = "*no docstring available*" - result = pytester.runpytest("--fixtures") - assert result.ret == 0 - result.stdout.no_fnmatch_line(match) - - result = pytester.runpytest("--fixtures", "-v") - assert result.ret == 0 - result.stdout.fnmatch_lines([match, match, match, match]) - - -def test_nose_setup_ordering(pytester: Pytester) -> None: - pytester.makepyfile( - """ - def setup_module(mod): - mod.visited = True - - class TestClass(object): - def setup(self): - assert visited - self.visited_cls = True - def test_first(self): - assert visited - assert self.visited_cls - """ - ) - result = pytester.runpytest() - result.stdout.fnmatch_lines(["*1 passed*"]) - - -def test_apiwrapper_problem_issue260(pytester: Pytester) -> None: - # this would end up trying a call an optional teardown on the class - # for plain unittests we don't want nose behaviour - pytester.makepyfile( - """ - import unittest - class TestCase(unittest.TestCase): - def setup(self): - #should not be called in unittest testcases - assert 0, 'setup' - def teardown(self): - #should not be called in unittest testcases - assert 0, 'teardown' - def setUp(self): - print('setup') - def tearDown(self): - print('teardown') - def test_fun(self): - pass - """ - ) - result = pytester.runpytest() - result.assert_outcomes(passed=1) - - -def test_setup_teardown_linking_issue265(pytester: Pytester) -> None: - # we accidentally didn't integrate nose setupstate with normal setupstate - # this test ensures that won't happen again - pytester.makepyfile( - ''' - import pytest - - class TestGeneric(object): - def test_nothing(self): - """Tests the API of the implementation (for generic and specialized).""" - - @pytest.mark.skipif("True", reason= - "Skip tests to check if teardown is skipped as well.") - class TestSkipTeardown(TestGeneric): - - def setup(self): - """Sets up my specialized implementation for $COOL_PLATFORM.""" - raise Exception("should not call setup for skipped tests") - - def teardown(self): - """Undoes the setup.""" - raise Exception("should not call teardown for skipped tests") - ''' - ) - reprec = pytester.runpytest() - reprec.assert_outcomes(passed=1, skipped=1) - - -def test_SkipTest_during_collection(pytester: Pytester) -> None: - p = pytester.makepyfile( - """ - import nose - raise nose.SkipTest("during collection") - def test_failing(): - assert False - """ - ) - result = pytester.runpytest(p) - result.assert_outcomes(skipped=1, warnings=0) - - -def test_SkipTest_in_test(pytester: Pytester) -> None: - pytester.makepyfile( - """ - import nose - - def test_skipping(): - raise nose.SkipTest("in test") - """ - ) - reprec = pytester.inline_run() - reprec.assertoutcome(skipped=1) - - -def test_istest_function_decorator(pytester: Pytester) -> None: - p = pytester.makepyfile( - """ - import nose.tools - @nose.tools.istest - def not_test_prefix(): - pass - """ - ) - result = pytester.runpytest(p) - result.assert_outcomes(passed=1) - - -def test_nottest_function_decorator(pytester: Pytester) -> None: - pytester.makepyfile( - """ - import nose.tools - @nose.tools.nottest - def test_prefix(): - pass - """ - ) - reprec = pytester.inline_run() - assert not reprec.getfailedcollections() - calls = reprec.getreports("pytest_runtest_logreport") - assert not calls - - -def test_istest_class_decorator(pytester: Pytester) -> None: - p = pytester.makepyfile( - """ - import nose.tools - @nose.tools.istest - class NotTestPrefix(object): - def test_method(self): - pass - """ - ) - result = pytester.runpytest(p) - result.assert_outcomes(passed=1) - - -def test_nottest_class_decorator(pytester: Pytester) -> None: - pytester.makepyfile( - """ - import nose.tools - @nose.tools.nottest - class TestPrefix(object): - def test_method(self): - pass - """ - ) - reprec = pytester.inline_run() - assert not reprec.getfailedcollections() - calls = reprec.getreports("pytest_runtest_logreport") - assert not calls - - -def test_skip_test_with_unicode(pytester: Pytester) -> None: - pytester.makepyfile( - """\ - import unittest - class TestClass(): - def test_io(self): - raise unittest.SkipTest('😊') - """ - ) - result = pytester.runpytest() - result.stdout.fnmatch_lines(["* 1 skipped *"]) - - -def test_raises(pytester: Pytester) -> None: - pytester.makepyfile( - """ - from nose.tools import raises - - @raises(RuntimeError) - def test_raises_runtimeerror(): - raise RuntimeError - - @raises(Exception) - def test_raises_baseexception_not_caught(): - raise BaseException - - @raises(BaseException) - def test_raises_baseexception_caught(): - raise BaseException - """ - ) - result = pytester.runpytest("-vv") - result.stdout.fnmatch_lines( - [ - "test_raises.py::test_raises_runtimeerror PASSED*", - "test_raises.py::test_raises_baseexception_not_caught FAILED*", - "test_raises.py::test_raises_baseexception_caught PASSED*", - "*= FAILURES =*", - "*_ test_raises_baseexception_not_caught _*", - "", - "arg = (), kw = {}", - "", - " def newfunc(*arg, **kw):", - " try:", - "> func(*arg, **kw)", - "", - "*/nose/*: ", - "_ _ *", - "", - " @raises(Exception)", - " def test_raises_baseexception_not_caught():", - "> raise BaseException", - "E BaseException", - "", - "test_raises.py:9: BaseException", - "* 1 failed, 2 passed *", - ] - ) - - -def test_nose_setup_skipped_if_non_callable(pytester: Pytester) -> None: - """Regression test for #9391.""" - p = pytester.makepyfile( - __init__="", - setup=""" - """, - teardown=""" - """, - test_it=""" - from . import setup, teardown - - def test_it(): - pass - """, - ) - result = pytester.runpytest(p, "-p", "nose") - assert result.ret == 0 - - -@pytest.mark.parametrize("fixture_name", ("teardown", "teardown_class")) -def test_teardown_fixture_not_called_directly(fixture_name, pytester: Pytester) -> None: - """Regression test for #10597.""" - p = pytester.makepyfile( - f""" - import pytest - - class TestHello: - - @pytest.fixture - def {fixture_name}(self): - yield - - def test_hello(self, {fixture_name}): - assert True - """ - ) - result = pytester.runpytest(p, "-p", "nose") - assert result.ret == 0 diff --git a/testing/test_parseopt.py b/testing/test_parseopt.py index 1899abe153f..36db7b13989 100644 --- a/testing/test_parseopt.py +++ b/testing/test_parseopt.py @@ -1,16 +1,19 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + import argparse import locale import os +from pathlib import Path import shlex import subprocess import sys -from pathlib import Path -import pytest from _pytest.config import argparsing as parseopt from _pytest.config.exceptions import UsageError from _pytest.monkeypatch import MonkeyPatch from _pytest.pytester import Pytester +import pytest @pytest.fixture @@ -25,7 +28,7 @@ def test_no_help_by_default(self) -> None: def test_custom_prog(self, parser: parseopt.Parser) -> None: """Custom prog can be set for `argparse.ArgumentParser`.""" - assert parser._getparser().prog == os.path.basename(sys.argv[0]) + assert parser._getparser().prog == argparse.ArgumentParser().prog parser.prog = "custom-prog" assert parser._getparser().prog == "custom-prog" @@ -54,9 +57,6 @@ def test_argument_type(self) -> None: assert argument.type is str argument = parseopt.Argument("-t", dest="abc", type=float) assert argument.type is float - with pytest.warns(DeprecationWarning): - with pytest.raises(KeyError): - argument = parseopt.Argument("-t", dest="abc", type="choice") argument = parseopt.Argument( "-t", dest="abc", type=str, choices=["red", "blue"] ) @@ -127,6 +127,17 @@ def test_parse2(self, parser: parseopt.Parser) -> None: args = parser.parse([Path(".")]) assert getattr(args, parseopt.FILE_OR_DIR)[0] == "." + # Warning ignore because of: + # https://github.com/python/cpython/issues/85308 + # Can be removed once Python<3.12 support is dropped. + @pytest.mark.filterwarnings("ignore:'encoding' argument not specified") + def test_parse_from_file(self, parser: parseopt.Parser, tmp_path: Path) -> None: + tests = [".", "some.py::Test::test_method[param0]", "other/test_file.py"] + args_file = tmp_path / "tests.txt" + args_file.write_text("\n".join(tests), encoding="utf-8") + args = parser.parse([f"@{args_file.absolute()}"]) + assert getattr(args, parseopt.FILE_OR_DIR) == tests + def test_parse_known_args(self, parser: parseopt.Parser) -> None: parser.parse_known_args([Path(".")]) parser.addoption("--hello", action="store_true") @@ -290,9 +301,10 @@ def test_multiple_metavar_help(self, parser: parseopt.Parser) -> None: def test_argcomplete(pytester: Pytester, monkeypatch: MonkeyPatch) -> None: - try: - encoding = locale.getencoding() # New in Python 3.11, ignores utf-8 mode - except AttributeError: + if sys.version_info >= (3, 11): + # New in Python 3.11, ignores utf-8 mode + encoding = locale.getencoding() + else: encoding = locale.getpreferredencoding(False) try: bash_version = subprocess.run( @@ -316,9 +328,7 @@ def test_argcomplete(pytester: Pytester, monkeypatch: MonkeyPatch) -> None: # http://stackoverflow.com/q/12589419/1307905 # so we use bash fp.write( - 'COMP_WORDBREAKS="$COMP_WORDBREAKS" {} -m pytest 8>&1 9>&2'.format( - shlex.quote(sys.executable) - ) + f'COMP_WORDBREAKS="$COMP_WORDBREAKS" {shlex.quote(sys.executable)} -m pytest 8>&1 9>&2' ) # alternative would be extended Pytester.{run(),_run(),popen()} to be able # to handle a keyword argument env that replaces os.environ in popen or @@ -336,9 +346,7 @@ def test_argcomplete(pytester: Pytester, monkeypatch: MonkeyPatch) -> None: pytest.skip("argcomplete not available") elif not result.stdout.str(): pytest.skip( - "bash provided no output on stdout, argcomplete not available? (stderr={!r})".format( - result.stderr.str() - ) + f"bash provided no output on stdout, argcomplete not available? (stderr={result.stderr.str()!r})" ) else: result.stdout.fnmatch_lines(["--funcargs", "--fulltrace"]) diff --git a/testing/test_pastebin.py b/testing/test_pastebin.py index 86b231f8b5e..9b928e00c06 100644 --- a/testing/test_pastebin.py +++ b/testing/test_pastebin.py @@ -1,17 +1,19 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + import email.message import io -from typing import List -from typing import Union +from unittest import mock -import pytest from _pytest.monkeypatch import MonkeyPatch from _pytest.pytester import Pytester +import pytest class TestPasteCapture: @pytest.fixture - def pastebinlist(self, monkeypatch, request) -> List[Union[str, bytes]]: - pastebinlist: List[Union[str, bytes]] = [] + def pastebinlist(self, monkeypatch, request) -> list[str | bytes]: + pastebinlist: list[str | bytes] = [] plugin = request.config.pluginmanager.getplugin("pastebin") monkeypatch.setattr(plugin, "create_new_paste", pastebinlist.append) return pastebinlist @@ -89,23 +91,6 @@ class TestPaste: def pastebin(self, request): return request.config.pluginmanager.getplugin("pastebin") - @pytest.fixture - def mocked_urlopen_fail(self, monkeypatch: MonkeyPatch): - """Monkeypatch the actual urlopen call to emulate a HTTP Error 400.""" - calls = [] - - import urllib.error - import urllib.request - - def mocked(url, data): - calls.append((url, data)) - raise urllib.error.HTTPError( - url, 400, "Bad request", email.message.Message(), io.BytesIO() - ) - - monkeypatch.setattr(urllib.request, "urlopen", mocked) - return calls - @pytest.fixture def mocked_urlopen_invalid(self, monkeypatch: MonkeyPatch): """Monkeypatch the actual urlopen calls done by the internal plugin @@ -157,10 +142,33 @@ def test_pastebin_invalid_url(self, pastebin, mocked_urlopen_invalid) -> None: ) assert len(mocked_urlopen_invalid) == 1 - def test_pastebin_http_error(self, pastebin, mocked_urlopen_fail) -> None: - result = pastebin.create_new_paste(b"full-paste-contents") + def test_pastebin_http_error(self, pastebin) -> None: + import urllib.error + + with mock.patch( + "urllib.request.urlopen", + side_effect=urllib.error.HTTPError( + url="https://bpa.st", + code=400, + msg="Bad request", + hdrs=email.message.Message(), + fp=io.BytesIO(), + ), + ) as mock_urlopen: + result = pastebin.create_new_paste(b"full-paste-contents") assert result == "bad response: HTTP Error 400: Bad request" - assert len(mocked_urlopen_fail) == 1 + assert len(mock_urlopen.mock_calls) == 1 + + def test_pastebin_url_error(self, pastebin) -> None: + import urllib.error + + with mock.patch( + "urllib.request.urlopen", + side_effect=urllib.error.URLError("the url was bad"), + ) as mock_urlopen: + result = pastebin.create_new_paste(b"full-paste-contents") + assert result == "bad response: " + assert len(mock_urlopen.mock_calls) == 1 def test_create_new_paste(self, pastebin, mocked_urlopen) -> None: result = pastebin.create_new_paste(b"full-paste-contents") @@ -170,7 +178,7 @@ def test_create_new_paste(self, pastebin, mocked_urlopen) -> None: assert type(data) is bytes lexer = "text" assert url == "https://bpa.st" - assert "lexer=%s" % lexer in data.decode() + assert f"lexer={lexer}" in data.decode() assert "code=full-paste-contents" in data.decode() assert "expiry=1week" in data.decode() diff --git a/testing/test_pathlib.py b/testing/test_pathlib.py index 56c54e484da..65a4117812f 100644 --- a/testing/test_pathlib.py +++ b/testing/test_pathlib.py @@ -1,30 +1,65 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + +from collections.abc import Generator +from collections.abc import Iterator +from collections.abc import Sequence +import errno +import importlib.abc +import importlib.machinery import os.path +from pathlib import Path import pickle +import shutil import sys -import unittest.mock -from pathlib import Path from textwrap import dedent from types import ModuleType from typing import Any -from typing import Generator +import unittest.mock -import pytest +from _pytest.config import ExitCode from _pytest.monkeypatch import MonkeyPatch +from _pytest.pathlib import _import_module_using_spec from _pytest.pathlib import bestrelpath from _pytest.pathlib import commonpath +from _pytest.pathlib import compute_module_name +from _pytest.pathlib import CouldNotResolvePathError from _pytest.pathlib import ensure_deletable from _pytest.pathlib import fnmatch_ex from _pytest.pathlib import get_extended_length_path_str from _pytest.pathlib import get_lock_path from _pytest.pathlib import import_path +from _pytest.pathlib import ImportMode from _pytest.pathlib import ImportPathMismatchError from _pytest.pathlib import insert_missing_modules +from _pytest.pathlib import is_importable from _pytest.pathlib import maybe_delete_a_numbered_dir from _pytest.pathlib import module_name_from_path from _pytest.pathlib import resolve_package_path +from _pytest.pathlib import resolve_pkg_root_and_module_name +from _pytest.pathlib import safe_exists +from _pytest.pathlib import scandir +from _pytest.pathlib import spec_matches_module_path from _pytest.pathlib import symlink_or_skip from _pytest.pathlib import visit +from _pytest.pytester import Pytester +from _pytest.pytester import RunResult from _pytest.tmpdir import TempPathFactory +import pytest + + +@pytest.fixture(autouse=True) +def autouse_pytester(pytester: Pytester) -> None: + """ + Fixture to make pytester() being autouse for all tests in this module. + + pytester makes sure to restore sys.path to its previous state, and many tests in this module + import modules and change sys.path because of that, so common module names such as "test" or "test.conftest" + end up leaking to tests in other modules. + + Note: we might consider extracting the sys.path restoration aspect into its own fixture, and apply it + to the entire test suite always. + """ class TestFNMatcherPort: @@ -76,6 +111,15 @@ def test_not_matching(self, pattern: str, path: str) -> None: assert not fnmatch_ex(pattern, path) +@pytest.fixture(params=[True, False]) +def ns_param(request: pytest.FixtureRequest) -> bool: + """ + Simple parametrized fixture for tests which call import_path() with consider_namespace_packages + using True and False. + """ + return bool(request.param) + + class TestImportPath: """ @@ -85,7 +129,7 @@ class TestImportPath: """ @pytest.fixture(scope="session") - def path1(self, tmp_path_factory: TempPathFactory) -> Generator[Path, None, None]: + def path1(self, tmp_path_factory: TempPathFactory) -> Generator[Path]: path = tmp_path_factory.mktemp("path") self.setuptestfs(path) yield path @@ -146,182 +190,245 @@ def setuptestfs(self, path: Path) -> None: encoding="utf-8", ) - def test_smoke_test(self, path1: Path) -> None: - obj = import_path(path1 / "execfile.py", root=path1) - assert obj.x == 42 # type: ignore[attr-defined] + def test_smoke_test(self, path1: Path, ns_param: bool) -> None: + obj = import_path( + path1 / "execfile.py", root=path1, consider_namespace_packages=ns_param + ) + assert obj.x == 42 assert obj.__name__ == "execfile" - def test_import_path_missing_file(self, path1: Path) -> None: + def test_import_path_missing_file(self, path1: Path, ns_param: bool) -> None: with pytest.raises(ImportPathMismatchError): - import_path(path1 / "sampledir", root=path1) + import_path( + path1 / "sampledir", root=path1, consider_namespace_packages=ns_param + ) def test_renamed_dir_creates_mismatch( - self, tmp_path: Path, monkeypatch: MonkeyPatch + self, tmp_path: Path, monkeypatch: MonkeyPatch, ns_param: bool ) -> None: tmp_path.joinpath("a").mkdir() p = tmp_path.joinpath("a", "test_x123.py") p.touch() - import_path(p, root=tmp_path) + import_path(p, root=tmp_path, consider_namespace_packages=ns_param) tmp_path.joinpath("a").rename(tmp_path.joinpath("b")) with pytest.raises(ImportPathMismatchError): - import_path(tmp_path.joinpath("b", "test_x123.py"), root=tmp_path) + import_path( + tmp_path.joinpath("b", "test_x123.py"), + root=tmp_path, + consider_namespace_packages=ns_param, + ) # Errors can be ignored. monkeypatch.setenv("PY_IGNORE_IMPORTMISMATCH", "1") - import_path(tmp_path.joinpath("b", "test_x123.py"), root=tmp_path) + import_path( + tmp_path.joinpath("b", "test_x123.py"), + root=tmp_path, + consider_namespace_packages=ns_param, + ) # PY_IGNORE_IMPORTMISMATCH=0 does not ignore error. monkeypatch.setenv("PY_IGNORE_IMPORTMISMATCH", "0") with pytest.raises(ImportPathMismatchError): - import_path(tmp_path.joinpath("b", "test_x123.py"), root=tmp_path) + import_path( + tmp_path.joinpath("b", "test_x123.py"), + root=tmp_path, + consider_namespace_packages=ns_param, + ) - def test_messy_name(self, tmp_path: Path) -> None: + def test_messy_name(self, tmp_path: Path, ns_param: bool) -> None: # https://bitbucket.org/hpk42/py-trunk/issue/129 path = tmp_path / "foo__init__.py" path.touch() - module = import_path(path, root=tmp_path) + module = import_path(path, root=tmp_path, consider_namespace_packages=ns_param) assert module.__name__ == "foo__init__" - def test_dir(self, tmp_path: Path) -> None: + def test_dir(self, tmp_path: Path, ns_param: bool) -> None: p = tmp_path / "hello_123" p.mkdir() p_init = p / "__init__.py" p_init.touch() - m = import_path(p, root=tmp_path) + m = import_path(p, root=tmp_path, consider_namespace_packages=ns_param) assert m.__name__ == "hello_123" - m = import_path(p_init, root=tmp_path) + m = import_path(p_init, root=tmp_path, consider_namespace_packages=ns_param) assert m.__name__ == "hello_123" - def test_a(self, path1: Path) -> None: + def test_a(self, path1: Path, ns_param: bool) -> None: otherdir = path1 / "otherdir" - mod = import_path(otherdir / "a.py", root=path1) - assert mod.result == "got it" # type: ignore[attr-defined] + mod = import_path( + otherdir / "a.py", root=path1, consider_namespace_packages=ns_param + ) + assert mod.result == "got it" assert mod.__name__ == "otherdir.a" - def test_b(self, path1: Path) -> None: + def test_b(self, path1: Path, ns_param: bool) -> None: otherdir = path1 / "otherdir" - mod = import_path(otherdir / "b.py", root=path1) - assert mod.stuff == "got it" # type: ignore[attr-defined] + mod = import_path( + otherdir / "b.py", root=path1, consider_namespace_packages=ns_param + ) + assert mod.stuff == "got it" assert mod.__name__ == "otherdir.b" - def test_c(self, path1: Path) -> None: + def test_c(self, path1: Path, ns_param: bool) -> None: otherdir = path1 / "otherdir" - mod = import_path(otherdir / "c.py", root=path1) - assert mod.value == "got it" # type: ignore[attr-defined] + mod = import_path( + otherdir / "c.py", root=path1, consider_namespace_packages=ns_param + ) + assert mod.value == "got it" - def test_d(self, path1: Path) -> None: + def test_d(self, path1: Path, ns_param: bool) -> None: otherdir = path1 / "otherdir" - mod = import_path(otherdir / "d.py", root=path1) - assert mod.value2 == "got it" # type: ignore[attr-defined] + mod = import_path( + otherdir / "d.py", root=path1, consider_namespace_packages=ns_param + ) + assert mod.value2 == "got it" - def test_import_after(self, tmp_path: Path) -> None: + def test_import_after(self, tmp_path: Path, ns_param: bool) -> None: tmp_path.joinpath("xxxpackage").mkdir() tmp_path.joinpath("xxxpackage", "__init__.py").touch() mod1path = tmp_path.joinpath("xxxpackage", "module1.py") mod1path.touch() - mod1 = import_path(mod1path, root=tmp_path) + mod1 = import_path( + mod1path, root=tmp_path, consider_namespace_packages=ns_param + ) assert mod1.__name__ == "xxxpackage.module1" from xxxpackage import module1 assert module1 is mod1 def test_check_filepath_consistency( - self, monkeypatch: MonkeyPatch, tmp_path: Path + self, monkeypatch: MonkeyPatch, tmp_path: Path, ns_param: bool ) -> None: name = "pointsback123" p = tmp_path.joinpath(name + ".py") p.touch() - for ending in (".pyc", ".pyo"): - mod = ModuleType(name) - pseudopath = tmp_path.joinpath(name + ending) - pseudopath.touch() - mod.__file__ = str(pseudopath) - monkeypatch.setitem(sys.modules, name, mod) - newmod = import_path(p, root=tmp_path) - assert mod == newmod - monkeypatch.undo() + with monkeypatch.context() as mp: + for ending in (".pyc", ".pyo"): + mod = ModuleType(name) + pseudopath = tmp_path.joinpath(name + ending) + pseudopath.touch() + mod.__file__ = str(pseudopath) + mp.setitem(sys.modules, name, mod) + newmod = import_path( + p, root=tmp_path, consider_namespace_packages=ns_param + ) + assert mod == newmod mod = ModuleType(name) pseudopath = tmp_path.joinpath(name + "123.py") pseudopath.touch() mod.__file__ = str(pseudopath) monkeypatch.setitem(sys.modules, name, mod) with pytest.raises(ImportPathMismatchError) as excinfo: - import_path(p, root=tmp_path) + import_path(p, root=tmp_path, consider_namespace_packages=ns_param) modname, modfile, orig = excinfo.value.args assert modname == name assert modfile == str(pseudopath) assert orig == p assert issubclass(ImportPathMismatchError, ImportError) - def test_issue131_on__init__(self, tmp_path: Path) -> None: - # __init__.py files may be namespace packages, and thus the - # __file__ of an imported module may not be ourselves - # see issue - tmp_path.joinpath("proja").mkdir() - p1 = tmp_path.joinpath("proja", "__init__.py") - p1.touch() - tmp_path.joinpath("sub", "proja").mkdir(parents=True) - p2 = tmp_path.joinpath("sub", "proja", "__init__.py") - p2.touch() - m1 = import_path(p1, root=tmp_path) - m2 = import_path(p2, root=tmp_path) - assert m1 == m2 - - def test_ensuresyspath_append(self, tmp_path: Path) -> None: + def test_ensuresyspath_append(self, tmp_path: Path, ns_param: bool) -> None: root1 = tmp_path / "root1" root1.mkdir() file1 = root1 / "x123.py" file1.touch() assert str(root1) not in sys.path - import_path(file1, mode="append", root=tmp_path) + import_path( + file1, mode="append", root=tmp_path, consider_namespace_packages=ns_param + ) assert str(root1) == sys.path[-1] assert str(root1) not in sys.path[:-1] - def test_invalid_path(self, tmp_path: Path) -> None: + def test_invalid_path(self, tmp_path: Path, ns_param: bool) -> None: with pytest.raises(ImportError): - import_path(tmp_path / "invalid.py", root=tmp_path) + import_path( + tmp_path / "invalid.py", + root=tmp_path, + consider_namespace_packages=ns_param, + ) @pytest.fixture - def simple_module(self, tmp_path: Path) -> Path: - fn = tmp_path / "_src/tests/mymod.py" + def simple_module( + self, tmp_path: Path, request: pytest.FixtureRequest + ) -> Iterator[Path]: + name = f"mymod_{request.node.name}" + fn = tmp_path / f"_src/tests/{name}.py" fn.parent.mkdir(parents=True) fn.write_text("def foo(x): return 40 + x", encoding="utf-8") - return fn - - def test_importmode_importlib(self, simple_module: Path, tmp_path: Path) -> None: + module_name = module_name_from_path(fn, root=tmp_path) + yield fn + sys.modules.pop(module_name, None) + + def test_importmode_importlib( + self, + simple_module: Path, + tmp_path: Path, + request: pytest.FixtureRequest, + ns_param: bool, + ) -> None: """`importlib` mode does not change sys.path.""" - module = import_path(simple_module, mode="importlib", root=tmp_path) - assert module.foo(2) == 42 # type: ignore[attr-defined] + module = import_path( + simple_module, + mode="importlib", + root=tmp_path, + consider_namespace_packages=ns_param, + ) + assert module.foo(2) == 42 assert str(simple_module.parent) not in sys.path assert module.__name__ in sys.modules - assert module.__name__ == "_src.tests.mymod" + assert module.__name__ == f"_src.tests.mymod_{request.node.name}" assert "_src" in sys.modules assert "_src.tests" in sys.modules - def test_importmode_twice_is_different_module( - self, simple_module: Path, tmp_path: Path + def test_remembers_previous_imports( + self, simple_module: Path, tmp_path: Path, ns_param: bool ) -> None: - """`importlib` mode always returns a new module.""" - module1 = import_path(simple_module, mode="importlib", root=tmp_path) - module2 = import_path(simple_module, mode="importlib", root=tmp_path) - assert module1 is not module2 + """`importlib` mode called remembers previous module (#10341, #10811).""" + module1 = import_path( + simple_module, + mode="importlib", + root=tmp_path, + consider_namespace_packages=ns_param, + ) + module2 = import_path( + simple_module, + mode="importlib", + root=tmp_path, + consider_namespace_packages=ns_param, + ) + assert module1 is module2 def test_no_meta_path_found( - self, simple_module: Path, monkeypatch: MonkeyPatch, tmp_path: Path + self, + simple_module: Path, + monkeypatch: MonkeyPatch, + tmp_path: Path, + ns_param: bool, ) -> None: """Even without any meta_path should still import module.""" monkeypatch.setattr(sys, "meta_path", []) - module = import_path(simple_module, mode="importlib", root=tmp_path) - assert module.foo(2) == 42 # type: ignore[attr-defined] + module = import_path( + simple_module, + mode="importlib", + root=tmp_path, + consider_namespace_packages=ns_param, + ) + assert module.foo(2) == 42 # mode='importlib' fails if no spec is found to load the module import importlib.util + # Force module to be re-imported. + del sys.modules[module.__name__] + monkeypatch.setattr( - importlib.util, "spec_from_file_location", lambda *args: None + importlib.util, "spec_from_file_location", lambda *args, **kwargs: None ) with pytest.raises(ImportError): - import_path(simple_module, mode="importlib", root=tmp_path) + import_path( + simple_module, + mode="importlib", + root=tmp_path, + consider_namespace_packages=False, + ) def test_resolve_package_path(tmp_path: Path) -> None: @@ -331,18 +438,18 @@ def test_resolve_package_path(tmp_path: Path) -> None: (pkg / "subdir").mkdir() (pkg / "subdir/__init__.py").touch() assert resolve_package_path(pkg) == pkg - assert resolve_package_path(pkg.joinpath("subdir", "__init__.py")) == pkg + assert resolve_package_path(pkg / "subdir/__init__.py") == pkg def test_package_unimportable(tmp_path: Path) -> None: pkg = tmp_path / "pkg1-1" pkg.mkdir() pkg.joinpath("__init__.py").touch() - subdir = pkg.joinpath("subdir") + subdir = pkg / "subdir" subdir.mkdir() - pkg.joinpath("subdir/__init__.py").touch() + (pkg / "subdir/__init__.py").touch() assert resolve_package_path(subdir) == subdir - xyz = subdir.joinpath("xyz.py") + xyz = subdir / "xyz.py" xyz.touch() assert resolve_package_path(xyz) == subdir assert not resolve_package_path(pkg) @@ -457,12 +564,39 @@ def test_samefile_false_negatives(tmp_path: Path, monkeypatch: MonkeyPatch) -> N # the paths too. Using a context to narrow the patch as much as possible given # this is an important system function. mp.setattr(os.path, "samefile", lambda x, y: False) - module = import_path(module_path, root=tmp_path) + module = import_path( + module_path, root=tmp_path, consider_namespace_packages=False + ) assert getattr(module, "foo")() == 42 +def test_scandir_with_non_existent_directory() -> None: + # Test with a directory that does not exist + non_existent_dir = "path_to_non_existent_dir" + result = scandir(non_existent_dir) + # Assert that the result is an empty list + assert result == [] + + +def test_scandir_handles_os_error() -> None: + # Create a mock entry that will raise an OSError when is_file is called + mock_entry = unittest.mock.MagicMock() + mock_entry.is_file.side_effect = OSError("some permission error") + # Mock os.scandir to return an iterator with our mock entry + with unittest.mock.patch("os.scandir") as mock_scandir: + mock_scandir.return_value.__enter__.return_value = [mock_entry] + # Call the scandir function with a path + # We expect an OSError to be raised here + with pytest.raises(OSError, match="some permission error"): + scandir("/fake/path") + # Verify that the is_file method was called on the mock entry + mock_entry.is_file.assert_called_once() + + class TestImportLibMode: - def test_importmode_importlib_with_dataclass(self, tmp_path: Path) -> None: + def test_importmode_importlib_with_dataclass( + self, tmp_path: Path, ns_param: bool + ) -> None: """Ensure that importlib mode works with a module containing dataclasses (#7856).""" fn = tmp_path.joinpath("_src/tests/test_dataclass.py") fn.parent.mkdir(parents=True) @@ -479,13 +613,23 @@ class Data: encoding="utf-8", ) - module = import_path(fn, mode="importlib", root=tmp_path) + module = import_path( + fn, mode="importlib", root=tmp_path, consider_namespace_packages=ns_param + ) Data: Any = getattr(module, "Data") data = Data(value="foo") assert data.value == "foo" assert data.__module__ == "_src.tests.test_dataclass" - def test_importmode_importlib_with_pickle(self, tmp_path: Path) -> None: + # Ensure we do not import the same module again (#11475). + module2 = import_path( + fn, mode="importlib", root=tmp_path, consider_namespace_packages=ns_param + ) + assert module is module2 + + def test_importmode_importlib_with_pickle( + self, tmp_path: Path, ns_param: bool + ) -> None: """Ensure that importlib mode works with pickle (#7859).""" fn = tmp_path.joinpath("_src/tests/test_pickle.py") fn.parent.mkdir(parents=True) @@ -505,13 +649,21 @@ def round_trip(): encoding="utf-8", ) - module = import_path(fn, mode="importlib", root=tmp_path) + module = import_path( + fn, mode="importlib", root=tmp_path, consider_namespace_packages=ns_param + ) round_trip = getattr(module, "round_trip") action = round_trip() assert action() == 42 + # Ensure we do not import the same module again (#11475). + module2 = import_path( + fn, mode="importlib", root=tmp_path, consider_namespace_packages=ns_param + ) + assert module is module2 + def test_importmode_importlib_with_pickle_separate_modules( - self, tmp_path: Path + self, tmp_path: Path, ns_param: bool ) -> None: """ Ensure that importlib mode works can load pickles that look similar but are @@ -555,10 +707,14 @@ def round_trip(obj): s = pickle.dumps(obj) return pickle.loads(s) - module = import_path(fn1, mode="importlib", root=tmp_path) + module = import_path( + fn1, mode="importlib", root=tmp_path, consider_namespace_packages=ns_param + ) Data1 = getattr(module, "Data") - module = import_path(fn2, mode="importlib", root=tmp_path) + module = import_path( + fn2, mode="importlib", root=tmp_path, consider_namespace_packages=ns_param + ) Data2 = getattr(module, "Data") assert round_trip(Data1(20)) == Data1(20) @@ -574,6 +730,64 @@ def test_module_name_from_path(self, tmp_path: Path) -> None: result = module_name_from_path(Path("/home/foo/test_foo.py"), Path("/bar")) assert result == "home.foo.test_foo" + # Importing __init__.py files should return the package as module name. + result = module_name_from_path(tmp_path / "src/app/__init__.py", tmp_path) + assert result == "src.app" + + # Unless __init__.py file is at the root, in which case we cannot have an empty module name. + result = module_name_from_path(tmp_path / "__init__.py", tmp_path) + assert result == "__init__" + + # Modules which start with "." are considered relative and will not be imported + # unless part of a package, so we replace it with a "_" when generating the fake module name. + result = module_name_from_path(tmp_path / ".env/tests/test_foo.py", tmp_path) + assert result == "_env.tests.test_foo" + + # We want to avoid generating extra intermediate modules if some directory just happens + # to contain a "." in the name. + result = module_name_from_path( + tmp_path / ".env.310/tests/test_foo.py", tmp_path + ) + assert result == "_env_310.tests.test_foo" + + def test_resolve_pkg_root_and_module_name( + self, tmp_path: Path, monkeypatch: MonkeyPatch, pytester: Pytester + ) -> None: + # Create a directory structure first without __init__.py files. + (tmp_path / "src/app/core").mkdir(parents=True) + models_py = tmp_path / "src/app/core/models.py" + models_py.touch() + + with pytest.raises(CouldNotResolvePathError): + _ = resolve_pkg_root_and_module_name(models_py) + + # Create the __init__.py files, it should now resolve to a proper module name. + (tmp_path / "src/app/__init__.py").touch() + (tmp_path / "src/app/core/__init__.py").touch() + assert resolve_pkg_root_and_module_name( + models_py, consider_namespace_packages=True + ) == ( + tmp_path / "src", + "app.core.models", + ) + + # If we add tmp_path to sys.path, src becomes a namespace package. + monkeypatch.syspath_prepend(tmp_path) + validate_namespace_package(pytester, [tmp_path], ["src.app.core.models"]) + + assert resolve_pkg_root_and_module_name( + models_py, consider_namespace_packages=True + ) == ( + tmp_path, + "src.app.core.models", + ) + assert resolve_pkg_root_and_module_name( + models_py, consider_namespace_packages=False + ) == ( + tmp_path / "src", + "app.core.models", + ) + def test_insert_missing_modules( self, monkeypatch: MonkeyPatch, tmp_path: Path ) -> None: @@ -592,3 +806,952 @@ def test_insert_missing_modules( modules = {} insert_missing_modules(modules, "") assert modules == {} + + @pytest.mark.parametrize("b_is_package", [True, False]) + @pytest.mark.parametrize("insert_modules", [True, False]) + def test_import_module_using_spec( + self, b_is_package, insert_modules, tmp_path: Path + ): + """ + Verify that `_import_module_using_spec` can obtain a spec based on the path, thereby enabling the import. + When importing, not only the target module is imported, but also the parent modules are recursively imported. + """ + file_path = tmp_path / "a/b/c/demo.py" + file_path.parent.mkdir(parents=True) + file_path.write_text("my_name='demo'", encoding="utf-8") + + if b_is_package: + (tmp_path / "a/b/__init__.py").write_text( + "my_name='b.__init__'", encoding="utf-8" + ) + + mod = _import_module_using_spec( + "a.b.c.demo", + file_path, + file_path.parent, + insert_modules=insert_modules, + ) + + # target module is imported + assert mod is not None + assert spec_matches_module_path(mod.__spec__, file_path) is True + + mod_demo = sys.modules["a.b.c.demo"] + assert "demo.py" in str(mod_demo) + assert mod_demo.my_name == "demo" # Imported and available for use + + # parent modules are recursively imported. + mod_a = sys.modules["a"] + mod_b = sys.modules["a.b"] + mod_c = sys.modules["a.b.c"] + + assert mod_a.b is mod_b + assert mod_a.b.c is mod_c + assert mod_a.b.c.demo is mod_demo + + assert "namespace" in str(mod_a).lower() + assert "namespace" in str(mod_c).lower() + + # Compatibility package and namespace package. + if b_is_package: + assert "namespace" not in str(mod_b).lower() + assert "__init__.py" in str(mod_b).lower() # Imported __init__.py + assert mod_b.my_name == "b.__init__" # Imported and available for use + + else: + assert "namespace" in str(mod_b).lower() + with pytest.raises(AttributeError): # Not imported __init__.py + assert mod_b.my_name + + def test_parent_contains_child_module_attribute( + self, monkeypatch: MonkeyPatch, tmp_path: Path + ): + monkeypatch.chdir(tmp_path) + # Use 'xxx' and 'xxy' as parent names as they are unlikely to exist and + # don't end up being imported. + modules = {"xxx.tests.foo": ModuleType("xxx.tests.foo")} + insert_missing_modules(modules, "xxx.tests.foo") + assert sorted(modules) == ["xxx", "xxx.tests", "xxx.tests.foo"] + assert modules["xxx"].tests is modules["xxx.tests"] + assert modules["xxx.tests"].foo is modules["xxx.tests.foo"] + + def test_importlib_package( + self, monkeypatch: MonkeyPatch, tmp_path: Path, ns_param: bool + ): + """ + Importing a package using --importmode=importlib should not import the + package's __init__.py file more than once (#11306). + """ + monkeypatch.chdir(tmp_path) + monkeypatch.syspath_prepend(tmp_path) + + package_name = "importlib_import_package" + tmp_path.joinpath(package_name).mkdir() + init = tmp_path.joinpath(f"{package_name}/__init__.py") + init.write_text( + dedent( + """ + from .singleton import Singleton + + instance = Singleton() + """ + ), + encoding="ascii", + ) + singleton = tmp_path.joinpath(f"{package_name}/singleton.py") + singleton.write_text( + dedent( + """ + class Singleton: + INSTANCES = [] + + def __init__(self) -> None: + self.INSTANCES.append(self) + if len(self.INSTANCES) > 1: + raise RuntimeError("Already initialized") + """ + ), + encoding="ascii", + ) + + mod = import_path( + init, + root=tmp_path, + mode=ImportMode.importlib, + consider_namespace_packages=ns_param, + ) + assert len(mod.instance.INSTANCES) == 1 + # Ensure we do not import the same module again (#11475). + mod2 = import_path( + init, + root=tmp_path, + mode=ImportMode.importlib, + consider_namespace_packages=ns_param, + ) + assert mod is mod2 + + def test_importlib_root_is_package(self, pytester: Pytester) -> None: + """ + Regression for importing a `__init__`.py file that is at the root + (#11417). + """ + pytester.makepyfile(__init__="") + pytester.makepyfile( + """ + def test_my_test(): + assert True + """ + ) + + result = pytester.runpytest("--import-mode=importlib") + result.stdout.fnmatch_lines("* 1 passed *") + + @pytest.mark.parametrize("name", ["code", "time", "math"]) + def test_importlib_same_name_as_stl( + self, pytester, ns_param: bool, tmp_path: Path, name: str + ): + """Import a namespace package with the same name as the standard library (#13026).""" + file_path = pytester.path / f"{name}/foo/test_demo.py" + file_path.parent.mkdir(parents=True) + file_path.write_text( + dedent( + """ + def test_demo(): + pass + """ + ), + encoding="utf-8", + ) + + # unit test + __import__(name) # import standard library + + import_path( # import user files + file_path, + mode=ImportMode.importlib, + root=pytester.path, + consider_namespace_packages=ns_param, + ) + + # E2E test + result = pytester.runpytest("--import-mode=importlib") + result.stdout.fnmatch_lines("* 1 passed *") + + def create_installed_doctests_and_tests_dir( + self, path: Path, monkeypatch: MonkeyPatch + ) -> tuple[Path, Path, Path]: + """ + Create a directory structure where the application code is installed in a virtual environment, + and the tests are in an outside ".tests" directory. + + Return the paths to the core module (installed in the virtualenv), and the test modules. + """ + app = path / "src/app" + app.mkdir(parents=True) + (app / "__init__.py").touch() + core_py = app / "core.py" + core_py.write_text( + dedent( + """ + def foo(): + ''' + >>> 1 + 1 + 2 + ''' + """ + ), + encoding="ascii", + ) + + # Install it into a site-packages directory, and add it to sys.path, mimicking what + # happens when installing into a virtualenv. + site_packages = path / ".env/lib/site-packages" + site_packages.mkdir(parents=True) + shutil.copytree(app, site_packages / "app") + assert (site_packages / "app/core.py").is_file() + + monkeypatch.syspath_prepend(site_packages) + + # Create the tests files, outside 'src' and the virtualenv. + # We use the same test name on purpose, but in different directories, to ensure + # this works as advertised. + conftest_path1 = path / ".tests/a/conftest.py" + conftest_path1.parent.mkdir(parents=True) + conftest_path1.write_text( + dedent( + """ + import pytest + @pytest.fixture + def a_fix(): return "a" + """ + ), + encoding="ascii", + ) + test_path1 = path / ".tests/a/test_core.py" + test_path1.write_text( + dedent( + """ + import app.core + def test(a_fix): + assert a_fix == "a" + """, + ), + encoding="ascii", + ) + + conftest_path2 = path / ".tests/b/conftest.py" + conftest_path2.parent.mkdir(parents=True) + conftest_path2.write_text( + dedent( + """ + import pytest + @pytest.fixture + def b_fix(): return "b" + """ + ), + encoding="ascii", + ) + + test_path2 = path / ".tests/b/test_core.py" + test_path2.write_text( + dedent( + """ + import app.core + def test(b_fix): + assert b_fix == "b" + """, + ), + encoding="ascii", + ) + return (site_packages / "app/core.py"), test_path1, test_path2 + + def test_import_using_normal_mechanism_first( + self, monkeypatch: MonkeyPatch, pytester: Pytester, ns_param: bool + ) -> None: + """ + Test import_path imports from the canonical location when possible first, only + falling back to its normal flow when the module being imported is not reachable via sys.path (#11475). + """ + core_py, test_path1, test_path2 = self.create_installed_doctests_and_tests_dir( + pytester.path, monkeypatch + ) + + # core_py is reached from sys.path, so should be imported normally. + mod = import_path( + core_py, + mode="importlib", + root=pytester.path, + consider_namespace_packages=ns_param, + ) + assert mod.__name__ == "app.core" + assert mod.__file__ and Path(mod.__file__) == core_py + + # Ensure we do not import the same module again (#11475). + mod2 = import_path( + core_py, + mode="importlib", + root=pytester.path, + consider_namespace_packages=ns_param, + ) + assert mod is mod2 + + # tests are not reachable from sys.path, so they are imported as a standalone modules. + # Instead of '.tests.a.test_core', we import as "_tests.a.test_core" because + # importlib considers module names starting with '.' to be local imports. + mod = import_path( + test_path1, + mode="importlib", + root=pytester.path, + consider_namespace_packages=ns_param, + ) + assert mod.__name__ == "_tests.a.test_core" + + # Ensure we do not import the same module again (#11475). + mod2 = import_path( + test_path1, + mode="importlib", + root=pytester.path, + consider_namespace_packages=ns_param, + ) + assert mod is mod2 + + mod = import_path( + test_path2, + mode="importlib", + root=pytester.path, + consider_namespace_packages=ns_param, + ) + assert mod.__name__ == "_tests.b.test_core" + + # Ensure we do not import the same module again (#11475). + mod2 = import_path( + test_path2, + mode="importlib", + root=pytester.path, + consider_namespace_packages=ns_param, + ) + assert mod is mod2 + + def test_import_using_normal_mechanism_first_integration( + self, monkeypatch: MonkeyPatch, pytester: Pytester, ns_param: bool + ) -> None: + """ + Same test as above, but verify the behavior calling pytest. + + We should not make this call in the same test as above, as the modules have already + been imported by separate import_path() calls. + """ + core_py, test_path1, test_path2 = self.create_installed_doctests_and_tests_dir( + pytester.path, monkeypatch + ) + result = pytester.runpytest( + "--import-mode=importlib", + "-o", + f"consider_namespace_packages={ns_param}", + "--doctest-modules", + "--pyargs", + "app", + "./.tests", + ) + result.stdout.fnmatch_lines( + [ + f"{core_py.relative_to(pytester.path)} . *", + f"{test_path1.relative_to(pytester.path)} . *", + f"{test_path2.relative_to(pytester.path)} . *", + "* 3 passed*", + ] + ) + + def test_import_path_imports_correct_file( + self, pytester: Pytester, ns_param: bool + ) -> None: + """ + Import the module by the given path, even if other module with the same name + is reachable from sys.path. + """ + pytester.syspathinsert() + # Create a 'x.py' module reachable from sys.path that raises AssertionError + # if imported. + x_at_root = pytester.path / "x.py" + x_at_root.write_text("raise AssertionError('x at root')", encoding="ascii") + + # Create another x.py module, but in some subdirectories to ensure it is not + # accessible from sys.path. + x_in_sub_folder = pytester.path / "a/b/x.py" + x_in_sub_folder.parent.mkdir(parents=True) + x_in_sub_folder.write_text("X = 'a/b/x'", encoding="ascii") + + # Import our x.py module from the subdirectories. + # The 'x.py' module from sys.path was not imported for sure because + # otherwise we would get an AssertionError. + mod = import_path( + x_in_sub_folder, + mode=ImportMode.importlib, + root=pytester.path, + consider_namespace_packages=ns_param, + ) + assert mod.__file__ and Path(mod.__file__) == x_in_sub_folder + assert mod.X == "a/b/x" + + mod2 = import_path( + x_in_sub_folder, + mode=ImportMode.importlib, + root=pytester.path, + consider_namespace_packages=ns_param, + ) + assert mod is mod2 + + # Attempt to import root 'x.py'. + with pytest.raises(AssertionError, match="x at root"): + _ = import_path( + x_at_root, + mode=ImportMode.importlib, + root=pytester.path, + consider_namespace_packages=ns_param, + ) + + +def test_safe_exists(tmp_path: Path) -> None: + d = tmp_path.joinpath("some_dir") + d.mkdir() + assert safe_exists(d) is True + + f = tmp_path.joinpath("some_file") + f.touch() + assert safe_exists(f) is True + + # Use unittest.mock() as a context manager to have a very narrow + # patch lifetime. + p = tmp_path.joinpath("some long filename" * 100) + with unittest.mock.patch.object( + Path, + "exists", + autospec=True, + side_effect=OSError(errno.ENAMETOOLONG, "name too long"), + ): + assert safe_exists(p) is False + + with unittest.mock.patch.object( + Path, + "exists", + autospec=True, + side_effect=ValueError("name too long"), + ): + assert safe_exists(p) is False + + +def test_import_sets_module_as_attribute(pytester: Pytester) -> None: + """Unittest test for #12194.""" + pytester.path.joinpath("foo/bar/baz").mkdir(parents=True) + pytester.path.joinpath("foo/__init__.py").touch() + pytester.path.joinpath("foo/bar/__init__.py").touch() + pytester.path.joinpath("foo/bar/baz/__init__.py").touch() + pytester.syspathinsert() + + # Import foo.bar.baz and ensure parent modules also ended up imported. + baz = import_path( + pytester.path.joinpath("foo/bar/baz/__init__.py"), + mode=ImportMode.importlib, + root=pytester.path, + consider_namespace_packages=False, + ) + assert baz.__name__ == "foo.bar.baz" + foo = sys.modules["foo"] + assert foo.__name__ == "foo" + bar = sys.modules["foo.bar"] + assert bar.__name__ == "foo.bar" + + # Check parent modules have an attribute pointing to their children. + assert bar.baz is baz + assert foo.bar is bar + + # Ensure we returned the "foo.bar" module cached in sys.modules. + bar_2 = import_path( + pytester.path.joinpath("foo/bar/__init__.py"), + mode=ImportMode.importlib, + root=pytester.path, + consider_namespace_packages=False, + ) + assert bar_2 is bar + + +def test_import_sets_module_as_attribute_without_init_files(pytester: Pytester) -> None: + """Similar to test_import_sets_module_as_attribute, but without __init__.py files.""" + pytester.path.joinpath("foo/bar").mkdir(parents=True) + pytester.path.joinpath("foo/bar/baz.py").touch() + pytester.syspathinsert() + + # Import foo.bar.baz and ensure parent modules also ended up imported. + baz = import_path( + pytester.path.joinpath("foo/bar/baz.py"), + mode=ImportMode.importlib, + root=pytester.path, + consider_namespace_packages=False, + ) + assert baz.__name__ == "foo.bar.baz" + foo = sys.modules["foo"] + assert foo.__name__ == "foo" + bar = sys.modules["foo.bar"] + assert bar.__name__ == "foo.bar" + + # Check parent modules have an attribute pointing to their children. + assert bar.baz is baz + assert foo.bar is bar + + # Ensure we returned the "foo.bar.baz" module cached in sys.modules. + baz_2 = import_path( + pytester.path.joinpath("foo/bar/baz.py"), + mode=ImportMode.importlib, + root=pytester.path, + consider_namespace_packages=False, + ) + assert baz_2 is baz + + +def test_import_sets_module_as_attribute_regression(pytester: Pytester) -> None: + """Regression test for #12194.""" + pytester.path.joinpath("foo/bar/baz").mkdir(parents=True) + pytester.path.joinpath("foo/__init__.py").touch() + pytester.path.joinpath("foo/bar/__init__.py").touch() + pytester.path.joinpath("foo/bar/baz/__init__.py").touch() + f = pytester.makepyfile( + """ + import foo + from foo.bar import baz + foo.bar.baz + + def test_foo() -> None: + pass + """ + ) + + pytester.syspathinsert() + result = pytester.runpython(f) + assert result.ret == 0 + + result = pytester.runpytest("--import-mode=importlib", "--doctest-modules") + assert result.ret == 0 + + +def test_import_submodule_not_namespace(pytester: Pytester) -> None: + """ + Regression test for importing a submodule 'foo.bar' while there is a 'bar' directory + reachable from sys.path -- ensuring the top-level module does not end up imported as a namespace + package. + + #12194 + https://github.com/pytest-dev/pytest/pull/12208#issuecomment-2056458432 + """ + pytester.syspathinsert() + # Create package 'foo' with a submodule 'bar'. + pytester.path.joinpath("foo").mkdir() + foo_path = pytester.path.joinpath("foo/__init__.py") + foo_path.touch() + bar_path = pytester.path.joinpath("foo/bar.py") + bar_path.touch() + # Create top-level directory in `sys.path` with the same name as that submodule. + pytester.path.joinpath("bar").mkdir() + + # Import `foo`, then `foo.bar`, and check they were imported from the correct location. + foo = import_path( + foo_path, + mode=ImportMode.importlib, + root=pytester.path, + consider_namespace_packages=False, + ) + bar = import_path( + bar_path, + mode=ImportMode.importlib, + root=pytester.path, + consider_namespace_packages=False, + ) + assert foo.__name__ == "foo" + assert bar.__name__ == "foo.bar" + assert foo.__file__ is not None + assert bar.__file__ is not None + assert Path(foo.__file__) == foo_path + assert Path(bar.__file__) == bar_path + + +class TestNamespacePackages: + """Test import_path support when importing from properly namespace packages.""" + + @pytest.fixture(autouse=True) + def setup_imports_tracking(self, monkeypatch: MonkeyPatch) -> None: + monkeypatch.setattr(sys, "pytest_namespace_packages_test", [], raising=False) + + def setup_directories( + self, tmp_path: Path, monkeypatch: MonkeyPatch | None, pytester: Pytester + ) -> tuple[Path, Path]: + # Use a code to guard against modules being imported more than once. + # This is a safeguard in case future changes break this invariant. + code = dedent( + """ + import sys + imported = getattr(sys, "pytest_namespace_packages_test", []) + assert __name__ not in imported, f"{__name__} already imported" + imported.append(__name__) + sys.pytest_namespace_packages_test = imported + """ + ) + + # Set up a namespace package "com.company", containing + # two subpackages, "app" and "calc". + (tmp_path / "src/dist1/com/company/app/core").mkdir(parents=True) + (tmp_path / "src/dist1/com/company/app/__init__.py").write_text( + code, encoding="UTF-8" + ) + (tmp_path / "src/dist1/com/company/app/core/__init__.py").write_text( + code, encoding="UTF-8" + ) + models_py = tmp_path / "src/dist1/com/company/app/core/models.py" + models_py.touch() + + (tmp_path / "src/dist2/com/company/calc/algo").mkdir(parents=True) + (tmp_path / "src/dist2/com/company/calc/__init__.py").write_text( + code, encoding="UTF-8" + ) + (tmp_path / "src/dist2/com/company/calc/algo/__init__.py").write_text( + code, encoding="UTF-8" + ) + algorithms_py = tmp_path / "src/dist2/com/company/calc/algo/algorithms.py" + algorithms_py.write_text(code, encoding="UTF-8") + + r = validate_namespace_package( + pytester, + [tmp_path / "src/dist1", tmp_path / "src/dist2"], + ["com.company.app.core.models", "com.company.calc.algo.algorithms"], + ) + assert r.ret == 0 + if monkeypatch is not None: + monkeypatch.syspath_prepend(tmp_path / "src/dist1") + monkeypatch.syspath_prepend(tmp_path / "src/dist2") + return models_py, algorithms_py + + @pytest.mark.parametrize("import_mode", ["prepend", "append", "importlib"]) + def test_resolve_pkg_root_and_module_name_ns_multiple_levels( + self, + tmp_path: Path, + monkeypatch: MonkeyPatch, + pytester: Pytester, + import_mode: str, + ) -> None: + models_py, algorithms_py = self.setup_directories( + tmp_path, monkeypatch, pytester + ) + + pkg_root, module_name = resolve_pkg_root_and_module_name( + models_py, consider_namespace_packages=True + ) + assert (pkg_root, module_name) == ( + tmp_path / "src/dist1", + "com.company.app.core.models", + ) + + mod = import_path( + models_py, mode=import_mode, root=tmp_path, consider_namespace_packages=True + ) + assert mod.__name__ == "com.company.app.core.models" + assert mod.__file__ == str(models_py) + + # Ensure we do not import the same module again (#11475). + mod2 = import_path( + models_py, mode=import_mode, root=tmp_path, consider_namespace_packages=True + ) + assert mod is mod2 + + pkg_root, module_name = resolve_pkg_root_and_module_name( + algorithms_py, consider_namespace_packages=True + ) + assert (pkg_root, module_name) == ( + tmp_path / "src/dist2", + "com.company.calc.algo.algorithms", + ) + + mod = import_path( + algorithms_py, + mode=import_mode, + root=tmp_path, + consider_namespace_packages=True, + ) + assert mod.__name__ == "com.company.calc.algo.algorithms" + assert mod.__file__ == str(algorithms_py) + + # Ensure we do not import the same module again (#11475). + mod2 = import_path( + algorithms_py, + mode=import_mode, + root=tmp_path, + consider_namespace_packages=True, + ) + assert mod is mod2 + + def test_ns_multiple_levels_import_rewrite_assertions( + self, + tmp_path: Path, + monkeypatch: MonkeyPatch, + pytester: Pytester, + ) -> None: + """Check assert rewriting with `--import-mode=importlib` (#12659).""" + self.setup_directories(tmp_path, monkeypatch, pytester) + code = dedent(""" + def test(): + assert "four lights" == "five lights" + """) + + # A case is in a subdirectory with an `__init__.py` file. + test_py = tmp_path / "src/dist2/com/company/calc/algo/test_demo.py" + test_py.write_text(code, encoding="UTF-8") + + pkg_root, module_name = resolve_pkg_root_and_module_name( + test_py, consider_namespace_packages=True + ) + assert (pkg_root, module_name) == ( + tmp_path / "src/dist2", + "com.company.calc.algo.test_demo", + ) + + result = pytester.runpytest("--import-mode=importlib", test_py) + + result.stdout.fnmatch_lines( + [ + "E AssertionError: assert 'four lights' == 'five lights'", + "E *", + "E - five lights*", + "E + four lights", + ] + ) + + def test_ns_multiple_levels_import_error( + self, + tmp_path: Path, + pytester: Pytester, + ) -> None: + # Trigger condition 1: ns and file with the same name + file = pytester.path / "cow/moo/moo.py" + file.parent.mkdir(parents=True) + file.write_text("data=123", encoding="utf-8") + + # Trigger condition 2: tests are located in ns + tests = pytester.path / "cow/moo/test_moo.py" + + tests.write_text( + dedent( + """ + from cow.moo.moo import data + + def test_moo(): + print(data) + """ + ), + encoding="utf-8", + ) + + result = pytester.runpytest("--import-mode=importlib") + assert result.ret == ExitCode.OK + + @pytest.mark.parametrize("import_mode", ["prepend", "append", "importlib"]) + def test_incorrect_namespace_package( + self, + tmp_path: Path, + monkeypatch: MonkeyPatch, + pytester: Pytester, + import_mode: str, + ) -> None: + models_py, algorithms_py = self.setup_directories( + tmp_path, monkeypatch, pytester + ) + # Namespace packages must not have an __init__.py at its top-level + # directory; if it does, it is no longer a namespace package, and we fall back + # to importing just the part of the package containing the __init__.py files. + (tmp_path / "src/dist1/com/__init__.py").touch() + + # Because of the __init__ file, 'com' is no longer a namespace package: + # 'com.company.app' is importable as a normal module. + # 'com.company.calc' is no longer importable because 'com' is not a namespace package anymore. + r = validate_namespace_package( + pytester, + [tmp_path / "src/dist1", tmp_path / "src/dist2"], + ["com.company.app.core.models", "com.company.calc.algo.algorithms"], + ) + assert r.ret == 1 + r.stderr.fnmatch_lines("*No module named 'com.company.calc*") + + pkg_root, module_name = resolve_pkg_root_and_module_name( + models_py, consider_namespace_packages=True + ) + assert (pkg_root, module_name) == ( + tmp_path / "src/dist1", + "com.company.app.core.models", + ) + + # dist2/com/company will contain a normal Python package. + pkg_root, module_name = resolve_pkg_root_and_module_name( + algorithms_py, consider_namespace_packages=True + ) + assert (pkg_root, module_name) == ( + tmp_path / "src/dist2/com/company", + "calc.algo.algorithms", + ) + + def test_detect_meta_path( + self, + tmp_path: Path, + monkeypatch: MonkeyPatch, + pytester: Pytester, + ) -> None: + """ + resolve_pkg_root_and_module_name() considers sys.meta_path when importing namespace packages. + + Regression test for #12112. + """ + + class CustomImporter(importlib.abc.MetaPathFinder): + """ + Imports the module name "com" as a namespace package. + + This ensures our namespace detection considers sys.meta_path, which is important + to support all possible ways a module can be imported (for example editable installs). + """ + + def find_spec( + self, name: str, path: Any = None, target: Any = None + ) -> importlib.machinery.ModuleSpec | None: + if name == "com": + spec = importlib.machinery.ModuleSpec("com", loader=None) + spec.submodule_search_locations = [str(com_root_2), str(com_root_1)] + return spec + return None + + # Setup directories without configuring sys.path. + models_py, algorithms_py = self.setup_directories( + tmp_path, monkeypatch=None, pytester=pytester + ) + com_root_1 = tmp_path / "src/dist1/com" + com_root_2 = tmp_path / "src/dist2/com" + + # Because the namespace package is not setup correctly, we cannot resolve it as a namespace package. + pkg_root, module_name = resolve_pkg_root_and_module_name( + models_py, consider_namespace_packages=True + ) + assert (pkg_root, module_name) == ( + tmp_path / "src/dist1/com/company", + "app.core.models", + ) + + # Insert our custom importer, which will recognize the "com" directory as a namespace package. + new_meta_path = [CustomImporter(), *sys.meta_path] + monkeypatch.setattr(sys, "meta_path", new_meta_path) + + # Now we should be able to resolve the path as namespace package. + pkg_root, module_name = resolve_pkg_root_and_module_name( + models_py, consider_namespace_packages=True + ) + assert (pkg_root, module_name) == ( + tmp_path / "src/dist1", + "com.company.app.core.models", + ) + + @pytest.mark.parametrize("insert", [True, False]) + def test_full_ns_packages_without_init_files( + self, pytester: Pytester, tmp_path: Path, monkeypatch: MonkeyPatch, insert: bool + ) -> None: + (tmp_path / "src/dist1/ns/b/app/bar/test").mkdir(parents=True) + (tmp_path / "src/dist1/ns/b/app/bar/m.py").touch() + + if insert: + # The presence of this __init__.py is not a problem, ns.b.app is still part of the namespace package. + (tmp_path / "src/dist1/ns/b/app/__init__.py").touch() + + (tmp_path / "src/dist2/ns/a/core/foo/test").mkdir(parents=True) + (tmp_path / "src/dist2/ns/a/core/foo/m.py").touch() + + # Validate the namespace package by importing it in a Python subprocess. + r = validate_namespace_package( + pytester, + [tmp_path / "src/dist1", tmp_path / "src/dist2"], + ["ns.b.app.bar.m", "ns.a.core.foo.m"], + ) + assert r.ret == 0 + monkeypatch.syspath_prepend(tmp_path / "src/dist1") + monkeypatch.syspath_prepend(tmp_path / "src/dist2") + + assert resolve_pkg_root_and_module_name( + tmp_path / "src/dist1/ns/b/app/bar/m.py", consider_namespace_packages=True + ) == (tmp_path / "src/dist1", "ns.b.app.bar.m") + assert resolve_pkg_root_and_module_name( + tmp_path / "src/dist2/ns/a/core/foo/m.py", consider_namespace_packages=True + ) == (tmp_path / "src/dist2", "ns.a.core.foo.m") + + +def test_ns_import_same_name_directory_12592( + tmp_path: Path, pytester: Pytester +) -> None: + """Regression for `--import-mode=importlib` with directory parent and child with same name (#12592).""" + y_dir = tmp_path / "x/y/y" + y_dir.mkdir(parents=True) + test_y = tmp_path / "x/y/test_y.py" + test_y.write_text("def test(): pass", encoding="UTF-8") + + result = pytester.runpytest("--import-mode=importlib", test_y) + assert result.ret == ExitCode.OK + + +def test_is_importable(pytester: Pytester) -> None: + pytester.syspathinsert() + + path = pytester.path / "bar/foo.py" + path.parent.mkdir() + path.touch() + assert is_importable("bar.foo", path) is True + + # Ensure that the module that can be imported points to the path we expect. + path = pytester.path / "some/other/path/bar/foo.py" + path.mkdir(parents=True, exist_ok=True) + assert is_importable("bar.foo", path) is False + + # Paths containing "." cannot be imported. + path = pytester.path / "bar.x/__init__.py" + path.parent.mkdir() + path.touch() + assert is_importable("bar.x", path) is False + + # Pass starting with "." denote relative imports and cannot be checked using is_importable. + path = pytester.path / ".bar.x/__init__.py" + path.parent.mkdir() + path.touch() + assert is_importable(".bar.x", path) is False + + +def test_compute_module_name(tmp_path: Path) -> None: + assert compute_module_name(tmp_path, tmp_path) is None + assert compute_module_name(Path(), Path()) is None + + assert compute_module_name(tmp_path, tmp_path / "mod.py") == "mod" + assert compute_module_name(tmp_path, tmp_path / "src/app/bar") == "src.app.bar" + assert compute_module_name(tmp_path, tmp_path / "src/app/bar.py") == "src.app.bar" + assert ( + compute_module_name(tmp_path, tmp_path / "src/app/bar/__init__.py") + == "src.app.bar" + ) + + +def validate_namespace_package( + pytester: Pytester, paths: Sequence[Path], modules: Sequence[str] +) -> RunResult: + """ + Validate that a Python namespace package is set up correctly. + + In a sub interpreter, add 'paths' to sys.path and attempt to import the given modules. + + In this module many tests configure a set of files as a namespace package, this function + is used as sanity check that our files are configured correctly from the point of view of Python. + """ + lines = [ + "import sys", + # Configure sys.path. + *[f"sys.path.append(r{str(x)!r})" for x in paths], + # Imports. + *[f"import {x}" for x in modules], + ] + return pytester.runpython_c("\n".join(lines)) diff --git a/testing/test_pluginmanager.py b/testing/test_pluginmanager.py index c6f518b1da2..db85124bf0d 100644 --- a/testing/test_pluginmanager.py +++ b/testing/test_pluginmanager.py @@ -1,10 +1,11 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + import os import shutil import sys import types -from typing import List -import pytest from _pytest.config import Config from _pytest.config import ExitCode from _pytest.config import PytestPluginManager @@ -13,6 +14,7 @@ from _pytest.monkeypatch import MonkeyPatch from _pytest.pathlib import import_path from _pytest.pytester import Pytester +import pytest @pytest.fixture @@ -45,7 +47,10 @@ def pytest_myhook(xyz): kwargs=dict(pluginmanager=config.pluginmanager) ) config.pluginmanager._importconftest( - conf, importmode="prepend", rootpath=pytester.path + conf, + importmode="prepend", + rootpath=pytester.path, + consider_namespace_packages=False, ) # print(config.pluginmanager.get_plugins()) res = config.hook.pytest_myhook(xyz=10) @@ -74,7 +79,10 @@ def pytest_addoption(parser): """ ) config.pluginmanager._importconftest( - p, importmode="prepend", rootpath=pytester.path + p, + importmode="prepend", + rootpath=pytester.path, + consider_namespace_packages=False, ) assert config.option.test123 @@ -98,6 +106,40 @@ def pytest_configure(self): config.pluginmanager.register(A()) assert len(values) == 2 + @pytest.mark.skipif( + not sys.platform.startswith("win"), + reason="requires a case-insensitive file system", + ) + def test_conftestpath_case_sensitivity(self, pytester: Pytester) -> None: + """Unit test for issue #9765.""" + config = pytester.parseconfig() + pytester.makepyfile(**{"tests/conftest.py": ""}) + + conftest = pytester.path.joinpath("tests/conftest.py") + conftest_upper_case = pytester.path.joinpath("TESTS/conftest.py") + + mod = config.pluginmanager._importconftest( + conftest, + importmode="prepend", + rootpath=pytester.path, + consider_namespace_packages=False, + ) + plugin = config.pluginmanager.get_plugin(str(conftest)) + assert plugin is mod + + mod_uppercase = config.pluginmanager._importconftest( + conftest_upper_case, + importmode="prepend", + rootpath=pytester.path, + consider_namespace_packages=False, + ) + plugin_uppercase = config.pluginmanager.get_plugin(str(conftest_upper_case)) + assert plugin_uppercase is mod_uppercase + + # No str(conftestpath) normalization so conftest should be imported + # twice and modules should be different objects + assert mod is not mod_uppercase + def test_hook_tracing(self, _config_for_test: Config) -> None: pytestpm = _config_for_test.pluginmanager # fully initialized with plugins saveindent = [] @@ -111,7 +153,7 @@ def pytest_plugin_registered(self): saveindent.append(pytestpm.trace.root.indent) raise ValueError() - values: List[str] = [] + values: list[str] = [] pytestpm.trace.root.setwriter(values.append) undo = pytestpm.enable_tracing() try: @@ -141,12 +183,18 @@ def test_hook_proxy(self, pytester: Pytester) -> None: conftest2 = pytester.path.joinpath("tests/subdir/conftest.py") config.pluginmanager._importconftest( - conftest1, importmode="prepend", rootpath=pytester.path + conftest1, + importmode="prepend", + rootpath=pytester.path, + consider_namespace_packages=False, ) ihook_a = session.gethookproxy(pytester.path / "tests") assert ihook_a is not None config.pluginmanager._importconftest( - conftest2, importmode="prepend", rootpath=pytester.path + conftest2, + importmode="prepend", + rootpath=pytester.path, + consider_namespace_packages=False, ) ihook_b = session.gethookproxy(pytester.path / "tests") assert ihook_a is not ihook_b @@ -242,8 +290,12 @@ def test_consider_module( mod = types.ModuleType("temp") mod.__dict__["pytest_plugins"] = ["pytest_p1", "pytest_p2"] pytestpm.consider_module(mod) - assert pytestpm.get_plugin("pytest_p1").__name__ == "pytest_p1" - assert pytestpm.get_plugin("pytest_p2").__name__ == "pytest_p2" + p1 = pytestpm.get_plugin("pytest_p1") + assert p1 is not None + assert p1.__name__ == "pytest_p1" + p2 = pytestpm.get_plugin("pytest_p2") + assert p2 is not None + assert p2.__name__ == "pytest_p2" def test_consider_module_import_module( self, pytester: Pytester, _config_for_test: Config @@ -336,6 +388,7 @@ def test_import_plugin_importname( len2 = len(pytestpm.get_plugins()) assert len1 == len2 plugin1 = pytestpm.get_plugin("pytest_hello") + assert plugin1 is not None assert plugin1.__name__.endswith("pytest_hello") plugin2 = pytestpm.get_plugin("pytest_hello") assert plugin2 is plugin1 @@ -351,6 +404,7 @@ def test_import_plugin_dotted_name( pluginname = "pkg.plug" pytestpm.import_plugin(pluginname) mod = pytestpm.get_plugin("pkg.plug") + assert mod is not None assert mod.x == 3 def test_consider_conftest_deps( @@ -359,13 +413,15 @@ def test_consider_conftest_deps( pytestpm: PytestPluginManager, ) -> None: mod = import_path( - pytester.makepyfile("pytest_plugins='xyz'"), root=pytester.path + pytester.makepyfile("pytest_plugins='xyz'"), + root=pytester.path, + consider_namespace_packages=False, ) with pytest.raises(ImportError): - pytestpm.consider_conftest(mod) + pytestpm.consider_conftest(mod, registration_name="unused") -class TestPytestPluginManagerBootstrapming: +class TestPytestPluginManagerBootstrapping: def test_preparse_args(self, pytestpm: PytestPluginManager) -> None: pytest.raises( ImportError, lambda: pytestpm.consider_preparse(["xyz", "-p", "hello123"]) @@ -391,7 +447,7 @@ def test_plugin_prevent_register(self, pytestpm: PytestPluginManager) -> None: assert len(l2) == len(l1) assert 42 not in l2 - def test_plugin_prevent_register_unregistered_alredy_registered( + def test_plugin_prevent_register_unregistered_already_registered( self, pytestpm: PytestPluginManager ) -> None: pytestpm.register(42, name="abc") diff --git a/testing/test_pytester.py b/testing/test_pytester.py index 8f8b4d2914f..721e8c19d8b 100644 --- a/testing/test_pytester.py +++ b/testing/test_pytester.py @@ -1,22 +1,22 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + import os import subprocess import sys -import time -from pathlib import Path from types import ModuleType -from typing import List -import _pytest.pytester as pytester_mod -import pytest from _pytest.config import ExitCode from _pytest.config import PytestPluginManager from _pytest.monkeypatch import MonkeyPatch -from _pytest.pytester import CwdSnapshot +import _pytest.pytester as pytester_mod from _pytest.pytester import HookRecorder from _pytest.pytester import LineMatcher from _pytest.pytester import Pytester from _pytest.pytester import SysModulesSnapshot from _pytest.pytester import SysPathsSnapshot +import _pytest.timing +import pytest def test_make_hook_recorder(pytester: Pytester) -> None: @@ -228,7 +228,7 @@ def test_inline_run_test_module_not_cleaned_up(self, pytester: Pytester) -> None def spy_factory(self): class SysModulesSnapshotSpy: - instances: List["SysModulesSnapshotSpy"] = [] # noqa: F821 + instances: list[SysModulesSnapshotSpy] = [] def __init__(self, preserve=None) -> None: SysModulesSnapshotSpy.instances.append(self) @@ -301,17 +301,6 @@ def test_assert_outcomes_after_pytest_error(pytester: Pytester) -> None: result.assert_outcomes(passed=0) -def test_cwd_snapshot(pytester: Pytester) -> None: - foo = pytester.mkdir("foo") - bar = pytester.mkdir("bar") - os.chdir(foo) - snapshot = CwdSnapshot() - os.chdir(bar) - assert Path().absolute() == bar - snapshot.restore() - assert Path().absolute() == foo - - class TestSysModulesSnapshot: key = "my-test-module" @@ -411,7 +400,7 @@ def test_preserve_container(self, monkeypatch: MonkeyPatch, path_type) -> None: original_data = list(getattr(sys, path_type)) original_other = getattr(sys, other_path_type) original_other_data = list(original_other) - new: List[object] = [] + new: list[object] = [] snapshot = SysPathsSnapshot() monkeypatch.setattr(sys, path_type, new) snapshot.restore() @@ -462,13 +451,12 @@ def test_pytester_run_with_timeout(pytester: Pytester) -> None: timeout = 120 - start = time.time() + instant = _pytest.timing.Instant() result = pytester.runpytest_subprocess(testfile, timeout=timeout) - end = time.time() - duration = end - start + duration = instant.elapsed() assert result.ret == ExitCode.OK - assert duration < timeout + assert duration.seconds < timeout def test_pytester_run_timeout_expires(pytester: Pytester) -> None: @@ -717,15 +705,13 @@ def test_spawn_uses_tmphome(pytester: Pytester) -> None: pytester._monkeypatch.setenv("CUSTOMENV", "42") p1 = pytester.makepyfile( - """ + f""" import os def test(): assert os.environ["HOME"] == {tmphome!r} assert os.environ["CUSTOMENV"] == "42" - """.format( - tmphome=tmphome - ) + """ ) child = pytester.spawn_pytest(str(p1)) out = child.read() @@ -739,7 +725,7 @@ def test_run_result_repr() -> None: # known exit code r = pytester_mod.RunResult(1, outlines, errlines, duration=0.5) assert repr(r) == ( - f"" ) diff --git a/testing/test_python_path.py b/testing/test_python_path.py index e1628feb159..d12ef96115f 100644 --- a/testing/test_python_path.py +++ b/testing/test_python_path.py @@ -1,11 +1,11 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + import sys from textwrap import dedent -from typing import Generator -from typing import List -from typing import Optional -import pytest from _pytest.pytester import Pytester +import pytest @pytest.fixture() @@ -61,6 +61,27 @@ def test_two_dirs(pytester: Pytester, file_structure) -> None: result.assert_outcomes(passed=2) +def test_local_plugin(pytester: Pytester, file_structure) -> None: + """`pythonpath` kicks early enough to load plugins via -p (#11118).""" + localplugin_py = pytester.path / "sub" / "localplugin.py" + content = dedent( + """ + def pytest_load_initial_conftests(): + print("local plugin load") + + def pytest_unconfigure(): + print("local plugin unconfig") + """ + ) + localplugin_py.write_text(content, encoding="utf-8") + + pytester.makeini("[pytest]\npythonpath=sub\n") + result = pytester.runpytest("-plocalplugin", "-s", "test_foo.py") + result.stdout.fnmatch_lines(["local plugin load", "local plugin unconfig"]) + assert result.ret == 0 + result.assert_outcomes(passed=1) + + def test_module_not_found(pytester: Pytester, file_structure) -> None: """Without the pythonpath setting, the module should not be found.""" pytester.makefile(".ini", pytest="[pytest]\n") @@ -85,23 +106,22 @@ def test_clean_up(pytester: Pytester) -> None: # This is tough to test behaviorally because the cleanup really runs last. # So the test make several implementation assumptions: # - Cleanup is done in pytest_unconfigure(). - # - Not a hookwrapper. - # So we can add a hookwrapper ourselves to test what it does. + # - Not a hook wrapper. + # So we can add a hook wrapper ourselves to test what it does. pytester.makefile(".ini", pytest="[pytest]\npythonpath=I_SHALL_BE_REMOVED\n") pytester.makepyfile(test_foo="""def test_foo(): pass""") - before: Optional[List[str]] = None - after: Optional[List[str]] = None + before: list[str] | None = None + after: list[str] | None = None class Plugin: - @pytest.hookimpl(hookwrapper=True, tryfirst=True) - def pytest_unconfigure(self) -> Generator[None, None, None]: - nonlocal before, after + @pytest.hookimpl(tryfirst=True) + def pytest_unconfigure(self) -> None: + nonlocal before before = sys.path.copy() - yield - after = sys.path.copy() result = pytester.runpytest_inprocess(plugins=[Plugin()]) + after = sys.path.copy() assert result.ret == 0 assert before is not None diff --git a/testing/test_recwarn.py b/testing/test_recwarn.py index 7e0f836a634..384f2b66a15 100644 --- a/testing/test_recwarn.py +++ b/testing/test_recwarn.py @@ -1,9 +1,13 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + +import sys import warnings -from typing import Optional import pytest -from _pytest.pytester import Pytester -from _pytest.recwarn import WarningsRecorder +from pytest import ExitCode +from pytest import Pytester +from pytest import WarningsRecorder def test_recwarn_stacklevel(recwarn: WarningsRecorder) -> None: @@ -37,6 +41,47 @@ def test_recwarn_captures_deprecation_warning(recwarn: WarningsRecorder) -> None assert recwarn.pop(DeprecationWarning) +class TestSubclassWarningPop: + class ParentWarning(Warning): + pass + + class ChildWarning(ParentWarning): + pass + + class ChildOfChildWarning(ChildWarning): + pass + + @staticmethod + def raise_warnings_from_list(_warnings: list[type[Warning]]): + for warn in _warnings: + warnings.warn(f"Warning {warn().__repr__()}", warn) + + def test_pop_finds_exact_match(self): + with pytest.warns((self.ParentWarning, self.ChildWarning)) as record: + self.raise_warnings_from_list( + [self.ChildWarning, self.ParentWarning, self.ChildOfChildWarning] + ) + + assert len(record) == 3 + _warn = record.pop(self.ParentWarning) + assert _warn.category is self.ParentWarning + + def test_pop_raises_if_no_match(self): + with pytest.raises(AssertionError): + with pytest.warns(self.ParentWarning) as record: + self.raise_warnings_from_list([self.ParentWarning]) + record.pop(self.ChildOfChildWarning) + + def test_pop_finds_best_inexact_match(self): + with pytest.warns(self.ParentWarning) as record: + self.raise_warnings_from_list( + [self.ChildOfChildWarning, self.ChildWarning, self.ChildOfChildWarning] + ) + + _warn = record.pop(self.ParentWarning) + assert _warn.category is self.ChildWarning + + class TestWarningsRecorderChecker: def test_recording(self) -> None: rec = WarningsRecorder(_ispytest=True) @@ -87,7 +132,7 @@ def test_invalid_enter_exit(self) -> None: class TestDeprecatedCall: """test pytest.deprecated_call()""" - def dep(self, i: int, j: Optional[int] = None) -> int: + def dep(self, i: int, j: int | None = None) -> int: if i == 0: warnings.warn("is deprecated", DeprecationWarning, stacklevel=1) return 42 @@ -149,7 +194,7 @@ def f(): f() @pytest.mark.parametrize( - "warning_type", [PendingDeprecationWarning, DeprecationWarning] + "warning_type", [PendingDeprecationWarning, DeprecationWarning, FutureWarning] ) @pytest.mark.parametrize("mode", ["context_manager", "call"]) @pytest.mark.parametrize("call_f_first", [True, False]) @@ -172,50 +217,35 @@ def f(): with pytest.deprecated_call(): assert f() == 10 - @pytest.mark.parametrize("mode", ["context_manager", "call"]) - def test_deprecated_call_exception_is_raised(self, mode) -> None: - """If the block of the code being tested by deprecated_call() raises an exception, - it must raise the exception undisturbed. - """ - - def f(): - raise ValueError("some exception") - - with pytest.raises(ValueError, match="some exception"): - if mode == "call": - pytest.deprecated_call(f) - else: - with pytest.deprecated_call(): - f() - def test_deprecated_call_specificity(self) -> None: other_warnings = [ Warning, UserWarning, SyntaxWarning, RuntimeWarning, - FutureWarning, ImportWarning, UnicodeWarning, ] for warning in other_warnings: def f(): - warnings.warn(warning("hi")) + warnings.warn(warning("hi")) # noqa: B023 - with pytest.raises(pytest.fail.Exception): - pytest.deprecated_call(f) - with pytest.raises(pytest.fail.Exception): - with pytest.deprecated_call(): - f() + with pytest.warns(warning): + with pytest.raises(pytest.fail.Exception): + pytest.deprecated_call(f) + with pytest.raises(pytest.fail.Exception): + with pytest.deprecated_call(): + f() def test_deprecated_call_supports_match(self) -> None: with pytest.deprecated_call(match=r"must be \d+$"): warnings.warn("value must be 42", DeprecationWarning) - with pytest.raises(pytest.fail.Exception): - with pytest.deprecated_call(match=r"must be \d+$"): - warnings.warn("this is not here", DeprecationWarning) + with pytest.deprecated_call(): + with pytest.raises(pytest.fail.Exception, match="DID NOT WARN"): + with pytest.deprecated_call(match=r"must be \d+$"): + warnings.warn("this is not here", DeprecationWarning) class TestWarns: @@ -227,8 +257,9 @@ def test_check_callable(self) -> None: def test_several_messages(self) -> None: # different messages, b/c Python suppresses multiple identical warnings pytest.warns(RuntimeWarning, lambda: warnings.warn("w1", RuntimeWarning)) - with pytest.raises(pytest.fail.Exception): - pytest.warns(UserWarning, lambda: warnings.warn("w2", RuntimeWarning)) + with pytest.warns(RuntimeWarning): + with pytest.raises(pytest.fail.Exception): + pytest.warns(UserWarning, lambda: warnings.warn("w2", RuntimeWarning)) pytest.warns(RuntimeWarning, lambda: warnings.warn("w3", RuntimeWarning)) def test_function(self) -> None: @@ -243,13 +274,14 @@ def test_warning_tuple(self) -> None: pytest.warns( (RuntimeWarning, SyntaxWarning), lambda: warnings.warn("w2", SyntaxWarning) ) - pytest.raises( - pytest.fail.Exception, - lambda: pytest.warns( - (RuntimeWarning, SyntaxWarning), - lambda: warnings.warn("w3", UserWarning), - ), - ) + with pytest.warns(): + pytest.raises( + pytest.fail.Exception, + lambda: pytest.warns( + (RuntimeWarning, SyntaxWarning), + lambda: warnings.warn("w3", UserWarning), + ), + ) def test_as_contextmanager(self) -> None: with pytest.warns(RuntimeWarning): @@ -258,20 +290,22 @@ def test_as_contextmanager(self) -> None: with pytest.warns(UserWarning): warnings.warn("user", UserWarning) - with pytest.raises(pytest.fail.Exception) as excinfo: - with pytest.warns(RuntimeWarning): - warnings.warn("user", UserWarning) + with pytest.warns(): + with pytest.raises(pytest.fail.Exception) as excinfo: + with pytest.warns(RuntimeWarning): + warnings.warn("user", UserWarning) excinfo.match( r"DID NOT WARN. No warnings of type \(.+RuntimeWarning.+,\) were emitted.\n" - r"The list of emitted warnings is: \[UserWarning\('user',?\)\]." + r" Emitted warnings: \[UserWarning\('user',?\)\]." ) - with pytest.raises(pytest.fail.Exception) as excinfo: - with pytest.warns(UserWarning): - warnings.warn("runtime", RuntimeWarning) + with pytest.warns(): + with pytest.raises(pytest.fail.Exception) as excinfo: + with pytest.warns(UserWarning): + warnings.warn("runtime", RuntimeWarning) excinfo.match( r"DID NOT WARN. No warnings of type \(.+UserWarning.+,\) were emitted.\n" - r"The list of emitted warnings is: \[RuntimeWarning\('runtime',?\)]." + r" Emitted warnings: \[RuntimeWarning\('runtime',?\)]." ) with pytest.raises(pytest.fail.Exception) as excinfo: @@ -279,19 +313,20 @@ def test_as_contextmanager(self) -> None: pass excinfo.match( r"DID NOT WARN. No warnings of type \(.+UserWarning.+,\) were emitted.\n" - r"The list of emitted warnings is: \[\]." + r" Emitted warnings: \[\]." ) warning_classes = (UserWarning, FutureWarning) - with pytest.raises(pytest.fail.Exception) as excinfo: - with pytest.warns(warning_classes) as warninfo: - warnings.warn("runtime", RuntimeWarning) - warnings.warn("import", ImportWarning) + with pytest.warns(): + with pytest.raises(pytest.fail.Exception) as excinfo: + with pytest.warns(warning_classes) as warninfo: + warnings.warn("runtime", RuntimeWarning) + warnings.warn("import", ImportWarning) messages = [each.message for each in warninfo] expected_str = ( f"DID NOT WARN. No warnings of type {warning_classes} were emitted.\n" - f"The list of emitted warnings is: {messages}." + f" Emitted warnings: {messages}." ) assert str(excinfo.value) == expected_str @@ -312,17 +347,9 @@ def test_record_only(self) -> None: assert str(record[0].message) == "user" assert str(record[1].message) == "runtime" - def test_record_only_none_deprecated_warn(self) -> None: - # This should become an error when WARNS_NONE_ARG is removed in Pytest 8.0 - with warnings.catch_warnings(): - warnings.simplefilter("ignore") - with pytest.warns(None) as record: # type: ignore[call-overload] - warnings.warn("user", UserWarning) - warnings.warn("runtime", RuntimeWarning) - - assert len(record) == 2 - assert str(record[0].message) == "user" - assert str(record[1].message) == "runtime" + def test_record_only_none_type_error(self) -> None: + with pytest.raises(TypeError): + pytest.warns(None) # type: ignore[call-overload] def test_record_by_subclass(self) -> None: with pytest.warns(Warning) as record: @@ -367,25 +394,31 @@ def test_match_regex(self) -> None: with pytest.warns(UserWarning, match=r"must be \d+$"): warnings.warn("value must be 42", UserWarning) - with pytest.raises(pytest.fail.Exception): - with pytest.warns(UserWarning, match=r"must be \d+$"): - warnings.warn("this is not here", UserWarning) + with pytest.warns(): + with pytest.raises(pytest.fail.Exception): + with pytest.warns(UserWarning, match=r"must be \d+$"): + warnings.warn("this is not here", UserWarning) - with pytest.raises(pytest.fail.Exception): - with pytest.warns(FutureWarning, match=r"must be \d+$"): - warnings.warn("value must be 42", UserWarning) + with pytest.warns(): + with pytest.raises(pytest.fail.Exception): + with pytest.warns(FutureWarning, match=r"must be \d+$"): + warnings.warn("value must be 42", UserWarning) def test_one_from_multiple_warns(self) -> None: - with pytest.warns(UserWarning, match=r"aaa"): - warnings.warn("cccccccccc", UserWarning) - warnings.warn("bbbbbbbbbb", UserWarning) - warnings.warn("aaaaaaaaaa", UserWarning) + with pytest.warns(): + with pytest.raises(pytest.fail.Exception, match="DID NOT WARN"): + with pytest.warns(UserWarning, match=r"aaa"): + with pytest.warns(UserWarning, match=r"aaa"): + warnings.warn("cccccccccc", UserWarning) + warnings.warn("bbbbbbbbbb", UserWarning) + warnings.warn("aaaaaaaaaa", UserWarning) def test_none_of_multiple_warns(self) -> None: - with pytest.raises(pytest.fail.Exception): - with pytest.warns(UserWarning, match=r"aaa"): - warnings.warn("bbbbbbbbbb", UserWarning) - warnings.warn("cccccccccc", UserWarning) + with pytest.warns(): + with pytest.raises(pytest.fail.Exception, match="DID NOT WARN"): + with pytest.warns(UserWarning, match=r"aaa"): + warnings.warn("bbbbbbbbbb", UserWarning) + warnings.warn("cccccccccc", UserWarning) @pytest.mark.filterwarnings("ignore") def test_can_capture_previously_warned(self) -> None: @@ -403,3 +436,160 @@ def test_warns_context_manager_with_kwargs(self) -> None: with pytest.warns(UserWarning, foo="bar"): # type: ignore pass assert "Unexpected keyword arguments" in str(excinfo.value) + + def test_re_emit_single(self) -> None: + with pytest.warns(DeprecationWarning): + with pytest.warns(UserWarning): + warnings.warn("user warning", UserWarning) + warnings.warn("some deprecation warning", DeprecationWarning) + + def test_re_emit_multiple(self) -> None: + with pytest.warns(UserWarning): + warnings.warn("first warning", UserWarning) + warnings.warn("second warning", UserWarning) + + def test_re_emit_match_single(self) -> None: + with pytest.warns(DeprecationWarning): + with pytest.warns(UserWarning, match="user warning"): + warnings.warn("user warning", UserWarning) + warnings.warn("some deprecation warning", DeprecationWarning) + + def test_re_emit_match_multiple(self) -> None: + with warnings.catch_warnings(): + warnings.simplefilter("error") # if anything is re-emitted + with pytest.warns(UserWarning, match="user warning"): + warnings.warn("first user warning", UserWarning) + warnings.warn("second user warning", UserWarning) + + def test_re_emit_non_match_single(self) -> None: + with pytest.warns(UserWarning, match="v2 warning"): + with pytest.warns(UserWarning, match="v1 warning"): + warnings.warn("v1 warning", UserWarning) + warnings.warn("non-matching v2 warning", UserWarning) + + def test_catch_warning_within_raise(self) -> None: + # warns-in-raises works since https://github.com/pytest-dev/pytest/pull/11129 + with pytest.raises(ValueError, match="some exception"): + with pytest.warns(FutureWarning, match="some warning"): + warnings.warn("some warning", category=FutureWarning) + raise ValueError("some exception") + # and raises-in-warns has always worked but we'll check for symmetry. + with pytest.warns(FutureWarning, match="some warning"): + with pytest.raises(ValueError, match="some exception"): + warnings.warn("some warning", category=FutureWarning) + raise ValueError("some exception") + + def test_skip_within_warns(self, pytester: Pytester) -> None: + """Regression test for #11907.""" + pytester.makepyfile( + """ + import pytest + + def test_it(): + with pytest.warns(Warning): + pytest.skip("this is OK") + """, + ) + + result = pytester.runpytest() + assert result.ret == ExitCode.OK + result.assert_outcomes(skipped=1) + + def test_fail_within_warns(self, pytester: Pytester) -> None: + """Regression test for #11907.""" + pytester.makepyfile( + """ + import pytest + + def test_it(): + with pytest.warns(Warning): + pytest.fail("BOOM") + """, + ) + + result = pytester.runpytest() + assert result.ret == ExitCode.TESTS_FAILED + result.assert_outcomes(failed=1) + assert "DID NOT WARN" not in str(result.stdout) + + def test_exit_within_warns(self, pytester: Pytester) -> None: + """Regression test for #11907.""" + pytester.makepyfile( + """ + import pytest + + def test_it(): + with pytest.warns(Warning): + pytest.exit() + """, + ) + + result = pytester.runpytest() + assert result.ret == ExitCode.INTERRUPTED + result.assert_outcomes() + + def test_keyboard_interrupt_within_warns(self, pytester: Pytester) -> None: + """Regression test for #11907.""" + pytester.makepyfile( + """ + import pytest + + def test_it(): + with pytest.warns(Warning): + raise KeyboardInterrupt() + """, + ) + + result = pytester.runpytest_subprocess() + assert result.ret == ExitCode.INTERRUPTED + result.assert_outcomes() + + +def test_raise_type_error_on_invalid_warning() -> None: + """Check pytest.warns validates warning messages are strings (#10865) or + Warning instances (#11959).""" + with pytest.raises(TypeError, match="Warning must be str or Warning"): + with pytest.warns(UserWarning): + warnings.warn(1) # type: ignore + + +@pytest.mark.parametrize( + "message", + [ + pytest.param("Warning", id="str"), + pytest.param(UserWarning(), id="UserWarning"), + pytest.param(Warning(), id="Warning"), + ], +) +def test_no_raise_type_error_on_valid_warning(message: str | Warning) -> None: + """Check pytest.warns validates warning messages are strings (#10865) or + Warning instances (#11959).""" + with pytest.warns(Warning): + warnings.warn(message) + + +@pytest.mark.skipif( + hasattr(sys, "pypy_version_info"), + reason="Not for pypy", +) +def test_raise_type_error_on_invalid_warning_message_cpython() -> None: + # Check that we get the same behavior with the stdlib, at least if filtering + # (see https://github.com/python/cpython/issues/103577 for details) + with pytest.raises(TypeError): + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", "test") + warnings.warn(1) # type: ignore + + +def test_multiple_arg_custom_warning() -> None: + """Test for issue #11906.""" + + class CustomWarning(UserWarning): + def __init__(self, a, b): + pass + + with pytest.warns(CustomWarning): + with pytest.raises(pytest.fail.Exception, match="DID NOT WARN"): + with pytest.warns(CustomWarning, match="not gonna match"): + a, b = 1, 2 + warnings.warn(CustomWarning(a, b)) diff --git a/testing/test_reports.py b/testing/test_reports.py index 387d2e807ce..7a893981838 100644 --- a/testing/test_reports.py +++ b/testing/test_reports.py @@ -1,7 +1,8 @@ -from typing import Sequence -from typing import Union +# mypy: allow-untyped-defs +from __future__ import annotations + +from collections.abc import Sequence -import pytest from _pytest._code.code import ExceptionChainRepr from _pytest._code.code import ExceptionRepr from _pytest.config import Config @@ -9,6 +10,7 @@ from _pytest.python_api import approx from _pytest.reports import CollectReport from _pytest.reports import TestReport +import pytest class TestReportSerialization: @@ -99,14 +101,13 @@ def test_repr_entry(): rep_entries = rep.longrepr.reprtraceback.reprentries a_entries = a.longrepr.reprtraceback.reprentries - for i in range(len(a_entries)): - rep_entry = rep_entries[i] + assert len(rep_entries) == len(a_entries) # python < 3.10 zip(strict=True) + for a_entry, rep_entry in zip(a_entries, rep_entries): assert isinstance(rep_entry, ReprEntry) assert rep_entry.reprfileloc is not None assert rep_entry.reprfuncargs is not None assert rep_entry.reprlocals is not None - a_entry = a_entries[i] assert isinstance(a_entry, ReprEntry) assert a_entry.reprfileloc is not None assert a_entry.reprfuncargs is not None @@ -145,9 +146,10 @@ def test_repr_entry_native(): rep_entries = rep.longrepr.reprtraceback.reprentries a_entries = a.longrepr.reprtraceback.reprentries - for i in range(len(a_entries)): - assert isinstance(rep_entries[i], ReprEntryNative) - assert rep_entries[i].lines == a_entries[i].lines + assert len(rep_entries) == len(a_entries) # python < 3.10 zip(strict=True) + for rep_entry, a_entry in zip(rep_entries, a_entries): + assert isinstance(rep_entry, ReprEntryNative) + assert rep_entry.lines == a_entry.lines def test_itemreport_outcomes(self, pytester: Pytester) -> None: # This test came originally from test_remote.py in xdist (ca03269). @@ -278,7 +280,7 @@ def test_chained_exceptions( ) -> None: """Check serialization/deserialization of report objects containing chained exceptions (#5786)""" pytester.makepyfile( - """ + f""" def foo(): raise ValueError('value error') def test_a(): @@ -286,27 +288,25 @@ def test_a(): foo() except ValueError as e: raise RuntimeError('runtime error') from e - if {error_during_import}: + if {report_class is CollectReport}: test_a() - """.format( - error_during_import=report_class is CollectReport - ) + """ ) reprec = pytester.inline_run() if report_class is TestReport: - reports: Union[ - Sequence[TestReport], Sequence[CollectReport] - ] = reprec.getreports("pytest_runtest_logreport") + reports: Sequence[TestReport] | Sequence[CollectReport] = reprec.getreports( + "pytest_runtest_logreport" + ) # we have 3 reports: setup/call/teardown assert len(reports) == 3 # get the call report report = reports[1] else: assert report_class is CollectReport - # two collection reports: session and test file + # three collection reports: session, test file, directory reports = reprec.getreports("pytest_collectreport") - assert len(reports) == 2 + assert len(reports) == 3 report = reports[1] def check_longrepr(longrepr: ExceptionChainRepr) -> None: @@ -471,7 +471,7 @@ def test_b(): pass ) reprec = pytester.inline_run() reports = reprec.getreports("pytest_collectreport") - assert len(reports) == 2 + assert len(reports) == 3 for rep in reports: data = pytestconfig.hook.pytest_report_to_serializable( config=pytestconfig, report=rep diff --git a/testing/test_runner.py b/testing/test_runner.py index de3e18184c7..0245438a47d 100644 --- a/testing/test_runner.py +++ b/testing/test_runner.py @@ -1,15 +1,14 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + +from functools import partial import inspect import os +from pathlib import Path import sys import types -from functools import partial -from pathlib import Path -from typing import Dict -from typing import List -from typing import Tuple -from typing import Type +import warnings -import pytest from _pytest import outcomes from _pytest import reports from _pytest import runner @@ -19,8 +18,10 @@ from _pytest.monkeypatch import MonkeyPatch from _pytest.outcomes import OutcomeException from _pytest.pytester import Pytester +import pytest + -if sys.version_info[:2] < (3, 11): +if sys.version_info < (3, 11): from exceptiongroup import ExceptionGroup @@ -136,8 +137,45 @@ def raiser(exc): ss.teardown_exact(None) mod, func = e.value.exceptions assert isinstance(mod, KeyError) - assert isinstance(func.exceptions[0], TypeError) # type: ignore - assert isinstance(func.exceptions[1], ValueError) # type: ignore + assert isinstance(func.exceptions[0], TypeError) + assert isinstance(func.exceptions[1], ValueError) + + def test_cached_exception_doesnt_get_longer(self, pytester: Pytester) -> None: + """Regression test for #12204 (the "BTW" case).""" + pytester.makepyfile(test="") + # If the collector.setup() raises, all collected items error with this + # exception. + pytester.makeconftest( + """ + import pytest + + class MyItem(pytest.Item): + def runtest(self) -> None: pass + + class MyBadCollector(pytest.Collector): + def collect(self): + return [ + MyItem.from_parent(self, name="one"), + MyItem.from_parent(self, name="two"), + MyItem.from_parent(self, name="three"), + ] + + def setup(self): + 1 / 0 + + def pytest_collect_file(file_path, parent): + if file_path.name == "test.py": + return MyBadCollector.from_parent(parent, name='bad') + """ + ) + + result = pytester.runpytest_inprocess("--tb=native") + assert result.ret == ExitCode.TESTS_FAILED + failures = result.reprec.getfailures() # type: ignore[attr-defined] + assert len(failures) == 3 + lines1 = failures[1].longrepr.reprtraceback.reprentries[0].lines + lines2 = failures[2].longrepr.reprtraceback.reprentries[0].lines + assert len(lines1) == len(lines2) class BaseFunctionalTests: @@ -406,7 +444,7 @@ def test_func(): # assert rep.outcome.when == "setup" # assert rep.outcome.where.lineno == 3 # assert rep.outcome.where.path.basename == "test_func.py" - # assert instanace(rep.failed.failurerepr, PythonFailureRepr) + # assert isinstance(rep.failed.failurerepr, PythonFailureRepr) def test_systemexit_does_not_bail_out(self, pytester: Pytester) -> None: try: @@ -483,7 +521,7 @@ class TestClass(object): assert res[1].name == "TestClass" -reporttypes: List[Type[reports.BaseReport]] = [ +reporttypes: list[type[reports.BaseReport]] = [ reports.BaseReport, reports.TestReport, reports.CollectReport, @@ -493,9 +531,9 @@ class TestClass(object): @pytest.mark.parametrize( "reporttype", reporttypes, ids=[x.__name__ for x in reporttypes] ) -def test_report_extra_parameters(reporttype: Type[reports.BaseReport]) -> None: +def test_report_extra_parameters(reporttype: type[reports.BaseReport]) -> None: args = list(inspect.signature(reporttype.__init__).parameters.keys())[1:] - basekw: Dict[str, List[object]] = dict.fromkeys(args, []) + basekw: dict[str, list[object]] = {arg: [] for arg in args} report = reporttype(newthing=1, **basekw) assert report.newthing == 1 @@ -542,10 +580,10 @@ def pytest_runtest_setup(self, item): @pytest.fixture def mylist(self, request): return request.function.mylist - @pytest.hookimpl(hookwrapper=True) + @pytest.hookimpl(wrapper=True) def pytest_runtest_call(self, item): try: - (yield).get_result() + yield except ValueError: pass def test_hello1(self, mylist): @@ -760,6 +798,73 @@ def test_importorskip_imports_last_module_part() -> None: assert os.path == ospath +class TestImportOrSkipExcType: + """Tests for #11523.""" + + def test_no_warning(self) -> None: + # An attempt on a module which does not exist will raise ModuleNotFoundError, so it will + # be skipped normally and no warning will be issued. + with warnings.catch_warnings(record=True) as captured: + warnings.simplefilter("always") + + with pytest.raises(pytest.skip.Exception): + pytest.importorskip("TestImportOrSkipExcType_test_no_warning") + + assert captured == [] + + def test_import_error_with_warning(self, pytester: Pytester) -> None: + # Create a module which exists and can be imported, however it raises + # ImportError due to some other problem. In this case we will issue a warning + # about the future behavior change. + fn = pytester.makepyfile("raise ImportError('some specific problem')") + pytester.syspathinsert() + + with warnings.catch_warnings(record=True) as captured: + warnings.simplefilter("always") + + with pytest.raises(pytest.skip.Exception): + pytest.importorskip(fn.stem) + + [warning] = captured + assert warning.category is pytest.PytestDeprecationWarning + + def test_import_error_suppress_warning(self, pytester: Pytester) -> None: + # Same as test_import_error_with_warning, but we can suppress the warning + # by passing ImportError as exc_type. + fn = pytester.makepyfile("raise ImportError('some specific problem')") + pytester.syspathinsert() + + with warnings.catch_warnings(record=True) as captured: + warnings.simplefilter("always") + + with pytest.raises(pytest.skip.Exception): + pytest.importorskip(fn.stem, exc_type=ImportError) + + assert captured == [] + + def test_warning_integration(self, pytester: Pytester) -> None: + pytester.makepyfile( + """ + import pytest + def test_foo(): + pytest.importorskip("warning_integration_module") + """ + ) + pytester.makepyfile( + warning_integration_module=""" + raise ImportError("required library foobar not compiled properly") + """ + ) + result = pytester.runpytest() + result.stdout.fnmatch_lines( + [ + "*Module 'warning_integration_module' was found, but when imported by pytest it raised:", + "* ImportError('required library foobar not compiled properly')", + "*1 skipped, 1 warning*", + ] + ) + + def test_importorskip_dev_module(monkeypatch) -> None: try: mod = types.ModuleType("mockmodule") @@ -826,12 +931,12 @@ def test_unicode_in_longrepr(pytester: Pytester) -> None: pytester.makeconftest( """\ import pytest - @pytest.hookimpl(hookwrapper=True) + @pytest.hookimpl(wrapper=True) def pytest_runtest_makereport(): - outcome = yield - rep = outcome.get_result() + rep = yield if rep.when == "call": rep.longrepr = 'ä' + return rep """ ) pytester.makepyfile( @@ -924,6 +1029,9 @@ def runtest(self): # Check that exception info is stored on sys assert sys.last_type is IndexError assert isinstance(sys.last_value, IndexError) + if sys.version_info >= (3, 12, 0): + assert isinstance(sys.last_exc, IndexError) # type:ignore[attr-defined] + assert sys.last_value.args[0] == "TEST" assert sys.last_traceback @@ -932,11 +1040,13 @@ def runtest(self): runner.pytest_runtest_call(ItemMightRaise()) # type: ignore[arg-type] assert not hasattr(sys, "last_type") assert not hasattr(sys, "last_value") + if sys.version_info >= (3, 12, 0): + assert not hasattr(sys, "last_exc") assert not hasattr(sys, "last_traceback") def test_current_test_env_var(pytester: Pytester, monkeypatch: MonkeyPatch) -> None: - pytest_current_test_vars: List[Tuple[str, str]] = [] + pytest_current_test_vars: list[tuple[str, str]] = [] monkeypatch.setattr( sys, "pytest_current_test_vars", pytest_current_test_vars, raising=False ) @@ -1006,7 +1116,7 @@ def test_longreprtext_collect_skip(self, pytester: Pytester) -> None: ) rec = pytester.inline_run() calls = rec.getcalls("pytest_collectreport") - _, call = calls + _, call, _ = calls assert isinstance(call.report.longrepr, tuple) assert "Skipped" in call.report.longreprtext @@ -1087,3 +1197,70 @@ def func() -> None: with pytest.raises(TypeError) as excinfo: OutcomeException(func) # type: ignore assert str(excinfo.value) == expected + + +def test_pytest_version_env_var(pytester: Pytester, monkeypatch: MonkeyPatch) -> None: + monkeypatch.setenv("PYTEST_VERSION", "old version") + pytester.makepyfile( + """ + import pytest + import os + + + def test(): + assert os.environ.get("PYTEST_VERSION") == pytest.__version__ + """ + ) + result = pytester.runpytest_inprocess() + assert result.ret == ExitCode.OK + assert os.environ["PYTEST_VERSION"] == "old version" + + +def test_teardown_session_failed(pytester: Pytester) -> None: + """Test that higher-scoped fixture teardowns run in the context of the last + item after the test session bails early due to --maxfail. + + Regression test for #11706. + """ + pytester.makepyfile( + """ + import pytest + + @pytest.fixture(scope="module") + def baz(): + yield + pytest.fail("This is a failing teardown") + + def test_foo(baz): + pytest.fail("This is a failing test") + + def test_bar(): pass + """ + ) + result = pytester.runpytest("--maxfail=1") + result.assert_outcomes(failed=1, errors=1) + + +def test_teardown_session_stopped(pytester: Pytester) -> None: + """Test that higher-scoped fixture teardowns run in the context of the last + item after the test session bails early due to --stepwise. + + Regression test for #11706. + """ + pytester.makepyfile( + """ + import pytest + + @pytest.fixture(scope="module") + def baz(): + yield + pytest.fail("This is a failing teardown") + + def test_foo(baz): + pytest.fail("This is a failing test") + + def test_bar(): pass + """ + ) + result = pytester.runpytest("--stepwise") + result.assert_outcomes(failed=1, errors=1) diff --git a/testing/test_runner_xunit.py b/testing/test_runner_xunit.py index e077ac41e2c..75e838a49e8 100644 --- a/testing/test_runner_xunit.py +++ b/testing/test_runner_xunit.py @@ -1,8 +1,10 @@ +# mypy: allow-untyped-defs """Test correct setup/teardowns at module, class, and instance level.""" -from typing import List -import pytest +from __future__ import annotations + from _pytest.pytester import Pytester +import pytest def test_module_and_function_setup(pytester: Pytester) -> None: @@ -249,12 +251,12 @@ def test_setup_teardown_function_level_with_optional_argument( """Parameter to setup/teardown xunit-style functions parameter is now optional (#1728).""" import sys - trace_setups_teardowns: List[str] = [] + trace_setups_teardowns: list[str] = [] monkeypatch.setattr( sys, "trace_setups_teardowns", trace_setups_teardowns, raising=False ) p = pytester.makepyfile( - """ + f""" import pytest import sys @@ -275,9 +277,7 @@ def teardown_method(self, {arg}): trace('teardown_method') def test_method_1(self): pass def test_method_2(self): pass - """.format( - arg=arg - ) + """ ) result = pytester.inline_run(p) result.assertoutcome(passed=4) diff --git a/testing/test_scope.py b/testing/test_scope.py index 09ee1343a80..3cb811469a9 100644 --- a/testing/test_scope.py +++ b/testing/test_scope.py @@ -1,7 +1,9 @@ +from __future__ import annotations + import re -import pytest from _pytest.scope import Scope +import pytest def test_ordering() -> None: diff --git a/testing/test_session.py b/testing/test_session.py index 48dc08e8c8f..ba904916033 100644 --- a/testing/test_session.py +++ b/testing/test_session.py @@ -1,7 +1,10 @@ -import pytest +# mypy: allow-untyped-defs +from __future__ import annotations + from _pytest.config import ExitCode from _pytest.monkeypatch import MonkeyPatch from _pytest.pytester import Pytester +import pytest class SessionTests: @@ -172,8 +175,9 @@ def test_one(): pass except pytest.skip.Exception: # pragma: no cover pytest.fail("wrong skipped caught") reports = reprec.getreports("pytest_collectreport") - assert len(reports) == 1 - assert reports[0].skipped + # Session, Dir + assert len(reports) == 2 + assert reports[1].skipped class TestNewSession(SessionTests): @@ -357,9 +361,10 @@ def test_2(): pass ) result.stdout.fnmatch_lines( [ - "", - " ", - " ", + " ", + " ", + " ", + " ", ], consecutive=True, ) @@ -373,11 +378,12 @@ def test_2(): pass ) result.stdout.fnmatch_lines( [ - "", - " ", - " ", - " ", - " ", + " ", + " ", + " ", + " ", + " ", + " ", ], consecutive=True, ) @@ -415,3 +421,63 @@ def test_rootdir_wrong_option_arg(pytester: Pytester) -> None: result.stderr.fnmatch_lines( ["*Directory *wrong_dir* not found. Check your '--rootdir' option.*"] ) + + +def test_shouldfail_is_sticky(pytester: Pytester) -> None: + """Test that session.shouldfail cannot be reset to False after being set. + + Issue #11706. + """ + pytester.makeconftest( + """ + def pytest_sessionfinish(session): + assert session.shouldfail + session.shouldfail = False + assert session.shouldfail + """ + ) + pytester.makepyfile( + """ + import pytest + + def test_foo(): + pytest.fail("This is a failing test") + + def test_bar(): pass + """ + ) + + result = pytester.runpytest("--maxfail=1", "-Wall") + + result.assert_outcomes(failed=1, warnings=1) + result.stdout.fnmatch_lines("*session.shouldfail cannot be unset*") + + +def test_shouldstop_is_sticky(pytester: Pytester) -> None: + """Test that session.shouldstop cannot be reset to False after being set. + + Issue #11706. + """ + pytester.makeconftest( + """ + def pytest_sessionfinish(session): + assert session.shouldstop + session.shouldstop = False + assert session.shouldstop + """ + ) + pytester.makepyfile( + """ + import pytest + + def test_foo(): + pytest.fail("This is a failing test") + + def test_bar(): pass + """ + ) + + result = pytester.runpytest("--stepwise", "-Wall") + + result.assert_outcomes(failed=1, warnings=1) + result.stdout.fnmatch_lines("*session.shouldstop cannot be unset*") diff --git a/testing/test_setuponly.py b/testing/test_setuponly.py index fe4bdc514eb..87123bd9a16 100644 --- a/testing/test_setuponly.py +++ b/testing/test_setuponly.py @@ -1,8 +1,11 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + import sys -import pytest from _pytest.config import ExitCode from _pytest.pytester import Pytester +import pytest @pytest.fixture(params=["--setup-only", "--setup-plan", "--setup-show"], scope="module") diff --git a/testing/test_setupplan.py b/testing/test_setupplan.py index d51a1873959..5a9211d7806 100644 --- a/testing/test_setupplan.py +++ b/testing/test_setupplan.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from _pytest.pytester import Pytester diff --git a/testing/test_skipping.py b/testing/test_skipping.py index 6b80346108f..9a6c2c4b6aa 100644 --- a/testing/test_skipping.py +++ b/testing/test_skipping.py @@ -1,12 +1,15 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + import sys import textwrap -import pytest from _pytest.pytester import Pytester from _pytest.runner import runtestprotocol from _pytest.skipping import evaluate_skip_marks from _pytest.skipping import evaluate_xfail_marks from _pytest.skipping import pytest_runtest_setup +import pytest class TestEvaluation: @@ -73,16 +76,15 @@ def test_marked_one_arg_twice(self, pytester: Pytester) -> None: """@pytest.mark.skipif("not hasattr(os, 'murks')")""", """@pytest.mark.skipif(condition="hasattr(os, 'murks')")""", ] - for i in range(0, 2): + for i in range(2): item = pytester.getitem( - """ + f""" import pytest - %s - %s + {lines[i]} + {lines[(i + 1) % 2]} def test_func(): pass """ - % (lines[i], lines[(i + 1) % 2]) ) skipped = evaluate_skip_marks(item) assert skipped @@ -297,13 +299,12 @@ class TestXFail: @pytest.mark.parametrize("strict", [True, False]) def test_xfail_simple(self, pytester: Pytester, strict: bool) -> None: item = pytester.getitem( - """ + f""" import pytest - @pytest.mark.xfail(strict=%s) + @pytest.mark.xfail(strict={strict}) def test_func(): assert 0 """ - % strict ) reports = runtestprotocol(item, log=False) assert len(reports) == 3 @@ -447,8 +448,8 @@ def test_this_false(): result = pytester.runpytest(p, "-rx") result.stdout.fnmatch_lines( [ - "*test_one*test_this - reason: *NOTRUN* noway", - "*test_one*test_this_true - reason: *NOTRUN* condition: True", + "*test_one*test_this - *NOTRUN* noway", + "*test_one*test_this_true - *NOTRUN* condition: True", "*1 passed*", ] ) @@ -491,7 +492,7 @@ def test_this(): result = pytester.runpytest(p) result.stdout.fnmatch_lines(["*1 xfailed*"]) result = pytester.runpytest(p, "-rx") - result.stdout.fnmatch_lines(["*XFAIL*test_this*reason:*hello*"]) + result.stdout.fnmatch_lines(["*XFAIL*test_this*hello*"]) result = pytester.runpytest(p, "--runxfail") result.stdout.fnmatch_lines(["*1 pass*"]) @@ -509,7 +510,7 @@ def test_this(): result = pytester.runpytest(p) result.stdout.fnmatch_lines(["*1 xfailed*"]) result = pytester.runpytest(p, "-rx") - result.stdout.fnmatch_lines(["*XFAIL*test_this*reason:*hello*"]) + result.stdout.fnmatch_lines(["*XFAIL*test_this*hello*"]) result = pytester.runpytest(p, "--runxfail") result.stdout.fnmatch_lines( """ @@ -601,13 +602,12 @@ def test_xfail_raises( self, expected, actual, matchline, pytester: Pytester ) -> None: p = pytester.makepyfile( - """ + f""" import pytest - @pytest.mark.xfail(raises=%s) + @pytest.mark.xfail(raises={expected}) def test_raises(): - raise %s() + raise {actual}() """ - % (expected, actual) ) result = pytester.runpytest(p) result.stdout.fnmatch_lines([matchline]) @@ -630,15 +630,14 @@ def test_foo(): @pytest.mark.parametrize("strict", [True, False]) def test_strict_xfail(self, pytester: Pytester, strict: bool) -> None: p = pytester.makepyfile( - """ + f""" import pytest - @pytest.mark.xfail(reason='unsupported feature', strict=%s) + @pytest.mark.xfail(reason='unsupported feature', strict={strict}) def test_foo(): with open('foo_executed', 'w', encoding='utf-8'): pass # make sure test executes """ - % strict ) result = pytester.runpytest(p, "-rxX") if strict: @@ -649,7 +648,7 @@ def test_foo(): result.stdout.fnmatch_lines( [ "*test_strict_xfail*", - "XPASS test_strict_xfail.py::test_foo unsupported feature", + "XPASS test_strict_xfail.py::test_foo - unsupported feature", ] ) assert result.ret == (1 if strict else 0) @@ -658,14 +657,13 @@ def test_foo(): @pytest.mark.parametrize("strict", [True, False]) def test_strict_xfail_condition(self, pytester: Pytester, strict: bool) -> None: p = pytester.makepyfile( - """ + f""" import pytest - @pytest.mark.xfail(False, reason='unsupported feature', strict=%s) + @pytest.mark.xfail(False, reason='unsupported feature', strict={strict}) def test_foo(): pass """ - % strict ) result = pytester.runpytest(p, "-rxX") result.stdout.fnmatch_lines(["*1 passed*"]) @@ -674,14 +672,13 @@ def test_foo(): @pytest.mark.parametrize("strict", [True, False]) def test_xfail_condition_keyword(self, pytester: Pytester, strict: bool) -> None: p = pytester.makepyfile( - """ + f""" import pytest - @pytest.mark.xfail(condition=False, reason='unsupported feature', strict=%s) + @pytest.mark.xfail(condition=False, reason='unsupported feature', strict={strict}) def test_foo(): pass """ - % strict ) result = pytester.runpytest(p, "-rxX") result.stdout.fnmatch_lines(["*1 passed*"]) @@ -692,11 +689,10 @@ def test_strict_xfail_default_from_file( self, pytester: Pytester, strict_val ) -> None: pytester.makeini( - """ + f""" [pytest] - xfail_strict = %s + xfail_strict = {strict_val} """ - % strict_val ) p = pytester.makepyfile( """ @@ -903,13 +899,12 @@ def test_func(): ) def test_skipif_reporting(self, pytester: Pytester, params) -> None: p = pytester.makepyfile( - test_foo=""" + test_foo=f""" import pytest - @pytest.mark.skipif(%(params)s) + @pytest.mark.skipif({params}) def test_that(): assert 0 """ - % dict(params=params) ) result = pytester.runpytest(p, "-s", "-rs") result.stdout.fnmatch_lines(["*SKIP*1*test_foo.py*platform*", "*1 skipped*"]) @@ -934,15 +929,13 @@ def test_skipif_reporting_multiple( self, pytester: Pytester, marker, msg1, msg2 ) -> None: pytester.makepyfile( - test_foo=""" + test_foo=f""" import pytest @pytest.mark.{marker}(False, reason='first_condition') @pytest.mark.{marker}(True, reason='second_condition') def test_foobar(): assert 1 - """.format( - marker=marker - ) + """ ) result = pytester.runpytest("-s", "-rsxX") result.stdout.fnmatch_lines( @@ -989,33 +982,34 @@ def test_skipped_reasons_functional(pytester: Pytester) -> None: pytester.makepyfile( test_one=""" import pytest - from conftest import doskip + from helpers import doskip - def setup_function(func): - doskip() + def setup_function(func): # LINE 4 + doskip("setup function") def test_func(): pass - class TestClass(object): + class TestClass: def test_method(self): - doskip() + doskip("test method") - @pytest.mark.skip("via_decorator") + @pytest.mark.skip("via_decorator") # LINE 14 def test_deco(self): assert 0 """, - conftest=""" + helpers=""" import pytest, sys - def doskip(): + def doskip(reason): assert sys._getframe().f_lineno == 3 - pytest.skip('test') + pytest.skip(reason) # LINE 4 """, ) result = pytester.runpytest("-rs") result.stdout.fnmatch_lines_random( [ - "SKIPPED [[]2[]] conftest.py:4: test", + "SKIPPED [[]1[]] test_one.py:7: setup function", + "SKIPPED [[]1[]] helpers.py:4: test method", "SKIPPED [[]1[]] test_one.py:14: via_decorator", ] ) @@ -1142,14 +1136,12 @@ def test_func(): """ ) result = pytester.runpytest() - markline = " ^" + markline = " ^" pypy_version_info = getattr(sys, "pypy_version_info", None) - if pypy_version_info is not None and pypy_version_info < (6,): - markline = markline[5:] - elif sys.version_info >= (3, 8) or hasattr(sys, "pypy_version_info"): - markline = markline[4:] + if pypy_version_info is not None: + markline = markline[7:] - if sys.version_info[:2] >= (3, 10): + if sys.version_info >= (3, 10): expected = [ "*ERROR*test_nameerror*", "*asd*", @@ -1495,54 +1487,6 @@ def test_failing_reason(): result.assert_outcomes(failed=1) -def test_fail_fails_with_msg_and_reason(pytester: Pytester) -> None: - p = pytester.makepyfile( - """ - import pytest - - def test_fail_both_arguments(): - pytest.fail(reason="foo", msg="bar") - """ - ) - result = pytester.runpytest(p) - result.stdout.fnmatch_lines( - "*UsageError: Passing both ``reason`` and ``msg`` to pytest.fail(...) is not permitted.*" - ) - result.assert_outcomes(failed=1) - - -def test_skip_fails_with_msg_and_reason(pytester: Pytester) -> None: - p = pytester.makepyfile( - """ - import pytest - - def test_skip_both_arguments(): - pytest.skip(reason="foo", msg="bar") - """ - ) - result = pytester.runpytest(p) - result.stdout.fnmatch_lines( - "*UsageError: Passing both ``reason`` and ``msg`` to pytest.skip(...) is not permitted.*" - ) - result.assert_outcomes(failed=1) - - -def test_exit_with_msg_and_reason_fails(pytester: Pytester) -> None: - p = pytester.makepyfile( - """ - import pytest - - def test_exit_both_arguments(): - pytest.exit(reason="foo", msg="bar") - """ - ) - result = pytester.runpytest(p) - result.stdout.fnmatch_lines( - "*UsageError: cannot pass reason and msg to exit(), `msg` is deprecated, use `reason`.*" - ) - result.assert_outcomes(failed=1) - - def test_exit_with_reason_works_ok(pytester: Pytester) -> None: p = pytester.makepyfile( """ diff --git a/testing/test_stash.py b/testing/test_stash.py index 2c9df4832e4..c7f6f4f95fe 100644 --- a/testing/test_stash.py +++ b/testing/test_stash.py @@ -1,6 +1,8 @@ -import pytest +from __future__ import annotations + from _pytest.stash import Stash from _pytest.stash import StashKey +import pytest def test_stash() -> None: diff --git a/testing/test_stepwise.py b/testing/test_stepwise.py index 85e38c7d568..d2ad3bae500 100644 --- a/testing/test_stepwise.py +++ b/testing/test_stepwise.py @@ -1,10 +1,15 @@ +# mypy: disallow-untyped-defs +from __future__ import annotations + +from collections.abc import Sequence +import json from pathlib import Path -import pytest from _pytest.cacheprovider import Cache from _pytest.monkeypatch import MonkeyPatch from _pytest.pytester import Pytester from _pytest.stepwise import STEPWISE_CACHE_DIR +import pytest @pytest.fixture @@ -81,7 +86,7 @@ def broken_pytester(pytester: Pytester) -> Pytester: return pytester -def _strip_resource_warnings(lines): +def _strip_resource_warnings(lines: Sequence[str]) -> Sequence[str]: # Strip unreliable ResourceWarnings, so no-output assertions on stderr can work. # (https://github.com/pytest-dev/pytest/issues/5088) return [ @@ -111,7 +116,10 @@ def test_data(expected): result.stdout.fnmatch_lines(["stepwise: no previously failed tests, not skipping."]) result = pytester.runpytest("-v", "--stepwise") result.stdout.fnmatch_lines( - ["stepwise: skipping 4 already passed items.", "*1 failed, 4 deselected*"] + [ + "stepwise: skipping 4 already passed items (cache from * ago, use --sw-reset to discard).", + "*1 failed, 4 deselected*", + ] ) @@ -355,3 +363,183 @@ def test_one(): with stepwise_cache_file.open(encoding="utf-8") as file_handle: observed_value = file_handle.readlines() assert [expected_value] == observed_value + + +def test_do_not_reset_cache_if_disabled(pytester: Pytester) -> None: + """ + If pytest is run without --stepwise, do not clear the stepwise cache. + + Keeping the cache around is important for this workflow: + + 1. Run tests with --stepwise + 2. Stop at the failing test, and iterate over it changing the code and running it in isolation + (in the IDE for example). + 3. Run tests with --stepwise again - at this point we expect to start from the failing test, which should now pass, + and continue with the next tests. + """ + pytester.makepyfile( + """ + def test_1(): pass + def test_2(): assert False + def test_3(): pass + """ + ) + result = pytester.runpytest("--stepwise") + result.stdout.fnmatch_lines( + [ + "*::test_2 - assert False*", + "*failed, continuing from this test next run*", + "=* 1 failed, 1 passed in *", + ] + ) + + # Run a specific test without passing `--stepwise`. + result = pytester.runpytest("-k", "test_1") + result.stdout.fnmatch_lines(["*1 passed*"]) + + # Running with `--stepwise` should continue from the last failing test. + result = pytester.runpytest("--stepwise") + result.stdout.fnmatch_lines( + [ + "stepwise: skipping 1 already passed items (cache from *, use --sw-reset to discard).", + "*::test_2 - assert False*", + "*failed, continuing from this test next run*", + "=* 1 failed, 1 deselected in *", + ] + ) + + +def test_reset(pytester: Pytester) -> None: + pytester.makepyfile( + """ + def test_1(): pass + def test_2(): assert False + def test_3(): pass + """ + ) + result = pytester.runpytest("--stepwise", "-v") + result.stdout.fnmatch_lines( + [ + "stepwise: no previously failed tests, not skipping.", + "*::test_1 *PASSED*", + "*::test_2 *FAILED*", + "*failed, continuing from this test next run*", + "* 1 failed, 1 passed in *", + ] + ) + + result = pytester.runpytest("--stepwise", "-v") + result.stdout.fnmatch_lines( + [ + "stepwise: skipping 1 already passed items (cache from *, use --sw-reset to discard).", + "*::test_2 *FAILED*", + "*failed, continuing from this test next run*", + "* 1 failed, 1 deselected in *", + ] + ) + + # Running with --stepwise-reset restarts the stepwise workflow. + result = pytester.runpytest("-v", "--stepwise-reset") + result.stdout.fnmatch_lines( + [ + "stepwise: resetting state, not skipping.", + "*::test_1 *PASSED*", + "*::test_2 *FAILED*", + "*failed, continuing from this test next run*", + "* 1 failed, 1 passed in *", + ] + ) + + +def test_change_test_count(pytester: Pytester) -> None: + # Run initially with 3 tests. + pytester.makepyfile( + """ + def test_1(): pass + def test_2(): assert False + def test_3(): pass + """ + ) + result = pytester.runpytest("--stepwise", "-v") + result.stdout.fnmatch_lines( + [ + "stepwise: no previously failed tests, not skipping.", + "*::test_1 *PASSED*", + "*::test_2 *FAILED*", + "*failed, continuing from this test next run*", + "* 1 failed, 1 passed in *", + ] + ) + + # Change the number of tests, which invalidates the test cache. + pytester.makepyfile( + """ + def test_1(): pass + def test_2(): assert False + def test_3(): pass + def test_4(): pass + """ + ) + result = pytester.runpytest("--stepwise", "-v") + result.stdout.fnmatch_lines( + [ + "stepwise: test count changed, not skipping (now 4 tests, previously 3).", + "*::test_1 *PASSED*", + "*::test_2 *FAILED*", + "*failed, continuing from this test next run*", + "* 1 failed, 1 passed in *", + ] + ) + + # Fix the failing test and run again. + pytester.makepyfile( + """ + def test_1(): pass + def test_2(): pass + def test_3(): pass + def test_4(): pass + """ + ) + result = pytester.runpytest("--stepwise", "-v") + result.stdout.fnmatch_lines( + [ + "stepwise: skipping 1 already passed items (cache from *, use --sw-reset to discard).", + "*::test_2 *PASSED*", + "*::test_3 *PASSED*", + "*::test_4 *PASSED*", + "* 3 passed, 1 deselected in *", + ] + ) + + +def test_cache_error(pytester: Pytester) -> None: + pytester.makepyfile( + """ + def test_1(): pass + """ + ) + # Run stepwise normally to generate the cache information. + result = pytester.runpytest("--stepwise", "-v") + result.stdout.fnmatch_lines( + [ + "stepwise: no previously failed tests, not skipping.", + "*::test_1 *PASSED*", + "* 1 passed in *", + ] + ) + + # Corrupt the cache. + cache_file = pytester.path / f".pytest_cache/v/{STEPWISE_CACHE_DIR}" + assert cache_file.is_file() + cache_file.write_text(json.dumps({"invalid": True}), encoding="UTF-8") + + # Check we run as if the cache did not exist, but also show an error message. + result = pytester.runpytest("--stepwise", "-v") + result.stdout.fnmatch_lines( + [ + "stepwise: error reading cache, discarding (KeyError: *", + "stepwise: no previously failed tests, not skipping.", + "*::test_1 *PASSED*", + "* 1 passed in *", + ] + ) diff --git a/testing/test_terminal.py b/testing/test_terminal.py index 7c2f7c94a50..8c50311e9aa 100644 --- a/testing/test_terminal.py +++ b/testing/test_terminal.py @@ -1,22 +1,21 @@ +# mypy: allow-untyped-defs """Terminal reporting of the full testing process.""" -import collections + +from __future__ import annotations + +from io import StringIO import os +from pathlib import Path import sys import textwrap -from io import StringIO -from pathlib import Path from types import SimpleNamespace from typing import cast -from typing import Dict -from typing import List -from typing import Tuple +from typing import NamedTuple import pluggy -import _pytest.config -import _pytest.terminal -import pytest from _pytest._io.wcwidth import wcswidth +import _pytest.config from _pytest.config import Config from _pytest.config import ExitCode from _pytest.monkeypatch import MonkeyPatch @@ -24,6 +23,7 @@ from _pytest.reports import BaseReport from _pytest.reports import CollectReport from _pytest.reports import TestReport +import _pytest.terminal from _pytest.terminal import _folded_skips from _pytest.terminal import _format_trimmed from _pytest.terminal import _get_line_with_reprcrash_message @@ -31,8 +31,12 @@ from _pytest.terminal import _plugin_nameversions from _pytest.terminal import getreportopt from _pytest.terminal import TerminalReporter +import pytest + -DistInfo = collections.namedtuple("DistInfo", ["project_name", "version"]) +class DistInfo(NamedTuple): + project_name: str + version: int TRANS_FNMATCH = str.maketrans({"[": "[[]", "]": "[]]"}) @@ -45,7 +49,7 @@ def __init__(self, verbosity=0): @property def args(self): values = [] - values.append("--verbosity=%d" % self.verbosity) + values.append(f"--verbosity={self.verbosity}") return values @@ -108,6 +112,31 @@ def test_func(): [" def test_func():", "> assert 0", "E assert 0"] ) + def test_console_output_style_times_with_skipped_and_passed( + self, pytester: Pytester + ) -> None: + pytester.makepyfile( + test_repro=""" + def test_hello(): + pass + """, + test_repro_skip=""" + import pytest + pytest.importorskip("fakepackage_does_not_exist") + """, + ) + result = pytester.runpytest( + "test_repro.py", + "test_repro_skip.py", + "-o", + "console_output_style=times", + ) + + result.stdout.fnmatch_lines("* 1 passed, 1 skipped in *") + + combined = "\n".join(result.stdout.lines + result.stderr.lines) + assert "INTERNALERROR" not in combined + def test_internalerror(self, pytester: Pytester, linecomp) -> None: modcol = pytester.getmodulecol("def test_one(): pass") rep = TerminalReporter(modcol.config, file=linecomp.stringio) @@ -155,7 +184,6 @@ def test_report_collect_after_half_a_second( self, pytester: Pytester, monkeypatch: MonkeyPatch ) -> None: """Test for "collecting" being updated after 0.5s""" - pytester.makepyfile( **{ "test1.py": """ @@ -323,16 +351,17 @@ def test_rewrite(self, pytester: Pytester, monkeypatch) -> None: tr.rewrite("hey", erase=True) assert f.getvalue() == "hello" + "\r" + "hey" + (6 * " ") + @pytest.mark.parametrize("category", ["foo", "failed", "error", "passed"]) def test_report_teststatus_explicit_markup( - self, monkeypatch: MonkeyPatch, pytester: Pytester, color_mapping + self, monkeypatch: MonkeyPatch, pytester: Pytester, color_mapping, category: str ) -> None: """Test that TerminalReporter handles markup explicitly provided by a pytest_report_teststatus hook.""" monkeypatch.setenv("PY_COLORS", "1") pytester.makeconftest( - """ + f""" def pytest_report_teststatus(report): - return 'foo', 'F', ('FOO', {'red': True}) + return {category!r}, 'F', ('FOO', {{'red': True}}) """ ) pytester.makepyfile( @@ -341,7 +370,9 @@ def test_foobar(): pass """ ) + result = pytester.runpytest("-v") + assert not result.stderr.lines result.stdout.fnmatch_lines( color_mapping.format_for_fnmatch(["*{red}FOO{reset}*"]) ) @@ -416,8 +447,8 @@ def test_long_xfail(): result = pytester.runpytest("-v") result.stdout.fnmatch_lines( - common_output - + [ + [ + *common_output, "test_verbose_skip_reason.py::test_long_skip SKIPPED (1 cannot *...) *", "test_verbose_skip_reason.py::test_long_xfail XFAIL (2 cannot *...) *", ] @@ -425,21 +456,27 @@ def test_long_xfail(): result = pytester.runpytest("-vv") result.stdout.fnmatch_lines( - common_output - + [ - ( - "test_verbose_skip_reason.py::test_long_skip SKIPPED" - " (1 cannot do foobar" - ), + [ + *common_output, + "test_verbose_skip_reason.py::test_long_skip SKIPPED" + " (1 cannot do foobar", "because baz is missing due to I don't know what) *", - ( - "test_verbose_skip_reason.py::test_long_xfail XFAIL" - " (2 cannot do foobar" - ), + "test_verbose_skip_reason.py::test_long_xfail XFAIL" + " (2 cannot do foobar", "because baz is missing due to I don't know what) *", ] ) + @pytest.mark.parametrize("isatty", [True, False]) + def test_isatty(self, pytester: Pytester, monkeypatch, isatty: bool) -> None: + config = pytester.parseconfig() + f = StringIO() + monkeypatch.setattr(f, "isatty", lambda: isatty) + tr = TerminalReporter(config, f) + assert tr.isatty() == isatty + # It was incorrectly implemented as a boolean so we still support using it as one. + assert bool(tr.isatty) == isatty + class TestCollectonly: def test_collectonly_basic(self, pytester: Pytester) -> None: @@ -451,7 +488,11 @@ def test_func(): ) result = pytester.runpytest("--collect-only") result.stdout.fnmatch_lines( - ["", " "] + [ + "", + " ", + " ", + ] ) def test_collectonly_skipped_module(self, pytester: Pytester) -> None: @@ -480,14 +521,15 @@ def test_with_description(): result = pytester.runpytest("--collect-only", "--verbose") result.stdout.fnmatch_lines( [ - "", - " ", - "", - " ", - " This test has a description.", - " ", - " more1.", - " more2.", + "", + " ", + " ", + " ", + " ", + " This test has a description.", + " ", + " more1.", + " more2.", ], consecutive=True, ) @@ -725,12 +767,12 @@ def test_three(): ) assert result.ret == 0 - def test_deselected_with_hookwrapper(self, pytester: Pytester) -> None: + def test_deselected_with_hook_wrapper(self, pytester: Pytester) -> None: pytester.makeconftest( """ import pytest - @pytest.hookimpl(hookwrapper=True) + @pytest.hookimpl(wrapper=True) def pytest_collection_modifyitems(config, items): yield deselected = items.pop() @@ -852,6 +894,7 @@ def test_header_trailer_info( self, monkeypatch: MonkeyPatch, pytester: Pytester, request ) -> None: monkeypatch.delenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD") + monkeypatch.delenv("PYTEST_PLUGINS", raising=False) pytester.makepyfile( """ def test_passes(): @@ -863,13 +906,7 @@ def test_passes(): result.stdout.fnmatch_lines( [ "*===== test session starts ====*", - "platform %s -- Python %s*pytest-%s**pluggy-%s" - % ( - sys.platform, - verinfo, - pytest.__version__, - pluggy.__version__, - ), + f"platform {sys.platform} -- Python {verinfo}*pytest-{pytest.__version__}**pluggy-{pluggy.__version__}", "*test_header_trailer_info.py .*", "=* 1 passed*in *.[0-9][0-9]s *=", ] @@ -890,13 +927,7 @@ def test_passes(): result = pytester.runpytest("--no-header") verinfo = ".".join(map(str, sys.version_info[:3])) result.stdout.no_fnmatch_line( - "platform %s -- Python %s*pytest-%s**pluggy-%s" - % ( - sys.platform, - verinfo, - pytest.__version__, - pluggy.__version__, - ) + f"platform {sys.platform} -- Python {verinfo}*pytest-{pytest.__version__}**pluggy-{pluggy.__version__}" ) if request.config.pluginmanager.list_plugin_distinfo(): result.stdout.no_fnmatch_line("plugins: *") @@ -933,16 +964,14 @@ def test_header(self, pytester: Pytester) -> None: def test_header_absolute_testpath( self, pytester: Pytester, monkeypatch: MonkeyPatch ) -> None: - """Regresstion test for #7814.""" + """Regression test for #7814.""" tests = pytester.path.joinpath("tests") tests.mkdir() pytester.makepyprojecttoml( - """ + f""" [tool.pytest.ini_options] - testpaths = ['{}'] - """.format( - tests - ) + testpaths = ['{tests}'] + """ ) result = pytester.runpytest() result.stdout.fnmatch_lines( @@ -1049,10 +1078,6 @@ def test_pass(): class TestClass(object): def test_skip(self): pytest.skip("hello") - def test_gen(): - def check(x): - assert x == 1 - yield check, 0 """ ) @@ -1065,7 +1090,6 @@ def test_verbose_reporting(self, verbose_testfile, pytester: Pytester) -> None: "*test_verbose_reporting.py::test_fail *FAIL*", "*test_verbose_reporting.py::test_pass *PASS*", "*test_verbose_reporting.py::TestClass::test_skip *SKIP*", - "*test_verbose_reporting.py::test_gen *XFAIL*", ] ) assert result.ret == 1 @@ -1157,11 +1181,49 @@ def test(): result.stdout.fnmatch_lines([expected]) assert result.stdout.lines.count(expected) == 1 + def test_summary_s_folded(self, pytester: Pytester) -> None: + """Test that skipped tests are correctly folded""" + pytester.makepyfile( + """ + import pytest + + @pytest.mark.parametrize("param", [True, False]) + @pytest.mark.skip("Some reason") + def test(param): + pass + """ + ) + result = pytester.runpytest("-rs") + expected = "SKIPPED [2] test_summary_s_folded.py:3: Some reason" + result.stdout.fnmatch_lines([expected]) + assert result.stdout.lines.count(expected) == 1 + + def test_summary_s_unfolded(self, pytester: Pytester) -> None: + """Test that skipped tests are not folded if --no-fold-skipped is set""" + pytester.makepyfile( + """ + import pytest + + @pytest.mark.parametrize("param", [True, False]) + @pytest.mark.skip("Some reason") + def test(param): + pass + """ + ) + result = pytester.runpytest("-rs", "--no-fold-skipped") + expected = [ + "SKIPPED test_summary_s_unfolded.py::test[True] - Skipped: Some reason", + "SKIPPED test_summary_s_unfolded.py::test[False] - Skipped: Some reason", + ] + result.stdout.fnmatch_lines(expected) + assert result.stdout.lines.count(expected[0]) == 1 + assert result.stdout.lines.count(expected[1]) == 1 + @pytest.mark.parametrize( ("use_ci", "expected_message"), ( - (True, f"- AssertionError: {'this_failed'*100}"), + (True, f"- AssertionError: {'this_failed' * 100}"), (False, "- AssertionError: this_failedt..."), ), ids=("on CI", "not on CI"), @@ -1268,13 +1330,13 @@ def test_this(): "=*= FAILURES =*=", "{red}{bold}_*_ test_this _*_{reset}", "", - " {kw}def{hl-reset} {function}test_this{hl-reset}():{endline}", + " {reset}{kw}def{hl-reset}{kwspace}{function}test_this{hl-reset}():{endline}", "> fail(){endline}", "", "{bold}{red}test_color_yes.py{reset}:5: ", "_ _ * _ _*", "", - " {kw}def{hl-reset} {function}fail{hl-reset}():{endline}", + " {reset}{kw}def{hl-reset}{kwspace}{function}fail{hl-reset}():{endline}", "> {kw}assert{hl-reset} {number}0{hl-reset}{endline}", "{bold}{red}E assert 0{reset}", "", @@ -1295,9 +1357,9 @@ def test_this(): "=*= FAILURES =*=", "{red}{bold}_*_ test_this _*_{reset}", "{bold}{red}test_color_yes.py{reset}:5: in test_this", - " fail(){endline}", + " {reset}fail(){endline}", "{bold}{red}test_color_yes.py{reset}:2: in fail", - " {kw}assert{hl-reset} {number}0{hl-reset}{endline}", + " {reset}{kw}assert{hl-reset} {number}0{hl-reset}{endline}", "{bold}{red}E assert 0{reset}", "{red}=*= {red}{bold}1 failed{reset}{red} in *s{reset}{red} =*={reset}", ] @@ -1430,7 +1492,7 @@ def test_opt(arg): s = result.stdout.str() assert "arg = 42" not in s assert "x = 0" not in s - result.stdout.fnmatch_lines(["*%s:8*" % p.name, " assert x", "E assert*"]) + result.stdout.fnmatch_lines([f"*{p.name}:8*", " assert x", "E assert*"]) result = pytester.runpytest() s = result.stdout.str() assert "x = 0" in s @@ -1506,8 +1568,8 @@ def test_func(): """ ) for tbopt in ["long", "short", "no"]: - print("testing --tb=%s..." % tbopt) - result = pytester.runpytest("-rN", "--tb=%s" % tbopt) + print(f"testing --tb={tbopt}...") + result = pytester.runpytest("-rN", f"--tb={tbopt}") s = result.stdout.str() if tbopt == "long": assert "print(6*7)" in s @@ -1537,7 +1599,7 @@ def test_func2(): result = pytester.runpytest("--tb=line") bn = p.name result.stdout.fnmatch_lines( - ["*%s:3: IndexError*" % bn, "*%s:8: AssertionError: hello*" % bn] + [f"*{bn}:3: IndexError*", f"*{bn}:8: AssertionError: hello*"] ) s = result.stdout.str() assert "def test_func2" not in s @@ -1553,7 +1615,7 @@ def test_func1(): result = pytester.runpytest("--tb=line") result.stdout.str() bn = p.name - result.stdout.fnmatch_lines(["*%s:3: Failed: test_func1" % bn]) + result.stdout.fnmatch_lines([f"*{bn}:3: Failed: test_func1"]) def test_pytest_report_header(self, pytester: Pytester, option) -> None: pytester.makeconftest( @@ -1802,7 +1864,7 @@ def test_failure(): @pytest.fixture(scope="session") def tr() -> TerminalReporter: - config = _pytest.config._prepareconfig() + config = _pytest.config._prepareconfig([]) return TerminalReporter(config) @@ -1938,9 +2000,9 @@ def tr() -> TerminalReporter: ) def test_summary_stats( tr: TerminalReporter, - exp_line: List[Tuple[str, Dict[str, bool]]], + exp_line: list[tuple[str, dict[str, bool]]], exp_color: str, - stats_arg: Dict[str, List[object]], + stats_arg: dict[str, list[object]], ) -> None: tr.stats = stats_arg @@ -1954,7 +2016,7 @@ class fake_session: # Reset cache. tr._main_color = None - print("Based on stats: %s" % stats_arg) + print(f"Based on stats: {stats_arg}") print(f'Expect summary: "{exp_line}"; with color "{exp_color}"') (line, color) = tr.build_summary_stats_line() print(f'Actually got: "{line}"; with color "{color}"') @@ -2001,9 +2063,9 @@ def test_normal_verbosity(self, pytester: Pytester, test_files) -> None: result = pytester.runpytest("-o", "console_output_style=classic") result.stdout.fnmatch_lines( [ + f"sub{os.sep}test_three.py .F.", "test_one.py .", "test_two.py F", - f"sub{os.sep}test_three.py .F.", "*2 failed, 3 passed in*", ] ) @@ -2012,18 +2074,18 @@ def test_verbose(self, pytester: Pytester, test_files) -> None: result = pytester.runpytest("-o", "console_output_style=classic", "-v") result.stdout.fnmatch_lines( [ - "test_one.py::test_one PASSED", - "test_two.py::test_two FAILED", f"sub{os.sep}test_three.py::test_three_1 PASSED", f"sub{os.sep}test_three.py::test_three_2 FAILED", f"sub{os.sep}test_three.py::test_three_3 PASSED", + "test_one.py::test_one PASSED", + "test_two.py::test_two FAILED", "*2 failed, 3 passed in*", ] ) def test_quiet(self, pytester: Pytester, test_files) -> None: result = pytester.runpytest("-o", "console_output_style=classic", "-q") - result.stdout.fnmatch_lines([".F.F.", "*2 failed, 3 passed in*"]) + result.stdout.fnmatch_lines([".F..F", "*2 failed, 3 passed in*"]) class TestProgressOutputStyle: @@ -2047,6 +2109,21 @@ def test_foobar(i): pass """, ) + @pytest.fixture + def more_tests_files(self, pytester: Pytester) -> None: + pytester.makepyfile( + test_bar=""" + import pytest + @pytest.mark.parametrize('i', range(30)) + def test_bar(i): pass + """, + test_foo=""" + import pytest + @pytest.mark.parametrize('i', range(5)) + def test_foo(i): pass + """, + ) + def test_zero_tests_collected(self, pytester: Pytester) -> None: """Some plugins (testmon for example) might issue pytest_runtest_logreport without any tests being actually collected (#2971).""" @@ -2143,6 +2220,52 @@ def test_count(self, many_tests_files, pytester: Pytester) -> None: ] ) + def test_times(self, many_tests_files, pytester: Pytester) -> None: + pytester.makeini( + """ + [pytest] + console_output_style = times + """ + ) + output = pytester.runpytest() + output.stdout.re_match_lines( + [ + r"test_bar.py \.{10} \s+ \d{1,3}[\.[a-z\ ]{1,2}\d{0,3}\w{1,2}$", + r"test_foo.py \.{5} \s+ \d{1,3}[\.[a-z\ ]{1,2}\d{0,3}\w{1,2}$", + r"test_foobar.py \.{5} \s+ \d{1,3}[\.[a-z\ ]{1,2}\d{0,3}\w{1,2}$", + ] + ) + + def test_times_multiline( + self, more_tests_files, monkeypatch, pytester: Pytester + ) -> None: + monkeypatch.setenv("COLUMNS", "40") + pytester.makeini( + """ + [pytest] + console_output_style = times + """ + ) + output = pytester.runpytest() + output.stdout.re_match_lines( + [ + r"test_bar.py ...................", + r"........... \s+ \d{1,3}[\.[a-z\ ]{1,2}\d{0,3}\w{1,2}$", + r"test_foo.py \.{5} \s+ \d{1,3}[\.[a-z\ ]{1,2}\d{0,3}\w{1,2}$", + ], + consecutive=True, + ) + + def test_times_none_collected(self, pytester: Pytester) -> None: + pytester.makeini( + """ + [pytest] + console_output_style = times + """ + ) + output = pytester.runpytest() + assert output.ret == ExitCode.NO_TESTS_COLLECTED + def test_verbose(self, many_tests_files, pytester: Pytester) -> None: output = pytester.runpytest("-v") output.stdout.re_match_lines( @@ -2169,6 +2292,22 @@ def test_verbose_count(self, many_tests_files, pytester: Pytester) -> None: ] ) + def test_verbose_times(self, many_tests_files, pytester: Pytester) -> None: + pytester.makeini( + """ + [pytest] + console_output_style = times + """ + ) + output = pytester.runpytest("-v") + output.stdout.re_match_lines( + [ + r"test_bar.py::test_bar\[0\] PASSED \s+ \d{1,3}[\.[a-z\ ]{1,2}\d{0,3}\w{1,2}$", + r"test_foo.py::test_foo\[4\] PASSED \s+ \d{1,3}[\.[a-z\ ]{1,2}\d{0,3}\w{1,2}$", + r"test_foobar.py::test_foobar\[4\] PASSED \s+ \d{1,3}[\.[a-z\ ]{1,2}\d{0,3}\w{1,2}$", + ] + ) + def test_xdist_normal( self, many_tests_files, pytester: Pytester, monkeypatch ) -> None: @@ -2221,6 +2360,26 @@ def test_xdist_verbose( ] ) + def test_xdist_times( + self, many_tests_files, pytester: Pytester, monkeypatch + ) -> None: + pytest.importorskip("xdist") + monkeypatch.delenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD", raising=False) + pytester.makeini( + """ + [pytest] + console_output_style = times + """ + ) + output = pytester.runpytest("-n2", "-v") + output.stdout.re_match_lines_random( + [ + r"\[gw\d\] \d{1,3}[\.[a-z\ ]{1,2}\d{0,3}\w{1,2} PASSED test_bar.py::test_bar\[1\]", + r"\[gw\d\] \d{1,3}[\.[a-z\ ]{1,2}\d{0,3}\w{1,2} PASSED test_foo.py::test_foo\[1\]", + r"\[gw\d\] \d{1,3}[\.[a-z\ ]{1,2}\d{0,3}\w{1,2} PASSED test_foobar.py::test_foobar\[1\]", + ] + ) + def test_capture_no(self, many_tests_files, pytester: Pytester) -> None: output = pytester.runpytest("-s") output.stdout.re_match_lines( @@ -2386,12 +2545,17 @@ def mock_get_pos(*args): monkeypatch.setattr(_pytest.terminal, "_get_node_id_with_markup", mock_get_pos) + class Namespace: + def __init__(self, **kwargs): + self.__dict__.update(kwargs) + class config: - pass + def __init__(self): + self.option = Namespace(verbose=0) class rep: - def _get_verbose_word(self, *args): - return mocked_verbose_word + def _get_verbose_word_with_markup(self, *args): + return mocked_verbose_word, {} class longrepr: class reprcrash: @@ -2407,7 +2571,12 @@ def markup(self, word: str, **markup: str): __tracebackhide__ = True if msg: rep.longrepr.reprcrash.message = msg # type: ignore - actual = _get_line_with_reprcrash_message(config, rep(), DummyTerminalWriter(), {}) # type: ignore + actual = _get_line_with_reprcrash_message( + config(), # type: ignore[arg-type] + rep(), # type: ignore[arg-type] + DummyTerminalWriter(), # type: ignore[arg-type] + {}, + ) assert actual == expected if actual != f"{mocked_verbose_word} {mocked_pos}": @@ -2447,6 +2616,89 @@ def markup(self, word: str, **markup: str): check("🉐🉐🉐🉐🉐\n2nd line", 80, "FAILED nodeid::🉐::withunicode - 🉐🉐🉐🉐🉐") +def test_short_summary_with_verbose( + monkeypatch: MonkeyPatch, pytester: Pytester +) -> None: + """With -vv do not truncate the summary info (#11777).""" + # On CI we also do not truncate the summary info, monkeypatch it to ensure we + # are testing against the -vv flag on CI. + monkeypatch.setattr(_pytest.terminal, "running_on_ci", lambda: False) + + string_length = 200 + pytester.makepyfile( + f""" + def test(): + s1 = "A" * {string_length} + s2 = "B" * {string_length} + assert s1 == s2 + """ + ) + + # No -vv, summary info should be truncated. + result = pytester.runpytest() + result.stdout.fnmatch_lines( + [ + "*short test summary info*", + "* assert 'AAA...", + ], + ) + + # No truncation with -vv. + result = pytester.runpytest("-vv") + result.stdout.fnmatch_lines( + [ + "*short test summary info*", + f"*{'A' * string_length}*{'B' * string_length}'", + ] + ) + + +def test_full_sequence_print_with_vv( + monkeypatch: MonkeyPatch, pytester: Pytester +) -> None: + """Do not truncate sequences in summaries with -vv (#11777).""" + monkeypatch.setattr(_pytest.terminal, "running_on_ci", lambda: False) + + pytester.makepyfile( + """ + def test_len_list(): + l = list(range(10)) + assert len(l) == 9 + + def test_len_dict(): + d = dict(zip(range(10), range(10))) + assert len(d) == 9 + """ + ) + + result = pytester.runpytest("-vv") + assert result.ret == 1 + result.stdout.fnmatch_lines( + [ + "*short test summary info*", + f"*{list(range(10))}*", + f"*{dict(zip(range(10), range(10)))}*", + ] + ) + + +def test_force_short_summary(monkeypatch: MonkeyPatch, pytester: Pytester) -> None: + monkeypatch.setattr(_pytest.terminal, "running_on_ci", lambda: False) + + pytester.makepyfile( + """ + def test(): + assert "a\\n" * 10 == "" + """ + ) + + result = pytester.runpytest("-vv", "--force-short-summary") + assert result.ret == 1 + result.stdout.fnmatch_lines( + ["*short test summary info*", "*AssertionError: assert 'a\\na\\na\\na..."] + ) + + @pytest.mark.parametrize( "seconds, expected", [ @@ -2464,6 +2716,27 @@ def test_format_session_duration(seconds, expected): assert format_session_duration(seconds) == expected +@pytest.mark.parametrize( + "seconds, expected", + [ + (3600 * 100 - 60, " 99h 59m"), + (31 * 60 - 1, " 30m 59s"), + (10.1236, " 10.124s"), + (9.1236, " 9.124s"), + (0.1236, " 123.6ms"), + (0.01236, " 12.36ms"), + (0.001236, " 1.236ms"), + (0.0001236, " 123.6us"), + (0.00001236, " 12.36us"), + (0.000001236, " 1.236us"), + ], +) +def test_format_node_duration(seconds: float, expected: str) -> None: + from _pytest.terminal import format_node_duration + + assert format_node_duration(seconds) == expected + + def test_collecterror(pytester: Pytester) -> None: p1 = pytester.makepyfile("raise SyntaxError()") result = pytester.runpytest("-ra", str(p1)) @@ -2507,7 +2780,7 @@ def test_foo(): result.stdout.fnmatch_lines( color_mapping.format_for_fnmatch( [ - " {kw}def{hl-reset} {function}test_foo{hl-reset}():{endline}", + " {reset}{kw}def{hl-reset}{kwspace}{function}test_foo{hl-reset}():{endline}", "> {kw}assert{hl-reset} {number}1{hl-reset} == {number}10{hl-reset}{endline}", "{bold}{red}E assert 1 == 10{reset}", ] @@ -2529,7 +2802,7 @@ def test_foo(): result.stdout.fnmatch_lines( color_mapping.format_for_fnmatch( [ - " {kw}def{hl-reset} {function}test_foo{hl-reset}():{endline}", + " {reset}{kw}def{hl-reset}{kwspace}{function}test_foo{hl-reset}():{endline}", " {print}print{hl-reset}({str}'''{hl-reset}{str}{hl-reset}", "> {str} {hl-reset}{str}'''{hl-reset}); {kw}assert{hl-reset} {number}0{hl-reset}{endline}", "{bold}{red}E assert 0{reset}", @@ -2552,7 +2825,7 @@ def test_foo(): result.stdout.fnmatch_lines( color_mapping.format_for_fnmatch( [ - " {kw}def{hl-reset} {function}test_foo{hl-reset}():{endline}", + " {reset}{kw}def{hl-reset}{kwspace}{function}test_foo{hl-reset}():{endline}", "> {kw}assert{hl-reset} {number}1{hl-reset} == {number}10{hl-reset}{endline}", "{bold}{red}E assert 1 == 10{reset}", ] @@ -2571,8 +2844,8 @@ def test_foo(): monkeypatch.setenv("PYTEST_THEME", "invalid") result = pytester.runpytest_subprocess("--color=yes") result.stderr.fnmatch_lines( - "ERROR: PYTEST_THEME environment variable had an invalid value: 'invalid'. " - "Only valid pygment styles are allowed." + "ERROR: PYTEST_THEME environment variable has an invalid value: 'invalid'. " + "Hint: See available pygments styles with `pygmentize -L styles`." ) def test_code_highlight_invalid_theme_mode( @@ -2587,8 +2860,8 @@ def test_foo(): monkeypatch.setenv("PYTEST_THEME_MODE", "invalid") result = pytester.runpytest_subprocess("--color=yes") result.stderr.fnmatch_lines( - "ERROR: PYTEST_THEME_MODE environment variable had an invalid value: 'invalid'. " - "The only allowed values are 'dark' and 'light'." + "ERROR: PYTEST_THEME_MODE environment variable has an invalid value: 'invalid'. " + "The allowed values are 'dark' (default) and 'light'." ) @@ -2614,3 +2887,413 @@ def test_format_trimmed() -> None: assert _format_trimmed(" ({}) ", msg, len(msg) + 4) == " (unconditional skip) " assert _format_trimmed(" ({}) ", msg, len(msg) + 3) == " (unconditional ...) " + + +class TestFineGrainedTestCase: + DEFAULT_FILE_CONTENTS = """ + import pytest + + @pytest.mark.parametrize("i", range(4)) + def test_ok(i): + ''' + some docstring + ''' + pass + + def test_fail(): + assert False + """ + LONG_SKIP_FILE_CONTENTS = """ + import pytest + + @pytest.mark.skip( + "some long skip reason that will not fit on a single line with other content that goes" + " on and on and on and on and on" + ) + def test_skip(): + pass + """ + + @pytest.mark.parametrize("verbosity", [1, 2]) + def test_execute_positive(self, verbosity, pytester: Pytester) -> None: + # expected: one test case per line (with file name), word describing result + p = TestFineGrainedTestCase._initialize_files(pytester, verbosity=verbosity) + result = pytester.runpytest(p) + + result.stdout.fnmatch_lines( + [ + "collected 5 items", + "", + f"{p.name}::test_ok[0] PASSED [ 20%]", + f"{p.name}::test_ok[1] PASSED [ 40%]", + f"{p.name}::test_ok[2] PASSED [ 60%]", + f"{p.name}::test_ok[3] PASSED [ 80%]", + f"{p.name}::test_fail FAILED [100%]", + ], + consecutive=True, + ) + + def test_execute_0_global_1(self, pytester: Pytester) -> None: + # expected: one file name per line, single character describing result + p = TestFineGrainedTestCase._initialize_files(pytester, verbosity=0) + result = pytester.runpytest("-v", p) + + result.stdout.fnmatch_lines( + [ + "collecting ... collected 5 items", + "", + f"{p.name} ....F [100%]", + ], + consecutive=True, + ) + + @pytest.mark.parametrize("verbosity", [-1, -2]) + def test_execute_negative(self, verbosity, pytester: Pytester) -> None: + # expected: single character describing result + p = TestFineGrainedTestCase._initialize_files(pytester, verbosity=verbosity) + result = pytester.runpytest(p) + + result.stdout.fnmatch_lines( + [ + "collected 5 items", + "....F [100%]", + ], + consecutive=True, + ) + + def test_execute_skipped_positive_2(self, pytester: Pytester) -> None: + # expected: one test case per line (with file name), word describing result, full reason + p = TestFineGrainedTestCase._initialize_files( + pytester, + verbosity=2, + file_contents=TestFineGrainedTestCase.LONG_SKIP_FILE_CONTENTS, + ) + result = pytester.runpytest(p) + + result.stdout.fnmatch_lines( + [ + "collected 1 item", + "", + f"{p.name}::test_skip SKIPPED (some long skip", + "reason that will not fit on a single line with other content that goes", + "on and on and on and on and on) [100%]", + ], + consecutive=True, + ) + + def test_execute_skipped_positive_1(self, pytester: Pytester) -> None: + # expected: one test case per line (with file name), word describing result, reason truncated + p = TestFineGrainedTestCase._initialize_files( + pytester, + verbosity=1, + file_contents=TestFineGrainedTestCase.LONG_SKIP_FILE_CONTENTS, + ) + result = pytester.runpytest(p) + + result.stdout.fnmatch_lines( + [ + "collected 1 item", + "", + f"{p.name}::test_skip SKIPPED (some long ski...) [100%]", + ], + consecutive=True, + ) + + def test_execute_skipped__0_global_1(self, pytester: Pytester) -> None: + # expected: one file name per line, single character describing result (no reason) + p = TestFineGrainedTestCase._initialize_files( + pytester, + verbosity=0, + file_contents=TestFineGrainedTestCase.LONG_SKIP_FILE_CONTENTS, + ) + result = pytester.runpytest("-v", p) + + result.stdout.fnmatch_lines( + [ + "collecting ... collected 1 item", + "", + f"{p.name} s [100%]", + ], + consecutive=True, + ) + + @pytest.mark.parametrize("verbosity", [-1, -2]) + def test_execute_skipped_negative(self, verbosity, pytester: Pytester) -> None: + # expected: single character describing result (no reason) + p = TestFineGrainedTestCase._initialize_files( + pytester, + verbosity=verbosity, + file_contents=TestFineGrainedTestCase.LONG_SKIP_FILE_CONTENTS, + ) + result = pytester.runpytest(p) + + result.stdout.fnmatch_lines( + [ + "collected 1 item", + "s [100%]", + ], + consecutive=True, + ) + + @pytest.mark.parametrize("verbosity", [1, 2]) + def test__collect_only_positive(self, verbosity, pytester: Pytester) -> None: + p = TestFineGrainedTestCase._initialize_files(pytester, verbosity=verbosity) + result = pytester.runpytest("--collect-only", p) + + result.stdout.fnmatch_lines( + [ + "collected 5 items", + "", + f"", + f" ", + " ", + " some docstring", + " ", + " some docstring", + " ", + " some docstring", + " ", + " some docstring", + " ", + ], + consecutive=True, + ) + + def test_collect_only_0_global_1(self, pytester: Pytester) -> None: + p = TestFineGrainedTestCase._initialize_files(pytester, verbosity=0) + result = pytester.runpytest("-v", "--collect-only", p) + + result.stdout.fnmatch_lines( + [ + "collecting ... collected 5 items", + "", + f"", + f" ", + " ", + " ", + " ", + " ", + " ", + ], + consecutive=True, + ) + + def test_collect_only_negative_1(self, pytester: Pytester) -> None: + p = TestFineGrainedTestCase._initialize_files(pytester, verbosity=-1) + result = pytester.runpytest("--collect-only", p) + + result.stdout.fnmatch_lines( + [ + "collected 5 items", + "", + f"{p.name}::test_ok[0]", + f"{p.name}::test_ok[1]", + f"{p.name}::test_ok[2]", + f"{p.name}::test_ok[3]", + f"{p.name}::test_fail", + ], + consecutive=True, + ) + + def test_collect_only_negative_2(self, pytester: Pytester) -> None: + p = TestFineGrainedTestCase._initialize_files(pytester, verbosity=-2) + result = pytester.runpytest("--collect-only", p) + + result.stdout.fnmatch_lines( + [ + "collected 5 items", + "", + f"{p.name}: 5", + ], + consecutive=True, + ) + + @staticmethod + def _initialize_files( + pytester: Pytester, verbosity: int, file_contents: str = DEFAULT_FILE_CONTENTS + ) -> Path: + p = pytester.makepyfile(file_contents) + pytester.makeini( + f""" + [pytest] + verbosity_test_cases = {verbosity} + """ + ) + return p + + +def test_summary_xfail_reason(pytester: Pytester) -> None: + pytester.makepyfile( + """ + import pytest + + @pytest.mark.xfail + def test_xfail(): + assert False + + @pytest.mark.xfail(reason="foo") + def test_xfail_reason(): + assert False + """ + ) + result = pytester.runpytest("-rx") + expect1 = "XFAIL test_summary_xfail_reason.py::test_xfail" + expect2 = "XFAIL test_summary_xfail_reason.py::test_xfail_reason - foo" + result.stdout.fnmatch_lines([expect1, expect2]) + assert result.stdout.lines.count(expect1) == 1 + assert result.stdout.lines.count(expect2) == 1 + + +@pytest.fixture() +def xfail_testfile(pytester: Pytester) -> Path: + return pytester.makepyfile( + """ + import pytest + + def test_fail(): + a, b = 1, 2 + assert a == b + + @pytest.mark.xfail + def test_xfail(): + c, d = 3, 4 + assert c == d + """ + ) + + +def test_xfail_tb_default(xfail_testfile, pytester: Pytester) -> None: + result = pytester.runpytest(xfail_testfile) + + # test_fail, show traceback + result.stdout.fnmatch_lines( + [ + "*= FAILURES =*", + "*_ test_fail _*", + "*def test_fail():*", + "* a, b = 1, 2*", + "*> assert a == b*", + "*E assert 1 == 2*", + ] + ) + + # test_xfail, don't show traceback + result.stdout.no_fnmatch_line("*= XFAILURES =*") + + +def test_xfail_tb_true(xfail_testfile, pytester: Pytester) -> None: + result = pytester.runpytest(xfail_testfile, "--xfail-tb") + + # both test_fail and test_xfail, show traceback + result.stdout.fnmatch_lines( + [ + "*= FAILURES =*", + "*_ test_fail _*", + "*def test_fail():*", + "* a, b = 1, 2*", + "*> assert a == b*", + "*E assert 1 == 2*", + "*= XFAILURES =*", + "*_ test_xfail _*", + "*def test_xfail():*", + "* c, d = 3, 4*", + "*> assert c == d*", + "*E assert 3 == 4*", + "*short test summary info*", + ] + ) + + +def test_xfail_tb_line(xfail_testfile, pytester: Pytester) -> None: + result = pytester.runpytest(xfail_testfile, "--xfail-tb", "--tb=line") + + # both test_fail and test_xfail, show line + result.stdout.fnmatch_lines( + [ + "*= FAILURES =*", + "*test_xfail_tb_line.py:5: assert 1 == 2", + "*= XFAILURES =*", + "*test_xfail_tb_line.py:10: assert 3 == 4", + "*short test summary info*", + ] + ) + + +def test_summary_xpass_reason(pytester: Pytester) -> None: + pytester.makepyfile( + """ + import pytest + + @pytest.mark.xfail + def test_pass(): + ... + + @pytest.mark.xfail(reason="foo") + def test_reason(): + ... + """ + ) + result = pytester.runpytest("-rX") + expect1 = "XPASS test_summary_xpass_reason.py::test_pass" + expect2 = "XPASS test_summary_xpass_reason.py::test_reason - foo" + result.stdout.fnmatch_lines([expect1, expect2]) + assert result.stdout.lines.count(expect1) == 1 + assert result.stdout.lines.count(expect2) == 1 + + +def test_xpass_output(pytester: Pytester) -> None: + pytester.makepyfile( + """ + import pytest + + @pytest.mark.xfail + def test_pass(): + print('hi there') + """ + ) + result = pytester.runpytest("-rX") + result.stdout.fnmatch_lines( + [ + "*= XPASSES =*", + "*_ test_pass _*", + "*- Captured stdout call -*", + "*= short test summary info =*", + "XPASS test_xpass_output.py::test_pass*", + "*= 1 xpassed in * =*", + ] + ) + + +class TestNodeIDHandling: + def test_nodeid_handling_windows_paths(self, pytester: Pytester, tmp_path) -> None: + """Test the correct handling of Windows-style paths with backslashes.""" + pytester.makeini("[pytest]") # Change `config.rootpath` + + test_path = pytester.path / "tests" / "test_foo.py" + test_path.parent.mkdir() + os.chdir(test_path.parent) # Change `config.invocation_params.dir` + + test_path.write_text( + textwrap.dedent( + """ + import pytest + + @pytest.mark.parametrize("a", ["x/y", "C:/path", "\\\\", "C:\\\\path", "a::b/"]) + def test_x(a): + assert False + """ + ), + encoding="utf-8", + ) + + result = pytester.runpytest("-v") + + result.stdout.re_match_lines( + [ + r".*test_foo.py::test_x\[x/y\] .*FAILED.*", + r".*test_foo.py::test_x\[C:/path\] .*FAILED.*", + r".*test_foo.py::test_x\[\\\\\] .*FAILED.*", + r".*test_foo.py::test_x\[C:\\\\path\] .*FAILED.*", + r".*test_foo.py::test_x\[a::b/\] .*FAILED.*", + ] + ) diff --git a/testing/test_threadexception.py b/testing/test_threadexception.py index 5b7519f27d8..f4595ec435d 100644 --- a/testing/test_threadexception.py +++ b/testing/test_threadexception.py @@ -1,11 +1,7 @@ -import sys +from __future__ import annotations -import pytest from _pytest.pytester import Pytester - - -if sys.version_info < (3, 8): - pytest.skip("threadexception plugin needs Python>=3.8", allow_module_level=True) +import pytest @pytest.mark.filterwarnings("default::pytest.PytestUnhandledThreadExceptionWarning") @@ -27,7 +23,7 @@ def test_2(): pass ) result = pytester.runpytest() assert result.ret == 0 - assert result.parseoutcomes() == {"passed": 2, "warnings": 1} + result.assert_outcomes(passed=2, warnings=1) result.stdout.fnmatch_lines( [ "*= warnings summary =*", @@ -63,7 +59,7 @@ def test_2(): pass ) result = pytester.runpytest() assert result.ret == 0 - assert result.parseoutcomes() == {"passed": 2, "warnings": 1} + result.assert_outcomes(passed=2, warnings=1) result.stdout.fnmatch_lines( [ "*= warnings summary =*", @@ -100,7 +96,7 @@ def test_2(): pass ) result = pytester.runpytest() assert result.ret == 0 - assert result.parseoutcomes() == {"passed": 2, "warnings": 1} + result.assert_outcomes(passed=2, warnings=1) result.stdout.fnmatch_lines( [ "*= warnings summary =*", @@ -134,4 +130,126 @@ def test_2(): pass ) result = pytester.runpytest() assert result.ret == pytest.ExitCode.TESTS_FAILED - assert result.parseoutcomes() == {"passed": 1, "failed": 1} + result.assert_outcomes(passed=1, failed=1) + + +@pytest.mark.filterwarnings("error::pytest.PytestUnhandledThreadExceptionWarning") +def test_threadexception_warning_multiple_errors(pytester: Pytester) -> None: + pytester.makepyfile( + test_it=""" + import threading + + def test_it(): + def oops(): + raise ValueError("Oops") + + t = threading.Thread(target=oops, name="MyThread") + t.start() + t.join() + + t = threading.Thread(target=oops, name="MyThread2") + t.start() + t.join() + + def test_2(): pass + """ + ) + result = pytester.runpytest() + assert result.ret == pytest.ExitCode.TESTS_FAILED + result.assert_outcomes(passed=1, failed=1) + result.stdout.fnmatch_lines( + [" | *ExceptionGroup: multiple thread exception warnings (2 sub-exceptions)"] + ) + + +def test_unraisable_collection_failure(pytester: Pytester) -> None: + pytester.makepyfile( + test_it=""" + import threading + + class Thread(threading.Thread): + @property + def name(self): + raise RuntimeError("oops!") + + def test_it(): + def oops(): + raise ValueError("Oops") + + t = Thread(target=oops, name="MyThread") + t.start() + t.join() + + def test_2(): pass + """ + ) + + result = pytester.runpytest() + assert result.ret == 1 + result.assert_outcomes(passed=1, failed=1) + result.stdout.fnmatch_lines( + ["E RuntimeError: Failed to process thread exception"] + ) + + +def test_unhandled_thread_exception_after_teardown(pytester: Pytester) -> None: + pytester.makepyfile( + test_it=""" + import threading + import pytest + + def thread(): + def oops(): + raise ValueError("Oops") + + t = threading.Thread(target=oops, name="MyThread") + t.start() + t.join() + + def test_it(request): + request.config.add_cleanup(thread) + """ + ) + + result = pytester.runpytest("-Werror") + + # TODO: should be a test failure or error + assert result.ret == pytest.ExitCode.INTERNAL_ERROR + + result.assert_outcomes(passed=1) + result.stderr.fnmatch_lines("ValueError: Oops") + + +@pytest.mark.filterwarnings("error::pytest.PytestUnhandledThreadExceptionWarning") +def test_possibly_none_excinfo(pytester: Pytester) -> None: + pytester.makepyfile( + test_it=""" + import threading + import types + + def test_it(): + threading.excepthook( + types.SimpleNamespace( + exc_type=RuntimeError, + exc_value=None, + exc_traceback=None, + thread=None, + ) + ) + """ + ) + + result = pytester.runpytest() + + # TODO: should be a test failure or error + assert result.ret == pytest.ExitCode.TESTS_FAILED + + result.assert_outcomes(failed=1) + result.stdout.fnmatch_lines( + [ + "E pytest.PytestUnhandledThreadExceptionWarning:" + " Exception in thread ", + "E ", + "E NoneType: None", + ] + ) diff --git a/testing/test_tmpdir.py b/testing/test_tmpdir.py index 1e1446af127..016588a143d 100644 --- a/testing/test_tmpdir.py +++ b/testing/test_tmpdir.py @@ -1,15 +1,15 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + +from collections.abc import Callable import dataclasses import os +from pathlib import Path import stat import sys -import warnings -from pathlib import Path -from typing import Callable from typing import cast -from typing import List -from typing import Union +import warnings -import pytest from _pytest import pathlib from _pytest.config import Config from _pytest.monkeypatch import MonkeyPatch @@ -23,6 +23,7 @@ from _pytest.pytester import Pytester from _pytest.tmpdir import get_user from _pytest.tmpdir import TempPathFactory +import pytest def test_tmp_path_fixture(pytester: Pytester) -> None: @@ -33,7 +34,7 @@ def test_tmp_path_fixture(pytester: Pytester) -> None: @dataclasses.dataclass class FakeConfig: - basetemp: Union[str, Path] + basetemp: str | Path @property def trace(self): @@ -86,11 +87,11 @@ def test_1(tmp_path): pass """ ) - pytester.runpytest(p, "--basetemp=%s" % mytemp) + pytester.runpytest(p, f"--basetemp={mytemp}") assert mytemp.exists() mytemp.joinpath("hello").touch() - pytester.runpytest(p, "--basetemp=%s" % mytemp) + pytester.runpytest(p, f"--basetemp={mytemp}") assert mytemp.exists() assert not mytemp.joinpath("hello").exists() @@ -241,15 +242,13 @@ def test_fixt(fixt): def test_mktemp(pytester: Pytester, basename: str, is_ok: bool) -> None: mytemp = pytester.mkdir("mytemp") p = pytester.makepyfile( - """ + f""" def test_abs_path(tmp_path_factory): - tmp_path_factory.mktemp('{}', numbered=False) - """.format( - basename - ) + tmp_path_factory.mktemp('{basename}', numbered=False) + """ ) - result = pytester.runpytest(p, "--basetemp=%s" % mytemp) + result = pytester.runpytest(p, f"--basetemp={mytemp}") if is_ok: assert result.ret == 0 assert mytemp.joinpath(basename).exists() @@ -337,7 +336,6 @@ def test_tmp_path_fallback_uid_not_found(pytester: Pytester) -> None: """Test that tmp_path works even if the current process's user id does not correspond to a valid user. """ - pytester.makepyfile( """ def test_some(tmp_path): @@ -396,7 +394,7 @@ def test_cleanup_lock_create(self, tmp_path): def test_lock_register_cleanup_removal(self, tmp_path: Path) -> None: lock = create_cleanup_lock(tmp_path) - registry: List[Callable[..., None]] = [] + registry: list[Callable[..., None]] = [] register_cleanup_lock_removal(lock, register=registry.append) (cleanup_func,) = registry @@ -530,13 +528,11 @@ def test_on_rm_rf_error(self, tmp_path: Path) -> None: assert fn.is_file() # ignored function - with warnings.catch_warnings(): - warnings.simplefilter("ignore") - with pytest.warns(None) as warninfo: # type: ignore[call-overload] - exc_info4 = PermissionError() - on_rm_rf_error(os.open, str(fn), exc_info4, start_path=tmp_path) - assert fn.is_file() - assert not [x.message for x in warninfo] + with warnings.catch_warnings(record=True) as w: + exc_info4 = PermissionError() + on_rm_rf_error(os.open, str(fn), exc_info4, start_path=tmp_path) + assert fn.is_file() + assert not [x.message for x in w] exc_info5 = PermissionError() on_rm_rf_error(os.unlink, str(fn), exc_info5, start_path=tmp_path) diff --git a/testing/test_unittest.py b/testing/test_unittest.py index d917d331a03..56224c08228 100644 --- a/testing/test_unittest.py +++ b/testing/test_unittest.py @@ -1,11 +1,12 @@ -import gc +# mypy: allow-untyped-defs +from __future__ import annotations + import sys -from typing import List -import pytest from _pytest.config import ExitCode from _pytest.monkeypatch import MonkeyPatch from _pytest.pytester import Pytester +import pytest def test_simple_unittest(pytester: Pytester) -> None: @@ -191,26 +192,35 @@ def test_check(self): def test_teardown_issue1649(pytester: Pytester) -> None: """ Are TestCase objects cleaned up? Often unittest TestCase objects set - attributes that are large and expensive during setUp. + attributes that are large and expensive during test run or setUp. The TestCase will not be cleaned up if the test fails, because it would then exist in the stackframe. + + Regression test for #1649 (see also #12367). """ - testpath = pytester.makepyfile( + pytester.makepyfile( """ import unittest - class TestCaseObjectsShouldBeCleanedUp(unittest.TestCase): - def setUp(self): - self.an_expensive_object = 1 - def test_demo(self): - pass + import gc - """ + class TestCaseObjectsShouldBeCleanedUp(unittest.TestCase): + def test_expensive(self): + self.an_expensive_obj = object() + + def test_is_it_still_alive(self): + gc.collect() + for obj in gc.get_objects(): + if type(obj).__name__ == "TestCaseObjectsShouldBeCleanedUp": + assert not hasattr(obj, "an_expensive_obj") + break + else: + assert False, "Could not find TestCaseObjectsShouldBeCleanedUp instance" + """ ) - pytester.inline_run("-s", testpath) - gc.collect() - for obj in gc.get_objects(): - assert type(obj).__name__ != "TestCaseObjectsShouldBeCleanedUp" + + result = pytester.runpytest() + assert result.ret == ExitCode.OK def test_unittest_skip_issue148(pytester: Pytester) -> None: @@ -294,7 +304,7 @@ def test_func2(self): @classmethod def tearDownClass(cls): cls.x -= 1 - def test_teareddown(): + def test_torn_down(): assert MyTestCase.x == 0 """ ) @@ -341,7 +351,7 @@ def test_func2(self): assert self.x == 1 def teardown_class(cls): cls.x -= 1 - def test_teareddown(): + def test_torn_down(): assert MyTestCase.x == 0 """ ) @@ -352,22 +362,21 @@ def test_teareddown(): @pytest.mark.parametrize("type", ["Error", "Failure"]) def test_testcase_adderrorandfailure_defers(pytester: Pytester, type: str) -> None: pytester.makepyfile( - """ + f""" from unittest import TestCase import pytest class MyTestCase(TestCase): def run(self, result): excinfo = pytest.raises(ZeroDivisionError, lambda: 0/0) try: - result.add%s(self, excinfo._excinfo) + result.add{type}(self, excinfo._excinfo) except KeyboardInterrupt: raise except: - pytest.fail("add%s should not raise") + pytest.fail("add{type} should not raise") def test_hello(self): pass """ - % (type, type) ) result = pytester.runpytest() result.stdout.no_fnmatch_line("*should not raise*") @@ -376,7 +385,7 @@ def test_hello(self): @pytest.mark.parametrize("type", ["Error", "Failure"]) def test_testcase_custom_exception_info(pytester: Pytester, type: str) -> None: pytester.makepyfile( - """ + f""" from typing import Generic, TypeVar from unittest import TestCase import pytest, _pytest._code @@ -399,14 +408,13 @@ def from_exc_info(cls, *args, **kwargs): mp.setattr(_pytest._code, 'ExceptionInfo', FakeExceptionInfo) try: excinfo = excinfo._excinfo - result.add%(type)s(self, excinfo) + result.add{type}(self, excinfo) finally: mp.undo() def test_hello(self): pass """ - % locals() ) result = pytester.runpytest() result.stdout.fnmatch_lines( @@ -833,7 +841,7 @@ def test_passing_test_is_fail(self): @pytest.mark.parametrize("stmt", ["return", "yield"]) def test_unittest_setup_interaction(pytester: Pytester, stmt: str) -> None: pytester.makepyfile( - """ + f""" import unittest import pytest class MyTestCase(unittest.TestCase): @@ -855,9 +863,7 @@ def test_method2(self): def test_classattr(self): assert self.__class__.hello == "world" - """.format( - stmt=stmt - ) + """ ) result = pytester.runpytest() result.stdout.fnmatch_lines(["*3 passed*"]) @@ -880,7 +886,7 @@ def test_method1(self): def tearDownClass(cls): cls.x = 1 - def test_not_teareddown(): + def test_not_torn_down(): assert TestFoo.x == 0 """ @@ -952,7 +958,7 @@ def test_issue333_result_clearing(pytester: Pytester) -> None: pytester.makeconftest( """ import pytest - @pytest.hookimpl(hookwrapper=True) + @pytest.hookimpl(wrapper=True) def pytest_runtest_call(item): yield assert 0 @@ -1062,7 +1068,7 @@ def pytest_collection_modifyitems(items): ) pytester.makepyfile( - """ + f""" import pytest import {module} @@ -1081,9 +1087,7 @@ def test_two(self): assert self.fixture2 - """.format( - module=module, base=base - ) + """ ) result = pytester.runpytest("-s") @@ -1211,7 +1215,7 @@ def test_pdb_teardown_called(pytester: Pytester, monkeypatch: MonkeyPatch) -> No We delay the normal tearDown() calls when --pdb is given, so this ensures we are calling tearDown() eventually to avoid memory leaks when using --pdb. """ - teardowns: List[str] = [] + teardowns: list[str] = [] monkeypatch.setattr( pytest, "test_pdb_teardown_called_teardowns", teardowns, raising=False ) @@ -1248,11 +1252,11 @@ def test_pdb_teardown_skipped_for_functions( With --pdb, setUp and tearDown should not be called for tests skipped via a decorator (#7215). """ - tracked: List[str] = [] + tracked: list[str] = [] monkeypatch.setattr(pytest, "track_pdb_teardown_skipped", tracked, raising=False) pytester.makepyfile( - """ + f""" import unittest import pytest @@ -1268,9 +1272,7 @@ def tearDown(self): def test_1(self): pass - """.format( - mark=mark - ) + """ ) result = pytester.runpytest_inprocess("--pdb") result.stdout.fnmatch_lines("* 1 skipped in *") @@ -1285,11 +1287,11 @@ def test_pdb_teardown_skipped_for_classes( With --pdb, setUp and tearDown should not be called for tests skipped via a decorator on the class (#10060). """ - tracked: List[str] = [] + tracked: list[str] = [] monkeypatch.setattr(pytest, "track_pdb_teardown_skipped", tracked, raising=False) pytester.makepyfile( - """ + f""" import unittest import pytest @@ -1305,9 +1307,7 @@ def tearDown(self): def test_1(self): pass - """.format( - mark=mark - ) + """ ) result = pytester.runpytest_inprocess("--pdb") result.stdout.fnmatch_lines("* 1 skipped in *") @@ -1354,9 +1354,6 @@ def test_plain_unittest_does_not_support_async(pytester: Pytester) -> None: result.stdout.fnmatch_lines(expected_lines) -@pytest.mark.skipif( - sys.version_info < (3, 8), reason="Feature introduced in Python 3.8" -) def test_do_class_cleanups_on_success(pytester: Pytester) -> None: testpath = pytester.makepyfile( """ @@ -1382,9 +1379,6 @@ def test_cleanup_called_exactly_once(): assert passed == 3 -@pytest.mark.skipif( - sys.version_info < (3, 8), reason="Feature introduced in Python 3.8" -) def test_do_class_cleanups_on_setupclass_failure(pytester: Pytester) -> None: testpath = pytester.makepyfile( """ @@ -1409,9 +1403,6 @@ def test_cleanup_called_exactly_once(): assert passed == 1 -@pytest.mark.skipif( - sys.version_info < (3, 8), reason="Feature introduced in Python 3.8" -) def test_do_class_cleanups_on_teardownclass_failure(pytester: Pytester) -> None: testpath = pytester.makepyfile( """ @@ -1514,6 +1505,95 @@ def test_cleanup_called_the_right_number_of_times(): assert passed == 1 +class TestClassCleanupErrors: + """ + Make sure to show exceptions raised during class cleanup function (those registered + via addClassCleanup()). + + See #11728. + """ + + def test_class_cleanups_failure_in_setup(self, pytester: Pytester) -> None: + testpath = pytester.makepyfile( + """ + import unittest + class MyTestCase(unittest.TestCase): + @classmethod + def setUpClass(cls): + def cleanup(n): + raise Exception(f"fail {n}") + cls.addClassCleanup(cleanup, 2) + cls.addClassCleanup(cleanup, 1) + raise Exception("fail 0") + def test(self): + pass + """ + ) + result = pytester.runpytest("-s", testpath) + result.assert_outcomes(passed=0, errors=1) + result.stdout.fnmatch_lines( + [ + "*Unittest class cleanup errors *2 sub-exceptions*", + "*Exception: fail 1", + "*Exception: fail 2", + ] + ) + result.stdout.fnmatch_lines( + [ + "* ERROR at setup of MyTestCase.test *", + "E * Exception: fail 0", + ] + ) + + def test_class_cleanups_failure_in_teardown(self, pytester: Pytester) -> None: + testpath = pytester.makepyfile( + """ + import unittest + class MyTestCase(unittest.TestCase): + @classmethod + def setUpClass(cls): + def cleanup(n): + raise Exception(f"fail {n}") + cls.addClassCleanup(cleanup, 2) + cls.addClassCleanup(cleanup, 1) + def test(self): + pass + """ + ) + result = pytester.runpytest("-s", testpath) + result.assert_outcomes(passed=1, errors=1) + result.stdout.fnmatch_lines( + [ + "*Unittest class cleanup errors *2 sub-exceptions*", + "*Exception: fail 1", + "*Exception: fail 2", + ] + ) + + def test_class_cleanup_1_failure_in_teardown(self, pytester: Pytester) -> None: + testpath = pytester.makepyfile( + """ + import unittest + class MyTestCase(unittest.TestCase): + @classmethod + def setUpClass(cls): + def cleanup(n): + raise Exception(f"fail {n}") + cls.addClassCleanup(cleanup, 1) + def test(self): + pass + """ + ) + result = pytester.runpytest("-s", testpath) + result.assert_outcomes(passed=1, errors=1) + result.stdout.fnmatch_lines( + [ + "*ERROR at teardown of MyTestCase.test*", + "*Exception: fail 1", + ] + ) + + def test_traceback_pruning(pytester: Pytester) -> None: """Regression test for #9610 - doesn't crash during traceback pruning.""" pytester.makepyfile( @@ -1565,3 +1645,31 @@ def test_it2(self): pass assert skipped == 1 assert failed == 0 assert reprec.ret == ExitCode.NO_TESTS_COLLECTED + + +def test_abstract_testcase_is_not_collected(pytester: Pytester) -> None: + """Regression test for #12275.""" + pytester.makepyfile( + """ + import abc + import unittest + + class TestBase(unittest.TestCase, abc.ABC): + @abc.abstractmethod + def abstract1(self): pass + + @abc.abstractmethod + def abstract2(self): pass + + def test_it(self): pass + + class TestPartial(TestBase): + def abstract1(self): pass + + class TestConcrete(TestPartial): + def abstract2(self): pass + """ + ) + result = pytester.runpytest() + assert result.ret == ExitCode.OK + result.assert_outcomes(passed=1) diff --git a/testing/test_unraisableexception.py b/testing/test_unraisableexception.py index f625833dcea..a6a4d6f35e8 100644 --- a/testing/test_unraisableexception.py +++ b/testing/test_unraisableexception.py @@ -1,13 +1,35 @@ +from __future__ import annotations + +import gc import sys +from unittest import mock -import pytest from _pytest.pytester import Pytester +import pytest -if sys.version_info < (3, 8): - pytest.skip("unraisableexception plugin needs Python>=3.8", allow_module_level=True) +PYPY = hasattr(sys, "pypy_version_info") +UNRAISABLE_LINE = ( + ( + " * PytestUnraisableExceptionWarning: Exception ignored while calling " + "deallocator : None" + ) + if sys.version_info >= (3, 14) + else " * PytestUnraisableExceptionWarning: Exception ignored in: " +) +TRACEMALLOC_LINES = ( + () + if sys.version_info >= (3, 14) + else ( + " Enable tracemalloc to get traceback where the object was allocated.", + " See https* for more info.", + ) +) + + +@pytest.mark.skipif(PYPY, reason="garbage-collection differences make this flaky") @pytest.mark.filterwarnings("default::pytest.PytestUnraisableExceptionWarning") def test_unraisable(pytester: Pytester) -> None: pytester.makepyfile( @@ -25,21 +47,23 @@ def test_2(): pass ) result = pytester.runpytest() assert result.ret == 0 - assert result.parseoutcomes() == {"passed": 2, "warnings": 1} + result.assert_outcomes(passed=2, warnings=1) result.stdout.fnmatch_lines( [ "*= warnings summary =*", "test_it.py::test_it", - " * PytestUnraisableExceptionWarning: Exception ignored in: ", + UNRAISABLE_LINE, " ", " Traceback (most recent call last):", " ValueError: del is broken", " ", + *TRACEMALLOC_LINES, " warnings.warn(pytest.PytestUnraisableExceptionWarning(msg))", ] ) +@pytest.mark.skipif(PYPY, reason="garbage-collection differences make this flaky") @pytest.mark.filterwarnings("default::pytest.PytestUnraisableExceptionWarning") def test_unraisable_in_setup(pytester: Pytester) -> None: pytester.makepyfile( @@ -61,21 +85,23 @@ def test_2(): pass ) result = pytester.runpytest() assert result.ret == 0 - assert result.parseoutcomes() == {"passed": 2, "warnings": 1} + result.assert_outcomes(passed=2, warnings=1) result.stdout.fnmatch_lines( [ "*= warnings summary =*", "test_it.py::test_it", - " * PytestUnraisableExceptionWarning: Exception ignored in: ", + UNRAISABLE_LINE, " ", " Traceback (most recent call last):", " ValueError: del is broken", " ", + *TRACEMALLOC_LINES, " warnings.warn(pytest.PytestUnraisableExceptionWarning(msg))", ] ) +@pytest.mark.skipif(PYPY, reason="garbage-collection differences make this flaky") @pytest.mark.filterwarnings("default::pytest.PytestUnraisableExceptionWarning") def test_unraisable_in_teardown(pytester: Pytester) -> None: pytester.makepyfile( @@ -98,16 +124,17 @@ def test_2(): pass ) result = pytester.runpytest() assert result.ret == 0 - assert result.parseoutcomes() == {"passed": 2, "warnings": 1} + result.assert_outcomes(passed=2, warnings=1) result.stdout.fnmatch_lines( [ "*= warnings summary =*", "test_it.py::test_it", - " * PytestUnraisableExceptionWarning: Exception ignored in: ", + UNRAISABLE_LINE, " ", " Traceback (most recent call last):", " ValueError: del is broken", " ", + *TRACEMALLOC_LINES, " warnings.warn(pytest.PytestUnraisableExceptionWarning(msg))", ] ) @@ -116,7 +143,7 @@ def test_2(): pass @pytest.mark.filterwarnings("error::pytest.PytestUnraisableExceptionWarning") def test_unraisable_warning_error(pytester: Pytester) -> None: pytester.makepyfile( - test_it=""" + test_it=f""" class BrokenDel: def __del__(self) -> None: raise ValueError("del is broken") @@ -124,10 +151,245 @@ def __del__(self) -> None: def test_it() -> None: obj = BrokenDel() del obj + {"import gc; gc.collect()" * PYPY} + + def test_2(): pass + """ + ) + result = pytester.runpytest() + assert result.ret == pytest.ExitCode.TESTS_FAILED + result.assert_outcomes(passed=1, failed=1) + + +@pytest.mark.filterwarnings("error::pytest.PytestUnraisableExceptionWarning") +def test_unraisable_warning_multiple_errors(pytester: Pytester) -> None: + pytester.makepyfile( + test_it=f""" + class BrokenDel: + def __init__(self, msg: str): + self.msg = msg + + def __del__(self) -> None: + raise ValueError(self.msg) + + def test_it() -> None: + BrokenDel("del is broken 1") + BrokenDel("del is broken 2") + {"import gc; gc.collect()" * PYPY} def test_2(): pass """ ) result = pytester.runpytest() assert result.ret == pytest.ExitCode.TESTS_FAILED - assert result.parseoutcomes() == {"passed": 1, "failed": 1} + result.assert_outcomes(passed=1, failed=1) + result.stdout.fnmatch_lines( + [ + " | *ExceptionGroup: multiple unraisable exception warnings (2 sub-exceptions)" + ] + ) + + +def test_unraisable_collection_failure(pytester: Pytester) -> None: + pytester.makepyfile( + test_it=f""" + class BrokenDel: + def __del__(self): + raise ValueError("del is broken") + + def test_it(): + obj = BrokenDel() + del obj + {"import gc; gc.collect()" * PYPY} + + def test_2(): pass + """ + ) + + class MyError(BaseException): + pass + + with mock.patch("traceback.format_exception", side_effect=MyError): + result = pytester.runpytest() + assert result.ret == 1 + result.assert_outcomes(passed=1, failed=1) + result.stdout.fnmatch_lines( + ["E RuntimeError: Failed to process unraisable exception"] + ) + + +def _set_gc_state(enabled: bool) -> bool: + was_enabled = gc.isenabled() + if enabled: + gc.enable() + else: + gc.disable() + return was_enabled + + +def test_refcycle_unraisable(pytester: Pytester) -> None: + # see: https://github.com/pytest-dev/pytest/issues/10404 + pytester.makepyfile( + test_it=""" + # Should catch the unraisable exception even if gc is disabled. + import gc; gc.disable() + + import pytest + + class BrokenDel: + def __init__(self): + self.self = self # make a reference cycle + + def __del__(self): + raise ValueError("del is broken") + + def test_it(): + BrokenDel() + """ + ) + + result = pytester.runpytest_subprocess( + "-Wdefault::pytest.PytestUnraisableExceptionWarning" + ) + + assert result.ret == 0 + + result.assert_outcomes(passed=1) + result.stderr.fnmatch_lines("ValueError: del is broken") + + +def test_refcycle_unraisable_warning_filter(pytester: Pytester) -> None: + pytester.makepyfile( + test_it=""" + # Should catch the unraisable exception even if gc is disabled. + import gc; gc.disable() + + import pytest + + class BrokenDel: + def __init__(self): + self.self = self # make a reference cycle + + def __del__(self): + raise ValueError("del is broken") + + def test_it(): + BrokenDel() + """ + ) + + result = pytester.runpytest_subprocess( + "-Werror::pytest.PytestUnraisableExceptionWarning" + ) + + # TODO: Should be a test failure or error. Currently the exception + # propagates all the way to the top resulting in exit code 1. + assert result.ret == 1 + + result.assert_outcomes(passed=1) + result.stderr.fnmatch_lines("ValueError: del is broken") + + +def test_create_task_raises_unraisable_warning_filter(pytester: Pytester) -> None: + # note that the host pytest warning filter is disabled and the pytester + # warning filter applies during config teardown of unraisablehook. + # see: https://github.com/pytest-dev/pytest/issues/10404 + # This is a dupe of the above test, but using the exact reproducer from + # the issue + pytester.makepyfile( + test_it=""" + # Should catch the unraisable exception even if gc is disabled. + import gc; gc.disable() + + import asyncio + import pytest + + async def my_task(): + pass + + def test_scheduler_must_be_created_within_running_loop() -> None: + with pytest.raises(RuntimeError) as _: + asyncio.create_task(my_task()) + """ + ) + + result = pytester.runpytest_subprocess("-Werror") + + # TODO: Should be a test failure or error. Currently the exception + # propagates all the way to the top resulting in exit code 1. + assert result.ret == 1 + + result.assert_outcomes(passed=1) + result.stderr.fnmatch_lines("RuntimeWarning: coroutine 'my_task' was never awaited") + + +def test_refcycle_unraisable_warning_filter_default(pytester: Pytester) -> None: + # note this time we use a default warning filter for pytester + # and run it in a subprocess, because the warning can only go to the + # sys.stdout rather than the terminal reporter, which has already + # finished. + # see: https://github.com/pytest-dev/pytest/pull/13057#discussion_r1888396126 + pytester.makepyfile( + test_it=""" + import gc + gc.disable() + + import pytest + + class BrokenDel: + def __init__(self): + self.self = self # make a reference cycle + + def __del__(self): + raise ValueError("del is broken") + + def test_it(): + BrokenDel() + """ + ) + + # since we use subprocess we need to disable gc inside test_it + result = pytester.runpytest_subprocess("-Wdefault") + + assert result.ret == pytest.ExitCode.OK + + # TODO: should be warnings=1, but the outcome has already come out + # by the time the warning triggers + result.assert_outcomes(passed=1) + result.stderr.fnmatch_lines("ValueError: del is broken") + + +@pytest.mark.filterwarnings("error::pytest.PytestUnraisableExceptionWarning") +def test_possibly_none_excinfo(pytester: Pytester) -> None: + pytester.makepyfile( + test_it=""" + import sys + import types + + def test_it(): + sys.unraisablehook( + types.SimpleNamespace( + exc_type=RuntimeError, + exc_value=None, + exc_traceback=None, + err_msg=None, + object=None, + ) + ) + """ + ) + + result = pytester.runpytest() + + # TODO: should be a test failure or error + assert result.ret == pytest.ExitCode.TESTS_FAILED + + result.assert_outcomes(failed=1) + result.stdout.fnmatch_lines( + [ + "E pytest.PytestUnraisableExceptionWarning:" + " Exception ignored in: None", + "E ", + "E NoneType: None", + ] + ) diff --git a/testing/test_warning_types.py b/testing/test_warning_types.py index 5f69439ef3e..7cbc4703c26 100644 --- a/testing/test_warning_types.py +++ b/testing/test_warning_types.py @@ -1,8 +1,11 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + import inspect -import pytest from _pytest import warning_types from _pytest.pytester import Pytester +import pytest @pytest.mark.parametrize( @@ -40,7 +43,8 @@ def test(): @pytest.mark.filterwarnings("error") def test_warn_explicit_for_annotates_errors_with_location(): - with pytest.raises(Warning, match="(?m)test\n at .*python_api.py:\\d+"): + with pytest.raises(Warning, match="(?m)test\n at .*raises.py:\\d+"): warning_types.warn_explicit_for( - pytest.raises, warning_types.PytestWarning("test") # type: ignore + pytest.raises, # type: ignore[arg-type] + warning_types.PytestWarning("test"), ) diff --git a/testing/test_warnings.py b/testing/test_warnings.py index 03846cb30c3..b1c64dc9332 100644 --- a/testing/test_warnings.py +++ b/testing/test_warnings.py @@ -1,13 +1,14 @@ +# mypy: allow-untyped-defs +from __future__ import annotations + import os import sys import warnings -from typing import List -from typing import Optional -from typing import Tuple -import pytest from _pytest.fixtures import FixtureRequest from _pytest.pytester import Pytester +import pytest + WARNINGS_SUMMARY_HEADER = "warnings summary" @@ -16,16 +17,13 @@ def pyfile_with_warnings(pytester: Pytester, request: FixtureRequest) -> str: """Create a test file which calls a function in a module which generates warnings.""" pytester.syspathinsert() - test_name = request.function.__name__ - module_name = test_name.lstrip("test_") + "_module" + module_name = request.function.__name__[len("test_") :] + "_module" test_file = pytester.makepyfile( - """ + f""" import {module_name} def test_func(): assert {module_name}.foo() == 1 - """.format( - module_name=module_name - ), + """, **{ module_name: """ import warnings @@ -45,7 +43,7 @@ def test_normal_flow(pytester: Pytester, pyfile_with_warnings) -> None: result = pytester.runpytest(pyfile_with_warnings) result.stdout.fnmatch_lines( [ - "*== %s ==*" % WARNINGS_SUMMARY_HEADER, + f"*== {WARNINGS_SUMMARY_HEADER} ==*", "test_normal_flow.py::test_func", "*normal_flow_module.py:3: UserWarning: user warning", '* warnings.warn(UserWarning("user warning"))', @@ -76,7 +74,7 @@ def test_func(fix): result = pytester.runpytest() result.stdout.fnmatch_lines( [ - "*== %s ==*" % WARNINGS_SUMMARY_HEADER, + f"*== {WARNINGS_SUMMARY_HEADER} ==*", "*test_setup_teardown_warnings.py:6: UserWarning: warning during setup", '*warnings.warn(UserWarning("warning during setup"))', "*test_setup_teardown_warnings.py:8: UserWarning: warning during teardown", @@ -144,13 +142,14 @@ def test_func(fix): result = pytester.runpytest() result.stdout.fnmatch_lines( [ - "*== %s ==*" % WARNINGS_SUMMARY_HEADER, + f"*== {WARNINGS_SUMMARY_HEADER} ==*", "*test_unicode.py:7: UserWarning: \u6d4b\u8bd5*", "* 1 passed, 1 warning*", ] ) +@pytest.mark.skip("issue #13485") def test_works_with_filterwarnings(pytester: Pytester) -> None: """Ensure our warnings capture does not mess with pre-installed filters (#2430).""" pytester.makepyfile( @@ -281,10 +280,8 @@ def pytest_warning_recorded(self, warning_message, when, nodeid, location): ("call warning", "runtest", "test_warning_recorded_hook.py::test_func"), ("teardown warning", "runtest", "test_warning_recorded_hook.py::test_func"), ] - for index in range(len(expected)): - collected_result = collected[index] - expected_result = expected[index] - + assert len(collected) == len(expected) # python < 3.10 zip(strict=True) + for collected_result, expected_result in zip(collected, expected): assert collected_result[0] == expected_result[0], str(collected) assert collected_result[1] == expected_result[1], str(collected) assert collected_result[2] == expected_result[2], str(collected) @@ -316,7 +313,7 @@ def test_foo(): result = pytester.runpytest() result.stdout.fnmatch_lines( [ - "*== %s ==*" % WARNINGS_SUMMARY_HEADER, + f"*== {WARNINGS_SUMMARY_HEADER} ==*", " *collection_warnings.py:3: UserWarning: collection warning", ' warnings.warn(UserWarning("collection warning"))', "* 1 passed, 1 warning*", @@ -375,7 +372,7 @@ def test_bar(): else: result.stdout.fnmatch_lines( [ - "*== %s ==*" % WARNINGS_SUMMARY_HEADER, + f"*== {WARNINGS_SUMMARY_HEADER} ==*", "*test_hide_pytest_internal_warnings.py:4: PytestWarning: some internal warning", "* 1 passed, 1 warning *", ] @@ -436,7 +433,7 @@ class TestDeprecationWarningsByDefault: def create_file(self, pytester: Pytester, mark="") -> None: pytester.makepyfile( - """ + f""" import pytest, warnings warnings.warn(DeprecationWarning("collection")) @@ -444,9 +441,7 @@ def create_file(self, pytester: Pytester, mark="") -> None: {mark} def test_foo(): warnings.warn(PendingDeprecationWarning("test run")) - """.format( - mark=mark - ) + """ ) @pytest.mark.parametrize("customize_filters", [True, False]) @@ -464,7 +459,7 @@ def test_shown_by_default(self, pytester: Pytester, customize_filters) -> None: result = pytester.runpytest_subprocess() result.stdout.fnmatch_lines( [ - "*== %s ==*" % WARNINGS_SUMMARY_HEADER, + f"*== {WARNINGS_SUMMARY_HEADER} ==*", "*test_shown_by_default.py:3: DeprecationWarning: collection", "*test_shown_by_default.py:7: PendingDeprecationWarning: test run", "* 1 passed, 2 warnings*", @@ -495,7 +490,7 @@ def test_hidden_by_mark(self, pytester: Pytester) -> None: result = pytester.runpytest_subprocess() result.stdout.fnmatch_lines( [ - "*== %s ==*" % WARNINGS_SUMMARY_HEADER, + f"*== {WARNINGS_SUMMARY_HEADER} ==*", "*test_hidden_by_mark.py:3: DeprecationWarning: collection", "* 1 passed, 1 warning*", ] @@ -517,8 +512,31 @@ def test_hidden_by_system(self, pytester: Pytester, monkeypatch) -> None: result = pytester.runpytest_subprocess() assert WARNINGS_SUMMARY_HEADER not in result.stdout.str() + def test_invalid_regex_in_filterwarning(self, pytester: Pytester) -> None: + self.create_file(pytester) + pytester.makeini( + """ + [pytest] + filterwarnings = + ignore::DeprecationWarning:* + """ + ) + result = pytester.runpytest_subprocess() + assert result.ret == pytest.ExitCode.USAGE_ERROR + result.stderr.fnmatch_lines( + [ + "ERROR: while parsing the following warning configuration:", + "", + " ignore::DeprecationWarning:[*]", + "", + "This error occurred:", + "", + "Invalid regex '[*]': nothing to repeat at position 0", + ] + ) + -@pytest.mark.skip("not relevant until pytest 8.0") +@pytest.mark.skip("not relevant until pytest 9.0") @pytest.mark.parametrize("change_default", [None, "ini", "cmdline"]) def test_removed_in_x_warning_as_error(pytester: Pytester, change_default) -> None: """This ensures that PytestRemovedInXWarnings raised by pytest are turned into errors. @@ -530,7 +548,7 @@ def test_removed_in_x_warning_as_error(pytester: Pytester, change_default) -> No """ import warnings, pytest def test(): - warnings.warn(pytest.PytestRemovedIn8Warning("some warning")) + warnings.warn(pytest.PytestRemovedIn9Warning("some warning")) """ ) if change_default == "ini": @@ -538,12 +556,12 @@ def test(): """ [pytest] filterwarnings = - ignore::pytest.PytestRemovedIn8Warning + ignore::pytest.PytestRemovedIn9Warning """ ) args = ( - ("-Wignore::pytest.PytestRemovedIn8Warning",) + ("-Wignore::pytest.PytestRemovedIn9Warning",) if change_default == "cmdline" else () ) @@ -558,7 +576,7 @@ def test(): class TestAssertionWarnings: @staticmethod def assert_result_warns(result, msg) -> None: - result.stdout.fnmatch_lines(["*PytestAssertRewriteWarning: %s*" % msg]) + result.stdout.fnmatch_lines([f"*PytestAssertRewriteWarning: {msg}*"]) def test_tuple_warning(self, pytester: Pytester) -> None: pytester.makepyfile( @@ -588,7 +606,7 @@ def test_group_warnings_by_message(pytester: Pytester) -> None: result = pytester.runpytest() result.stdout.fnmatch_lines( [ - "*== %s ==*" % WARNINGS_SUMMARY_HEADER, + f"*== {WARNINGS_SUMMARY_HEADER} ==*", "test_group_warnings_by_message.py::test_foo[[]0[]]", "test_group_warnings_by_message.py::test_foo[[]1[]]", "test_group_warnings_by_message.py::test_foo[[]2[]]", @@ -620,14 +638,14 @@ def test_group_warnings_by_message_summary(pytester: Pytester) -> None: result = pytester.runpytest() result.stdout.fnmatch_lines( [ - "*== %s ==*" % WARNINGS_SUMMARY_HEADER, + f"*== {WARNINGS_SUMMARY_HEADER} ==*", "test_1.py: 21 warnings", "test_2.py: 1 warning", - " */test_1.py:7: UserWarning: foo", + " */test_1.py:10: UserWarning: foo", " warnings.warn(UserWarning(msg))", "", "test_1.py: 20 warnings", - " */test_1.py:7: UserWarning: bar", + " */test_1.py:10: UserWarning: bar", " warnings.warn(UserWarning(msg))", "", "-- Docs: *", @@ -659,8 +677,8 @@ class TestStackLevel: @pytest.fixture def capwarn(self, pytester: Pytester): class CapturedWarnings: - captured: List[ - Tuple[warnings.WarningMessage, Optional[Tuple[str, int, str]]] + captured: list[ + tuple[warnings.WarningMessage, tuple[str, int, str] | None] ] = [] @classmethod @@ -797,7 +815,7 @@ def test_resource_warning(pytester: Pytester, monkeypatch: pytest.MonkeyPatch) - # available, using `importorskip("tracemalloc")` for example, # because we want to ensure the same code path does not break in those platforms. try: - import tracemalloc # noqa + import tracemalloc # noqa: F401 has_tracemalloc = True except ImportError: diff --git a/testing/typing_checks.py b/testing/typing_checks.py index 57f2bae475f..8a316580a25 100644 --- a/testing/typing_checks.py +++ b/testing/typing_checks.py @@ -1,15 +1,21 @@ +# mypy: allow-untyped-defs """File for checking typing issues. This file is not executed, it is only checked by mypy to ensure that none of the code triggers any mypy errors. """ + +from __future__ import annotations + import contextlib +from typing import Literal from typing import Optional from typing_extensions import assert_type import pytest from pytest import MonkeyPatch +from pytest import TestReport # Issue #7488. @@ -47,3 +53,9 @@ def check_raises_is_a_context_manager(val: bool) -> None: with pytest.raises(RuntimeError) if val else contextlib.nullcontext() as excinfo: pass assert_type(excinfo, Optional[pytest.ExceptionInfo[RuntimeError]]) + + +# Issue #12941. +def check_testreport_attributes(report: TestReport) -> None: + assert_type(report.when, Literal["setup", "call", "teardown"]) + assert_type(report.location, tuple[str, Optional[int], str]) diff --git a/testing/typing_raises_group.py b/testing/typing_raises_group.py new file mode 100644 index 00000000000..c7dd16991ac --- /dev/null +++ b/testing/typing_raises_group.py @@ -0,0 +1,247 @@ +from __future__ import annotations + +import sys +from typing import Callable +from typing import Union + +from typing_extensions import assert_type + +from _pytest.main import Failed as main_Failed +from _pytest.outcomes import Failed +from pytest import raises +from pytest import RaisesExc +from pytest import RaisesGroup + + +# does not work +assert_type(raises.Exception, Failed) # type: ignore[assert-type, attr-defined] + +# FIXME: these are different for some reason(?) +assert Failed is not main_Failed # type: ignore[comparison-overlap] + +if sys.version_info < (3, 11): + from exceptiongroup import BaseExceptionGroup + from exceptiongroup import ExceptionGroup + +# split into functions to isolate the different scopes + + +def check_raisesexc_typevar_default(e: RaisesExc) -> None: + assert e.expected_exceptions is not None + _exc: type[BaseException] | tuple[type[BaseException], ...] = e.expected_exceptions + # this would previously pass, as the type would be `Any` + e.exception_type().blah() # type: ignore + + +def check_basic_contextmanager() -> None: + with RaisesGroup(ValueError) as e: + raise ExceptionGroup("foo", (ValueError(),)) + assert_type(e.value, ExceptionGroup[ValueError]) + + +def check_basic_matches() -> None: + # check that matches gets rid of the naked ValueError in the union + exc: ExceptionGroup[ValueError] | ValueError = ExceptionGroup("", (ValueError(),)) + if RaisesGroup(ValueError).matches(exc): + assert_type(exc, ExceptionGroup[ValueError]) + + # also check that BaseExceptionGroup shows up for BaseExceptions + if RaisesGroup(KeyboardInterrupt).matches(exc): + assert_type(exc, BaseExceptionGroup[KeyboardInterrupt]) + + +def check_matches_with_different_exception_type() -> None: + e: BaseExceptionGroup[KeyboardInterrupt] = BaseExceptionGroup( + "", + (KeyboardInterrupt(),), + ) + + # note: it might be tempting to have this warn. + # however, that isn't possible with current typing + if RaisesGroup(ValueError).matches(e): + assert_type(e, ExceptionGroup[ValueError]) + + +def check_raisesexc_init() -> None: + def check_exc(exc: BaseException) -> bool: + return isinstance(exc, ValueError) + + # Check various combinations of constructor signatures. + # At least 1 arg must be provided. + RaisesExc() # type: ignore + RaisesExc(ValueError) + RaisesExc(ValueError, match="regex") + RaisesExc(ValueError, match="regex", check=check_exc) + RaisesExc(match="regex") + RaisesExc(check=check_exc) + RaisesExc(ValueError, match="regex") + RaisesExc(match="regex", check=check_exc) + + def check_filenotfound(exc: FileNotFoundError) -> bool: + return not exc.filename.endswith(".tmp") + + # If exception_type is provided, that narrows the `check` method's argument. + RaisesExc(FileNotFoundError, check=check_filenotfound) + RaisesExc(ValueError, check=check_filenotfound) # type: ignore + RaisesExc(check=check_filenotfound) # type: ignore + RaisesExc(FileNotFoundError, match="regex", check=check_filenotfound) + + # exceptions are pos-only + RaisesExc(expected_exception=ValueError) # type: ignore + # match and check are kw-only + RaisesExc(ValueError, "regex") # type: ignore + + +def raisesgroup_check_type_narrowing() -> None: + """Check type narrowing on the `check` argument to `RaisesGroup`. + All `type: ignore`s are correctly pointing out type errors. + """ + + def handle_exc(e: BaseExceptionGroup[BaseException]) -> bool: + return True + + def handle_kbi(e: BaseExceptionGroup[KeyboardInterrupt]) -> bool: + return True + + def handle_value(e: BaseExceptionGroup[ValueError]) -> bool: + return True + + RaisesGroup(BaseException, check=handle_exc) + RaisesGroup(BaseException, check=handle_kbi) # type: ignore + + RaisesGroup(Exception, check=handle_exc) + RaisesGroup(Exception, check=handle_value) # type: ignore + + RaisesGroup(KeyboardInterrupt, check=handle_exc) + RaisesGroup(KeyboardInterrupt, check=handle_kbi) + RaisesGroup(KeyboardInterrupt, check=handle_value) # type: ignore + + RaisesGroup(ValueError, check=handle_exc) + RaisesGroup(ValueError, check=handle_kbi) # type: ignore + RaisesGroup(ValueError, check=handle_value) + + RaisesGroup(ValueError, KeyboardInterrupt, check=handle_exc) + RaisesGroup(ValueError, KeyboardInterrupt, check=handle_kbi) # type: ignore + RaisesGroup(ValueError, KeyboardInterrupt, check=handle_value) # type: ignore + + +def raisesgroup_narrow_baseexceptiongroup() -> None: + """Check type narrowing specifically for the container exceptiongroup.""" + + def handle_group(e: ExceptionGroup[Exception]) -> bool: + return True + + def handle_group_value(e: ExceptionGroup[ValueError]) -> bool: + return True + + RaisesGroup(ValueError, check=handle_group_value) + + RaisesGroup(Exception, check=handle_group) + + +def check_raisesexc_transparent() -> None: + with RaisesGroup(RaisesExc(ValueError)) as e: + ... + _: BaseExceptionGroup[ValueError] = e.value + assert_type(e.value, ExceptionGroup[ValueError]) + + +def check_nested_raisesgroups_contextmanager() -> None: + with RaisesGroup(RaisesGroup(ValueError)) as excinfo: + raise ExceptionGroup("foo", (ValueError(),)) + + _: BaseExceptionGroup[BaseExceptionGroup[ValueError]] = excinfo.value + + assert_type( + excinfo.value, + ExceptionGroup[ExceptionGroup[ValueError]], + ) + + assert_type( + excinfo.value.exceptions[0], + # this union is because of how typeshed defines .exceptions + Union[ + ExceptionGroup[ValueError], + ExceptionGroup[ExceptionGroup[ValueError]], + ], + ) + + +def check_nested_raisesgroups_matches() -> None: + """Check nested RaisesGroup with .matches""" + exc: ExceptionGroup[ExceptionGroup[ValueError]] = ExceptionGroup( + "", + (ExceptionGroup("", (ValueError(),)),), + ) + + if RaisesGroup(RaisesGroup(ValueError)).matches(exc): + assert_type(exc, ExceptionGroup[ExceptionGroup[ValueError]]) + + +def check_multiple_exceptions_1() -> None: + a = RaisesGroup(ValueError, ValueError) + b = RaisesGroup(RaisesExc(ValueError), RaisesExc(ValueError)) + c = RaisesGroup(ValueError, RaisesExc(ValueError)) + + d: RaisesGroup[ValueError] + d = a + d = b + d = c + assert d + + +def check_multiple_exceptions_2() -> None: + # This previously failed due to lack of covariance in the TypeVar + a = RaisesGroup(RaisesExc(ValueError), RaisesExc(TypeError)) + b = RaisesGroup(RaisesExc(ValueError), TypeError) + c = RaisesGroup(ValueError, TypeError) + + d: RaisesGroup[Exception] + d = a + d = b + d = c + assert d + + +def check_raisesgroup_overloads() -> None: + # allow_unwrapped=True does not allow: + # multiple exceptions + RaisesGroup(ValueError, TypeError, allow_unwrapped=True) # type: ignore + # nested RaisesGroup + RaisesGroup(RaisesGroup(ValueError), allow_unwrapped=True) # type: ignore + # specifying match + RaisesGroup(ValueError, match="foo", allow_unwrapped=True) # type: ignore + # specifying check + RaisesGroup(ValueError, check=bool, allow_unwrapped=True) # type: ignore + # allowed variants + RaisesGroup(ValueError, allow_unwrapped=True) + RaisesGroup(ValueError, allow_unwrapped=True, flatten_subgroups=True) + RaisesGroup(RaisesExc(ValueError), allow_unwrapped=True) + + # flatten_subgroups=True does not allow nested RaisesGroup + RaisesGroup(RaisesGroup(ValueError), flatten_subgroups=True) # type: ignore + # but rest is plenty fine + RaisesGroup(ValueError, TypeError, flatten_subgroups=True) + RaisesGroup(ValueError, match="foo", flatten_subgroups=True) + RaisesGroup(ValueError, check=bool, flatten_subgroups=True) + RaisesGroup(ValueError, flatten_subgroups=True) + RaisesGroup(RaisesExc(ValueError), flatten_subgroups=True) + + # if they're both false we can of course specify nested raisesgroup + RaisesGroup(RaisesGroup(ValueError)) + + +def check_triple_nested_raisesgroup() -> None: + with RaisesGroup(RaisesGroup(RaisesGroup(ValueError))) as e: + assert_type(e.value, ExceptionGroup[ExceptionGroup[ExceptionGroup[ValueError]]]) + + +def check_check_typing() -> None: + # `BaseExceptiongroup` should perhaps be `ExceptionGroup`, but close enough + assert_type( + RaisesGroup(ValueError).check, + Union[ + Callable[[BaseExceptionGroup[ValueError]], bool], + None, + ], + ) diff --git a/tox.ini b/tox.ini index 05c1842af8a..3fe7865a289 100644 --- a/tox.ini +++ b/tox.ini @@ -4,17 +4,18 @@ minversion = 3.20.0 distshare = {homedir}/.tox/distshare envlist = linting - py37 - py38 py39 py310 py311 py312 + py313 + py314 pypy3 - py37-{pexpect,xdist,unittestextras,numpy,pluggymain,pylib} + py39-{pexpect,xdist,twisted24,twisted25,asynctest,numpy,pluggymain,pylib} doctesting + doctesting-coverage plugins - py37-freeze + py39-freeze docs docs-checklinks @@ -25,6 +26,22 @@ envlist = [testenv] +description = + run the tests + coverage: collecting coverage + exceptiongroup: against `exceptiongroup` + nobyte: in no-bytecode mode + lsof: with `--lsof` pytest CLI option + numpy: against `numpy` + pexpect: against `pexpect` + pluggymain: against the bleeding edge `pluggy` from Git + pylib: against `py` lib + twisted24: against the unit test extras with twisted prior to 24.0 + twisted25: against the unit test extras with twisted 25.0 or later + asynctest: against the unit test extras with asynctest + xdist: with pytest in parallel mode + under `{basepython}` + doctesting: including doctests commands = {env:_PYTEST_TOX_COVERAGE_RUN:} pytest {posargs:{env:_PYTEST_TOX_DEFAULT_POSARGS:}} doctesting: {env:_PYTEST_TOX_COVERAGE_RUN:} pytest --doctest-modules --pyargs _pytest @@ -35,15 +52,16 @@ passenv = PYTEST_ADDOPTS TERM SETUPTOOLS_SCM_PRETEND_VERSION_FOR_PYTEST + CI setenv = - _PYTEST_TOX_DEFAULT_POSARGS={env:_PYTEST_TOX_POSARGS_DOCTESTING:} {env:_PYTEST_TOX_POSARGS_LSOF:} {env:_PYTEST_TOX_POSARGS_XDIST:} + _PYTEST_TOX_DEFAULT_POSARGS={env:_PYTEST_TOX_POSARGS_DOCTESTING:} {env:_PYTEST_TOX_POSARGS_LSOF:} {env:_PYTEST_TOX_POSARGS_XDIST:} {env:_PYTEST_FILES:} # See https://docs.python.org/3/library/io.html#io-encoding-warning # If we don't enable this, neither can any of our downstream users! PYTHONWARNDEFAULTENCODING=1 # Configuration to run with coverage similar to CI, e.g. - # "tox -e py37-coverage". + # "tox -e py39-coverage". coverage: _PYTEST_TOX_COVERAGE_RUN=coverage run -m coverage: _PYTEST_TOX_EXTRA_DEP=coverage-enable-subprocess coverage: COVERAGE_FILE={toxinidir}/.coverage @@ -51,12 +69,18 @@ setenv = doctesting: _PYTEST_TOX_POSARGS_DOCTESTING=doc/en + # The configurations below are related only to standard unittest support. + # Run only tests from test_unittest.py. + asynctest: _PYTEST_FILES=testing/test_unittest.py + twisted24: _PYTEST_FILES=testing/test_unittest.py + twisted25: _PYTEST_FILES=testing/test_unittest.py + nobyte: PYTHONDONTWRITEBYTECODE=1 lsof: _PYTEST_TOX_POSARGS_LSOF=--lsof xdist: _PYTEST_TOX_POSARGS_XDIST=-n auto -extras = testing +extras = dev deps = doctesting: PyYAML exceptiongroup: exceptiongroup>=1.0.0rc8 @@ -64,41 +88,58 @@ deps = pexpect: pexpect>=4.8.0 pluggymain: pluggy @ git+https://github.com/pytest-dev/pluggy.git pylib: py>=1.8.2 - unittestextras: twisted - unittestextras: asynctest + twisted24: twisted<25 + twisted25: twisted>=25 + asynctest: asynctest xdist: pytest-xdist>=2.1.0 xdist: -e . {env:_PYTEST_TOX_EXTRA_DEP:} [testenv:linting] +description = + run pre-commit-defined linters under `{basepython}` skip_install = True basepython = python3 deps = pre-commit>=2.9.3 commands = pre-commit run --all-files --show-diff-on-failure {posargs:} +setenv = + # pre-commit and tools it launches are not clean of this warning. + PYTHONWARNDEFAULTENCODING= [testenv:docs] -basepython = python3 +description = + build the documentation site under \ + `{toxinidir}{/}doc{/}en{/}_build{/}html` with `{basepython}` +basepython = python3.12 # sync with rtd to get errors usedevelop = True deps = -r{toxinidir}/doc/en/requirements.txt - # https://github.com/twisted/towncrier/issues/340 - towncrier<21.3.0 commands = - python scripts/towncrier-draft-to-file.py - # the '-t changelog_towncrier_draft' tags makes sphinx include the draft - # changelog in the docs; this does not happen on ReadTheDocs because it uses - # the standard sphinx command so the 'changelog_towncrier_draft' is never set there - sphinx-build -W --keep-going -b html doc/en doc/en/_build/html -t changelog_towncrier_draft {posargs:} + sphinx-build \ + -j auto \ + -W --keep-going \ + -b html doc/en doc/en/_build/html \ + {posargs:} +setenv = + # Sphinx is not clean of this warning. + PYTHONWARNDEFAULTENCODING= [testenv:docs-checklinks] +description = + check the links in the documentation with `{basepython}` basepython = python3 usedevelop = True changedir = doc/en deps = -r{toxinidir}/doc/en/requirements.txt commands = sphinx-build -W -q --keep-going -b linkcheck . _build +setenv = + # Sphinx is not clean of this warning. + PYTHONWARNDEFAULTENCODING= [testenv:regen] +description = + regenerate documentation examples under `{basepython}` changedir = doc/en basepython = python3 passenv = @@ -111,8 +152,13 @@ allowlist_externals = make commands = make regen +setenv = + # We don't want this warning to reach regen output. + PYTHONWARNDEFAULTENCODING= [testenv:plugins] +description = + run reverse dependency testing against pytest plugins under `{basepython}` # use latest versions of all plugins, including pre-releases pip_pre=true # use latest pip to get new dependency resolver (#7783) @@ -128,7 +174,7 @@ commands = pytest --cov=. simple_integration.py pytest --ds=django_settings simple_integration.py pytest --html=simple.html simple_integration.py - pytest --reruns 5 simple_integration.py + pytest --reruns 5 simple_integration.py pytest_rerunfailures_integration.py pytest pytest_anyio_integration.py pytest pytest_asyncio_integration.py pytest pytest_mock_integration.py @@ -136,7 +182,9 @@ commands = pytest pytest_twisted_integration.py pytest simple_integration.py --force-sugar --flakes -[testenv:py37-freeze] +[testenv:py39-freeze] +description = + test pytest frozen with `pyinstaller` under `{basepython}` changedir = testing/freeze deps = pyinstaller @@ -151,11 +199,8 @@ usedevelop = True passenv = * deps = colorama - github3.py pre-commit>=2.9.3 - wheel - # https://github.com/twisted/towncrier/issues/340 - towncrier<21.3.0 + towncrier commands = python scripts/release.py {posargs} [testenv:prepare-release-pr] @@ -165,37 +210,10 @@ passenv = {[testenv:release]passenv} deps = {[testenv:release]deps} commands = python scripts/prepare-release-pr.py {posargs} -[testenv:publish-gh-release-notes] -description = create GitHub release after deployment +[testenv:generate-gh-release-notes] +description = generate release notes that can be published as GitHub Release basepython = python3 usedevelop = True -passenv = - GH_RELEASE_NOTES_TOKEN - GITHUB_REF - GITHUB_REPOSITORY deps = - github3.py pypandoc -commands = python scripts/publish-gh-release-notes.py {posargs} - -[flake8] -max-line-length = 120 -extend-ignore = - ; whitespace before ':' - E203 - ; Missing Docstrings - D100,D101,D102,D103,D104,D105,D106,D107 - ; Whitespace Issues - D202,D203,D204,D205,D209,D213 - ; Quotes Issues - D302 - ; Docstring Content Issues - D400,D401,D401,D402,D405,D406,D407,D408,D409,D410,D411,D412,D413,D414,D415,D416,D417 - - -[isort] -; This config mimics what reorder-python-imports does. -force_single_line = 1 -known_localfolder = pytest,_pytest -known_third_party = test_source,test_excinfo -force_alphabetical_sort_within_sections = 1 +commands = python scripts/generate-gh-release-notes.py {posargs}