diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index f5ea4d39764..9dc69c3cca3 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -31,7 +31,7 @@ jobs: persist-credentials: false - name: Build and Check Package - uses: hynek/build-and-inspect-python-package@v2.6.0 + uses: hynek/build-and-inspect-python-package@v2.12.0 with: attest-build-provenance-github: 'true' @@ -46,6 +46,8 @@ jobs: contents: write steps: - uses: actions/checkout@v4 + with: + persist-credentials: true - name: Download Package uses: actions/download-artifact@v4 @@ -54,14 +56,18 @@ jobs: path: dist - name: Publish package to PyPI - uses: pypa/gh-action-pypi-publish@v1.9.0 + uses: pypa/gh-action-pypi-publish@v1.12.3 + 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${{ github.event.inputs.version }} ${{ github.event.inputs.version }} ${{ github.sha }} - git push origin ${{ github.event.inputs.version }} + git tag --annotate --message=v"$VERSION" "$VERSION" ${{ github.sha }} + git push origin "$VERSION" release-notes: @@ -96,9 +102,11 @@ jobs: pip install --upgrade tox - name: Generate release notes + env: + VERSION: ${{ github.event.inputs.version }} run: | sudo apt-get install pandoc - tox -e generate-gh-release-notes -- ${{ github.event.inputs.version }} scripts/latest-release-notes.md + tox -e generate-gh-release-notes -- "$VERSION" scripts/latest-release-notes.md - name: Publish GitHub Release uses: softprops/action-gh-release@v2 diff --git a/.github/workflows/prepare-release-pr.yml b/.github/workflows/prepare-release-pr.yml index 1bb23fab844..fdb770317df 100644 --- a/.github/workflows/prepare-release-pr.yml +++ b/.github/workflows/prepare-release-pr.yml @@ -30,6 +30,7 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 + persist-credentials: false - name: Set up Python uses: actions/setup-python@v5 @@ -43,10 +44,16 @@ 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 }} run: | - tox -e prepare-release-pr -- ${{ github.event.inputs.branch }} ${{ github.token }} --prerelease='${{ github.event.inputs.prerelease }}' + tox -e prepare-release-pr -- "$BRANCH" ${{ github.token }} --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 }} run: | - tox -e prepare-release-pr -- ${{ github.event.inputs.branch }} ${{ github.token }} --major --prerelease='${{ github.event.inputs.prerelease }}' + tox -e prepare-release-pr -- "$BRANCH" ${{ github.token }} --major --prerelease="$PRERELEASE" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9158d6bcc72..33dede68906 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -40,7 +40,7 @@ jobs: fetch-depth: 0 persist-credentials: false - name: Build and Check Package - uses: hynek/build-and-inspect-python-package@v2.6.0 + uses: hynek/build-and-inspect-python-package@v2.12.0 build: needs: [package] @@ -55,7 +55,7 @@ jobs: matrix: name: [ "windows-py38", - "windows-py38-pluggy", + "windows-py39-pluggy", "windows-py39", "windows-py310", "windows-py311", @@ -63,7 +63,7 @@ jobs: "windows-py313", "ubuntu-py38", - "ubuntu-py38-pluggy", + "ubuntu-py39-pluggy", "ubuntu-py38-freeze", "ubuntu-py39", "ubuntu-py310", @@ -88,10 +88,10 @@ jobs: os: windows-latest tox_env: "py38-unittestextras" use_coverage: true - - name: "windows-py38-pluggy" - python: "3.8" + - name: "windows-py39-pluggy" + python: "3.9" os: windows-latest - tox_env: "py38-pluggymain-pylib-xdist" + tox_env: "py39-pluggymain-pylib-xdist" - name: "windows-py39" python: "3.9" os: windows-latest @@ -118,10 +118,10 @@ jobs: os: ubuntu-latest tox_env: "py38-lsof-numpy-pexpect" use_coverage: true - - name: "ubuntu-py38-pluggy" - python: "3.8" + - name: "ubuntu-py39-pluggy" + python: "3.9" os: ubuntu-latest - tox_env: "py38-pluggymain-pylib-xdist" + tox_env: "py39-pluggymain-pylib-xdist" - name: "ubuntu-py38-freeze" python: "3.8" os: ubuntu-latest @@ -147,7 +147,7 @@ jobs: - name: "ubuntu-py313" python: "3.13-dev" os: ubuntu-latest - tox_env: "py313" + tox_env: "py313-pexpect" use_coverage: true - name: "ubuntu-pypy3" python: "pypy-3.9" @@ -192,9 +192,9 @@ jobs: contains( fromJSON( '[ - "windows-py38-pluggy", + "windows-py39-pluggy", "windows-py313", - "ubuntu-py38-pluggy", + "ubuntu-py39-pluggy", "ubuntu-py38-freeze", "ubuntu-py313", "macos-py38", diff --git a/.github/workflows/update-plugin-list.yml b/.github/workflows/update-plugin-list.yml index ade8452afd5..267ed69396e 100644 --- a/.github/workflows/update-plugin-list.yml +++ b/.github/workflows/update-plugin-list.yml @@ -23,12 +23,14 @@ jobs: uses: actions/checkout@v4 with: fetch-depth: 0 + persist-credentials: false - name: Setup Python uses: actions/setup-python@v5 with: python-version: "3.11" cache: pip + - name: requests-cache uses: actions/cache@v4 with: @@ -41,7 +43,6 @@ jobs: python -m pip install --upgrade pip pip install packaging requests tabulate[widechars] tqdm requests-cache platformdirs - - name: Update Plugin List run: python scripts/update-plugin-list.py @@ -61,8 +62,9 @@ jobs: - 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 }}' - '${{ steps.pr.outputs.pull-request-number }}' + "$PULL_REQUEST_NUMBER" diff --git a/.gitignore b/.gitignore index c4557b33a1c..df8dd2a9560 100644 --- a/.gitignore +++ b/.gitignore @@ -18,8 +18,6 @@ include/ *~ .hypothesis/ -# autogenerated -src/_pytest/_version.py # setuptools .eggs/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 419addd95be..1a8469a2f04 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: "v0.5.2" + rev: "v0.6.2" hooks: - id: ruff args: ["--fix"] @@ -11,40 +11,50 @@ repos: - id: trailing-whitespace - id: end-of-file-fixer - id: check-yaml +- repo: https://github.com/woodruffw/zizmor-pre-commit + rev: v0.9.2 + hooks: + - id: zizmor - repo: https://github.com/adamchainz/blacken-docs rev: 1.18.0 hooks: - id: blacken-docs additional_dependencies: [black==24.1.1] +- repo: https://github.com/codespell-project/codespell + rev: v2.3.0 + hooks: + - id: codespell + args: ["--toml=pyproject.toml"] + additional_dependencies: + - 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.10.1 + rev: v1.11.2 hooks: - id: mypy files: ^(src/|testing/|scripts/) - args: [] 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 - repo: https://github.com/tox-dev/pyproject-fmt - rev: "2.1.4" + rev: "2.2.1" 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.16.0 + rev: v3.17.0 hooks: - id: pyupgrade stages: [manual] diff --git a/AUTHORS b/AUTHORS index 9b6cb6a9d23..e8ae5e4beaa 100644 --- a/AUTHORS +++ b/AUTHORS @@ -85,6 +85,7 @@ Chris NeJame Chris Rose Chris Wheeler Christian Boelsen +Christian Clauss Christian Fetzer Christian Neumüller Christian Theunert @@ -115,6 +116,7 @@ Dave Hunt David Díaz-Barquero David Mohr David Paul Röthlisberger +David Peled David Szotten David Vierra Daw-Ran Liou @@ -158,6 +160,7 @@ Feng Ma Florian Bruhin Florian Dahlitz Floris Bruynooghe +Frank Hoffmann Fraser Stark Gabriel Landau Gabriel Reis @@ -242,6 +245,7 @@ Kristoffer Nordström Kyle Altendorf Lawrence Mitchell Lee Kamentsky +Leonardus Chen Lev Maximov Levon Saldamli Lewis Cowles @@ -251,6 +255,7 @@ lovetheguitar Lukas Bednar Luke Murphy Maciek Fijalkowski +Maggie Chung Maho Maik Figura Mandeep Bhutani @@ -299,6 +304,7 @@ mrbean-bremen Nathan Goldbaum Nathaniel Compton Nathaniel Waisbrot +Nauman Ahmed Ned Batchelder Neil Martin Neven Mundar @@ -348,9 +354,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 @@ -411,6 +419,7 @@ Ted Xiao Terje Runde Thomas Grainger Thomas Hisch +Tianyu Dongfang Tim Hoffmann Tim Strazny TJ Bruno diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 12e2b18bb52..d615e5fb113 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -380,6 +380,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 ------------------------------------------------ @@ -438,6 +489,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 ------------------------- @@ -485,9 +538,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/RELEASING.rst b/RELEASING.rst index 08004a84c00..000eae814d3 100644 --- a/RELEASING.rst +++ b/RELEASING.rst @@ -170,4 +170,8 @@ Both automatic and manual processes described above follow the same steps from t * 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/changelog/11706.bugfix.rst b/changelog/11706.bugfix.rst deleted file mode 100644 index a86db5ef66a..00000000000 --- a/changelog/11706.bugfix.rst +++ /dev/null @@ -1,4 +0,0 @@ -Fix 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. diff --git a/changelog/11771.contrib.rst b/changelog/11771.contrib.rst deleted file mode 100644 index a3c1ed1099e..00000000000 --- a/changelog/11771.contrib.rst +++ /dev/null @@ -1,5 +0,0 @@ -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 V3.8 is EoL. - --- by :user:`x612skm` diff --git a/changelog/11797.bugfix.rst b/changelog/11797.bugfix.rst deleted file mode 100644 index 94b72da00fd..00000000000 --- a/changelog/11797.bugfix.rst +++ /dev/null @@ -1 +0,0 @@ -:func:`pytest.approx` now correctly handles :class:`Sequence `-like objects. diff --git a/changelog/12153.doc.rst b/changelog/12153.doc.rst deleted file mode 100644 index ac36becf9a7..00000000000 --- a/changelog/12153.doc.rst +++ /dev/null @@ -1 +0,0 @@ -Documented using :envvar:`PYTEST_VERSION` to detect if code is running from within a pytest run. diff --git a/changelog/12204.bugfix.rst b/changelog/12204.bugfix.rst deleted file mode 100644 index 099ad70610a..00000000000 --- a/changelog/12204.bugfix.rst +++ /dev/null @@ -1,11 +0,0 @@ -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``. diff --git a/changelog/12231.feature.rst b/changelog/12231.feature.rst deleted file mode 100644 index dad04bc20c1..00000000000 --- a/changelog/12231.feature.rst +++ /dev/null @@ -1,11 +0,0 @@ -Added `--xfail-tb` flag, which turns on traceback output for XFAIL results. - -* If the `--xfail-tb` flag is not sent, 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. diff --git a/changelog/12264.bugfix.rst b/changelog/12264.bugfix.rst deleted file mode 120000 index e5704e6e819..00000000000 --- a/changelog/12264.bugfix.rst +++ /dev/null @@ -1 +0,0 @@ -12204.bugfix.rst \ No newline at end of file diff --git a/changelog/12275.bugfix.rst b/changelog/12275.bugfix.rst deleted file mode 100644 index 2d040a3a063..00000000000 --- a/changelog/12275.bugfix.rst +++ /dev/null @@ -1 +0,0 @@ -Fix collection error upon encountering an :mod:`abstract ` class, including abstract `unittest.TestCase` subclasses. diff --git a/changelog/12281.feature.rst b/changelog/12281.feature.rst deleted file mode 100644 index c6e8e3b3098..00000000000 --- a/changelog/12281.feature.rst +++ /dev/null @@ -1,8 +0,0 @@ -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` diff --git a/changelog/12328.bugfix.rst b/changelog/12328.bugfix.rst deleted file mode 100644 index f334425850b..00000000000 --- a/changelog/12328.bugfix.rst +++ /dev/null @@ -1 +0,0 @@ -Fix a regression in pytest 8.0.0 where package-scoped parameterized items were not correctly reordered to minimize setups/teardowns in some cases. diff --git a/changelog/12424.bugfix.rst b/changelog/12424.bugfix.rst deleted file mode 100644 index 7ad1126858b..00000000000 --- a/changelog/12424.bugfix.rst +++ /dev/null @@ -1 +0,0 @@ -Fix crash with `assert testcase is not None` assertion failure when re-running unittest tests using plugins like pytest-rerunfailures. Regressed in 8.2.2. diff --git a/changelog/12467.improvement.rst b/changelog/12467.improvement.rst deleted file mode 100644 index b1e0581ed16..00000000000 --- a/changelog/12467.improvement.rst +++ /dev/null @@ -1,3 +0,0 @@ -Migrated all internal type-annotations to the python3.10+ style by using the `annotations` future import. - --- by :user:`RonnyPfannschmidt` diff --git a/changelog/12469.doc.rst b/changelog/12469.doc.rst deleted file mode 100644 index 2340315353c..00000000000 --- a/changelog/12469.doc.rst +++ /dev/null @@ -1,6 +0,0 @@ -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` diff --git a/changelog/12469.improvement.rst b/changelog/12469.improvement.rst deleted file mode 100644 index a90fb1e6610..00000000000 --- a/changelog/12469.improvement.rst +++ /dev/null @@ -1,4 +0,0 @@ -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`. diff --git a/changelog/12472.bugfix.rst b/changelog/12472.bugfix.rst deleted file mode 100644 index f08e9d1f90b..00000000000 --- a/changelog/12472.bugfix.rst +++ /dev/null @@ -1 +0,0 @@ -Fixed a crash when returning category ``"error"`` or ``"failed"`` with a custom test status from :hook:`pytest_report_teststatus` hook -- :user:`pbrezina`. diff --git a/changelog/12493.contrib.rst b/changelog/12493.contrib.rst deleted file mode 100644 index db3d045697e..00000000000 --- a/changelog/12493.contrib.rst +++ /dev/null @@ -1,13 +0,0 @@ -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` diff --git a/changelog/12498.contrib.rst b/changelog/12498.contrib.rst deleted file mode 100644 index 436c6f0e9ed..00000000000 --- a/changelog/12498.contrib.rst +++ /dev/null @@ -1,5 +0,0 @@ -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` diff --git a/changelog/12501.contrib.rst b/changelog/12501.contrib.rst deleted file mode 100644 index 6f434c287b3..00000000000 --- a/changelog/12501.contrib.rst +++ /dev/null @@ -1,11 +0,0 @@ -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` diff --git a/changelog/12502.contrib.rst b/changelog/12502.contrib.rst deleted file mode 100644 index 940a2d7a120..00000000000 --- a/changelog/12502.contrib.rst +++ /dev/null @@ -1,7 +0,0 @@ -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` diff --git a/changelog/12505.bugfix.rst b/changelog/12505.bugfix.rst deleted file mode 100644 index f55a8a17e4b..00000000000 --- a/changelog/12505.bugfix.rst +++ /dev/null @@ -1 +0,0 @@ -Improve handling of invalid regex patterns in :func:`pytest.raises(match=r'...') ` by providing a clear error message. diff --git a/changelog/12522.contrib.rst b/changelog/12522.contrib.rst deleted file mode 100644 index dd994317165..00000000000 --- a/changelog/12522.contrib.rst +++ /dev/null @@ -1,4 +0,0 @@ -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`. diff --git a/changelog/12531.contrib.rst b/changelog/12531.contrib.rst deleted file mode 100644 index 12083fc320e..00000000000 --- a/changelog/12531.contrib.rst +++ /dev/null @@ -1,6 +0,0 @@ -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` diff --git a/changelog/12533.contrib.rst b/changelog/12533.contrib.rst deleted file mode 100644 index 3da7007a0fd..00000000000 --- a/changelog/12533.contrib.rst +++ /dev/null @@ -1,7 +0,0 @@ -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` diff --git a/changelog/12544.improvement.rst b/changelog/12544.improvement.rst deleted file mode 100644 index 41125f5d939..00000000000 --- a/changelog/12544.improvement.rst +++ /dev/null @@ -1,3 +0,0 @@ -The ``_in_venv()`` function now detects Python virtual environments by -checking for a :file:`pyvenv.cfg` file, ensuring reliable detection on -various platforms -- by :user:`zachsnickers`. diff --git a/changelog/12545.improvement.rst b/changelog/12545.improvement.rst deleted file mode 120000 index 41a1e6bfa49..00000000000 --- a/changelog/12545.improvement.rst +++ /dev/null @@ -1 +0,0 @@ -12544.improvement.rst \ No newline at end of file diff --git a/changelog/12557.contrib.rst b/changelog/12557.contrib.rst deleted file mode 120000 index c036c519093..00000000000 --- a/changelog/12557.contrib.rst +++ /dev/null @@ -1 +0,0 @@ -11771.contrib.rst \ No newline at end of file diff --git a/changelog/12562.contrib.rst b/changelog/12562.contrib.rst deleted file mode 100644 index 0d30495983a..00000000000 --- a/changelog/12562.contrib.rst +++ /dev/null @@ -1,2 +0,0 @@ -Possible typos in using the ``:user:`` RST role is now being linted -through the pre-commit tool integration -- by :user:`webknjaz`. diff --git a/changelog/12567.feature.rst b/changelog/12567.feature.rst deleted file mode 100644 index 3690d7aff68..00000000000 --- a/changelog/12567.feature.rst +++ /dev/null @@ -1,7 +0,0 @@ -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 correct nodeid in the same -way as other statuses. - --- by :user:`pbrezina` diff --git a/changelog/12577.doc.rst b/changelog/12577.doc.rst deleted file mode 100644 index 0bd427e177d..00000000000 --- a/changelog/12577.doc.rst +++ /dev/null @@ -1,3 +0,0 @@ -`CI` and `BUILD_NUMBER` environment variables role is discribed in -the reference doc. They now also appears when doing `pytest -h` --- by :user:`MarcBresson`. diff --git a/changelog/12580.bugfix.rst b/changelog/12580.bugfix.rst deleted file mode 100644 index 9186ef1a4c9..00000000000 --- a/changelog/12580.bugfix.rst +++ /dev/null @@ -1 +0,0 @@ -Fixed a crash when using the cache class on Windows and the cache directory was created concurrently. diff --git a/changelog/2871.improvement.rst b/changelog/2871.improvement.rst deleted file mode 100644 index 1ba399550c7..00000000000 --- a/changelog/2871.improvement.rst +++ /dev/null @@ -1 +0,0 @@ -Do not truncate arguments to functions in output when running with `-vvv`. diff --git a/changelog/389.improvement.rst b/changelog/389.improvement.rst deleted file mode 100644 index f8e2c19fde0..00000000000 --- a/changelog/389.improvement.rst +++ /dev/null @@ -1,38 +0,0 @@ -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 =========================== diff --git a/changelog/6962.bugfix.rst b/changelog/6962.bugfix.rst deleted file mode 100644 index 030b6e06392..00000000000 --- a/changelog/6962.bugfix.rst +++ /dev/null @@ -1,2 +0,0 @@ -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. diff --git a/changelog/7166.bugfix.rst b/changelog/7166.bugfix.rst deleted file mode 100644 index 98e6821f2ff..00000000000 --- a/changelog/7166.bugfix.rst +++ /dev/null @@ -1 +0,0 @@ -Fixed progress percentages (the ``[ 87%]`` at the edge of the screen) sometimes not aligning correctly when running with pytest-xdist ``-n``. diff --git a/changelog/7662.improvement.rst b/changelog/7662.improvement.rst deleted file mode 100644 index b6ae1ba7e4c..00000000000 --- a/changelog/7662.improvement.rst +++ /dev/null @@ -1 +0,0 @@ -Added timezone information to the testsuite timestamp in the JUnit XML report. diff --git a/doc/en/announce/index.rst b/doc/en/announce/index.rst index c65eb5f3613..51edc964a0c 100644 --- a/doc/en/announce/index.rst +++ b/doc/en/announce/index.rst @@ -6,6 +6,12 @@ Release announcements :maxdepth: 2 + 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 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-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/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 8dfffb0828a..8aa6fef681c 100644 --- a/doc/en/builtin.rst +++ b/doc/en/builtin.rst @@ -22,7 +22,7 @@ For information about fixtures, see :ref:`fixtures`. To see a complete list of a cachedir: .pytest_cache rootdir: /home/sweet/project collected 0 items - cache -- .../_pytest/cacheprovider.py:560 + cache -- .../_pytest/cacheprovider.py:556 Return a cache object that can persist state between testing sessions. cache.get(key, default) @@ -33,7 +33,7 @@ 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:1003 + capsysbinary -- .../_pytest/capture.py:1024 Enable bytes capturing of writes to ``sys.stdout`` and ``sys.stderr``. The captured output is made available via ``capsysbinary.readouterr()`` @@ -43,6 +43,7 @@ For information about fixtures, see :ref:`fixtures`. To see a complete list of a Returns an instance of :class:`CaptureFixture[bytes] `. Example: + .. code-block:: python def test_output(capsysbinary): @@ -50,7 +51,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:1030 + capfd -- .../_pytest/capture.py:1052 Enable text capturing of writes to file descriptors ``1`` and ``2``. The captured output is made available via ``capfd.readouterr()`` method @@ -60,6 +61,7 @@ For information about fixtures, see :ref:`fixtures`. To see a complete list of a Returns an instance of :class:`CaptureFixture[str] `. Example: + .. code-block:: python def test_system_echo(capfd): @@ -67,7 +69,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:1080 Enable bytes capturing of writes to file descriptors ``1`` and ``2``. The captured output is made available via ``capfd.readouterr()`` method @@ -77,6 +79,7 @@ For information about fixtures, see :ref:`fixtures`. To see a complete list of a Returns an instance of :class:`CaptureFixture[bytes] `. Example: + .. code-block:: python def test_system_echo(capfdbinary): @@ -84,7 +87,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:976 + capsys -- .../_pytest/capture.py:996 Enable text capturing of writes to ``sys.stdout`` and ``sys.stderr``. The captured output is made available via ``capsys.readouterr()`` method @@ -94,6 +97,7 @@ For information about fixtures, see :ref:`fixtures`. To see a complete list of a Returns an instance of :class:`CaptureFixture[str] `. Example: + .. code-block:: python def test_output(capsys): @@ -101,7 +105,7 @@ For information about fixtures, see :ref:`fixtures`. To see a complete list of a captured = capsys.readouterr() assert captured.out == "hello\n" - doctest_namespace [session scope] -- .../_pytest/doctest.py:738 + doctest_namespace [session scope] -- .../_pytest/doctest.py:741 Fixture that returns a :py:class:`dict` that will be injected into the namespace of doctests. @@ -115,17 +119,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:1338 + pytestconfig [session scope] -- .../_pytest/fixtures.py:1345 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:284 + record_property -- .../_pytest/junitxml.py:280 Add extra properties to the calling test. User properties become part of the test report and are available to the @@ -139,13 +143,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:307 + record_xml_attribute -- .../_pytest/junitxml.py:303 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:345 + record_testsuite_property [session scope] -- .../_pytest/junitxml.py:341 Record a new ```` tag as child of the root ````. This is suitable to writing global information regarding the entire test @@ -170,20 +174,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:303 + tmpdir_factory [session scope] -- .../_pytest/legacypath.py:298 Return a :class:`pytest.TempdirFactory` instance for the test session. - tmpdir -- .../_pytest/legacypath.py:310 - 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:`temporary directory location and retention`. - - 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``. @@ -192,7 +191,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:602 + caplog -- .../_pytest/logging.py:598 Access and control log capturing. Captured logs are available through the following properties/methods:: @@ -203,7 +202,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:33 + monkeypatch -- .../_pytest/monkeypatch.py:31 A convenient fixture for monkey-patching. The fixture provides these methods to modify objects, dictionaries, or @@ -227,28 +226,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:32 + recwarn -- .../_pytest/recwarn.py:35 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:242 + tmp_path_factory [session scope] -- .../_pytest/tmpdir.py:241 Return a :class:`pytest.TempPathFactory` instance for the test session. - tmp_path -- .../_pytest/tmpdir.py:257 - 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:`temporary directory location and retention`. - - The returned object is a :class:`pathlib.Path` object. + tmp_path -- .../_pytest/tmpdir.py:256 + 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 8e3efd0479b..c95ba561969 100644 --- a/doc/en/changelog.rst +++ b/doc/en/changelog.rst @@ -31,6 +31,410 @@ with advance notice in the **Deprecations** section of releases. .. towncrier release notes start +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 Towncier 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) ========================= @@ -43,7 +447,7 @@ Bug Fixes - `#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 concurent cache dir (``.pytest_cache``) creation. Regressed in pytest 8.2.0. +- `#12381 `_: Fix possible "Directory not empty" crashes arising from concurrent cache dir (``.pytest_cache``) creation. Regressed in pytest 8.2.0. @@ -54,7 +458,7 @@ Improved Documentation - `#12356 `_: Added a subsection to the documentation for debugging flaky tests to mention - lack of thread safety in pytest as a possible source of flakyness. + 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. @@ -400,7 +804,7 @@ Bug Fixes 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 interchangably in some places in the code. +- `#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*. diff --git a/doc/en/contact.rst b/doc/en/contact.rst index ef9d1e8edca..cd34f548e99 100644 --- a/doc/en/contact.rst +++ b/doc/en/contact.rst @@ -24,6 +24,13 @@ Chat `_) - ``#pytest`` `on Matrix `_. +Microblogging +------------- + +- Bluesky: `@pytest.org `_ +- Mastodon: `@pytest@fosstodon.org `_ +- Twitter/X: `@pytestdotorg `_ + Mail ---- @@ -33,10 +40,11 @@ Mail discussed in public. Mails sent there will be distributed among the members in the pytest core team, who can also be contacted individually: - * Ronny Pfannschmidt (:user:`RonnyPfannschmidt`, `ronny@pytest.org `_) - * Florian Bruhin (:user:`The-Compiler`, `florian@pytest.org `_) * 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 `_) Other diff --git a/doc/en/deprecations.rst b/doc/en/deprecations.rst index bf6268a4980..153d5195476 100644 --- a/doc/en/deprecations.rst +++ b/doc/en/deprecations.rst @@ -43,7 +43,7 @@ If the import attempt raises :class:`ModuleNotFoundError` (the usual case), then warning is emitted. This way, the usual cases will keep working the same way, while unexpected errors will now issue a warning, with -users being able to supress the warning by passing ``exc_type=ImportError`` explicitly. +users being able to suppress the warning by passing ``exc_type=ImportError`` explicitly. 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 diff --git a/doc/en/example/parametrize.rst b/doc/en/example/parametrize.rst index d540bf08337..69e715c9db1 100644 --- a/doc/en/example/parametrize.rst +++ b/doc/en/example/parametrize.rst @@ -162,7 +162,7 @@ objects, they are still using the default pytest representation: rootdir: /home/sweet/project collected 8 items - + @@ -239,7 +239,7 @@ If you just collect tests you'll also nicely see 'advanced' and 'basic' as varia rootdir: /home/sweet/project collected 4 items - + @@ -318,7 +318,7 @@ Let's first see how it looks like at collection time: rootdir: /home/sweet/project collected 2 items - + @@ -503,11 +503,12 @@ Running it results in some skips if we don't have all the python interpreters in .. code-block:: pytest . $ pytest -rs -q multipython.py - ssssssssssss...ssssssssssss [100%] + sssssssssssssssssssssssssss [100%] ========================= short test summary info ========================== - SKIPPED [12] multipython.py:65: 'python3.9' not found - SKIPPED [12] multipython.py:65: 'python3.11' not found - 3 passed, 24 skipped in 0.12s + 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 Parametrization of optional implementations/imports --------------------------------------------------- @@ -686,5 +687,5 @@ For example: assert (6 / example_input) == e In the example above, the first three test cases should run without any -exceptions, while the fourth should raise a``ZeroDivisionError`` exception, +exceptions, while the fourth should raise a ``ZeroDivisionError`` exception, which is expected by pytest. diff --git a/doc/en/example/pythoncollection.rst b/doc/en/example/pythoncollection.rst index 39b799ed934..89d7ee42614 100644 --- a/doc/en/example/pythoncollection.rst +++ b/doc/en/example/pythoncollection.rst @@ -152,7 +152,7 @@ The test collection would look like this: configfile: pytest.ini collected 2 items - + @@ -215,7 +215,7 @@ You can always peek at the collection tree without running tests like this: configfile: pytest.ini collected 3 items - + diff --git a/doc/en/example/reportingdemo.rst b/doc/en/example/reportingdemo.rst index 2c34cc2b00d..5e48815bbc9 100644 --- a/doc/en/example/reportingdemo.rst +++ b/doc/en/example/reportingdemo.rst @@ -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 = @@ -84,7 +84,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: E - eggs E + spam - failure_demo.py:44: AssertionError + failure_demo.py:46: AssertionError _____________ TestSpecialisedExplanations.test_eq_similar_text _____________ self = @@ -98,7 +98,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: E + foo 1 bar E ? ^ - failure_demo.py:47: AssertionError + failure_demo.py:49: AssertionError ____________ TestSpecialisedExplanations.test_eq_multiline_text ____________ self = @@ -112,7 +112,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: E + spam E bar - failure_demo.py:50: AssertionError + failure_demo.py:52: AssertionError ______________ TestSpecialisedExplanations.test_eq_long_text _______________ self = @@ -130,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 = @@ -150,7 +150,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: E 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 = @@ -162,7 +162,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: 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 = @@ -176,7 +176,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: 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 = @@ -194,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 = @@ -212,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 = @@ -224,7 +224,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: 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 = @@ -233,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 = @@ -252,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 = @@ -266,7 +266,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: E single foo line E ? +++ - failure_demo.py:88: AssertionError + failure_demo.py:90: AssertionError _________ TestSpecialisedExplanations.test_not_in_text_single_long _________ self = @@ -280,7 +280,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: 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 = @@ -294,7 +294,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: 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 = @@ -321,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 = @@ -348,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(): @@ -360,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(): @@ -372,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(): @@ -385,7 +385,7 @@ 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> @@ -394,7 +394,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(): @@ -411,7 +411,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 = @@ -421,7 +421,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 = @@ -430,7 +430,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 = @@ -439,7 +439,7 @@ 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 = @@ -448,7 +448,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: > 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 = @@ -459,7 +459,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: > 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 ________________________ @@ -470,7 +470,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: > 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(): @@ -486,7 +486,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: _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ > ??? @@ -506,9 +506,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) _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ @@ -518,7 +518,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 = @@ -528,7 +528,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: > 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 = @@ -538,7 +538,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: > 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 = @@ -551,7 +551,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 = @@ -568,12 +568,12 @@ Here is a nice run of several failures and how ``pytest`` presents things: E + where False = ('456') E + where = '123'.startswith E + where '123' = .f at 0xdeadbeef0029>() - E + and '456' = .g at 0xdeadbeef002a>() + E + and '456' = .g at 0xdeadbeef0003>() - failure_demo.py:235: AssertionError + failure_demo.py:237: AssertionError _____________________ TestMoreErrors.test_global_func ______________________ - self = + self = def test_global_func(self): > assert isinstance(globf(42), float) @@ -581,31 +581,31 @@ 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 = + self = def test_instance(self): self.x = 6 * 7 > assert self.x != 42 E assert 42 != 42 - E + where 42 = .x + E + where 42 = .x - failure_demo.py:242: AssertionError + failure_demo.py:244: AssertionError _______________________ TestMoreErrors.test_compare ________________________ - self = + self = def test_compare(self): > assert globf(10) < 5 E assert 11 < 5 E + where 11 = globf(10) - failure_demo.py:245: AssertionError + failure_demo.py:247: AssertionError _____________________ TestMoreErrors.test_try_finally ______________________ - self = + self = def test_try_finally(self): x = 1 @@ -613,10 +613,10 @@ 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 = + self = def test_single_line(self): class A: @@ -628,10 +628,10 @@ 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 = + self = def test_multiline(self): class A: @@ -647,10 +647,10 @@ 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:268: AssertionError + failure_demo.py:270: AssertionError ___________________ TestCustomAssertMsg.test_custom_repr ___________________ - self = + self = def test_custom_repr(self): class JSON: @@ -669,7 +669,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 d4ace3f0413..bc6c2784849 100644 --- a/doc/en/example/simple.rst +++ b/doc/en/example/simple.rst @@ -164,7 +164,7 @@ 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 @@ -460,7 +460,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": @@ -645,31 +645,6 @@ If we run this: E assert 0 test_step.py:11: AssertionError - ================================ XFAILURES ================================= - ______________________ TestUserHandling.test_deletion ______________________ - - item = - - def pytest_runtest_setup(item): - if "incremental" in item.keywords: - # retrieve the class name of the test - cls_name = str(item.cls) - # check if a previous test has failed for this class - if cls_name in _test_failed_incremental: - # retrieve the index of the test (if parametrize is used in combination with incremental) - parametrize_index = ( - tuple(item.callspec.indices.values()) - if hasattr(item, "callspec") - else () - ) - # retrieve the name of the first test function to fail for this class name and index - test_name = _test_failed_incremental[cls_name].get(parametrize_index, None) - # if name found, test has failed for the combination of class name & test name - if test_name is not None: - > pytest.xfail(f"previous test failed ({test_name})") - E _pytest.outcomes.XFailed: previous test failed (test_modification) - - conftest.py:47: XFailed ========================= short test summary info ========================== XFAIL test_step.py::TestUserHandling::test_deletion - reason: previous test failed (test_modification) ================== 1 failed, 2 passed, 1 xfailed in 0.12s ================== diff --git a/doc/en/explanation/fixtures.rst b/doc/en/explanation/fixtures.rst index 0bb3bf49fb0..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 diff --git a/doc/en/explanation/flaky.rst b/doc/en/explanation/flaky.rst index cb6c3983424..8369e1d9311 100644 --- a/doc/en/explanation/flaky.rst +++ b/doc/en/explanation/flaky.rst @@ -117,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 `__ -* 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 `__ +* 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 ^^^^^^^^^ @@ -137,5 +140,12 @@ 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 1390ba4e8fe..51c0b960aed 100644 --- a/doc/en/explanation/goodpractices.rst +++ b/doc/en/explanation/goodpractices.rst @@ -210,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 2edf60a5d8b..2606d7d4b34 100644 --- a/doc/en/explanation/index.rst +++ b/doc/en/explanation/index.rst @@ -12,5 +12,6 @@ Explanation fixtures goodpractices pythonpath + types ci flaky diff --git a/doc/en/explanation/pythonpath.rst b/doc/en/explanation/pythonpath.rst index d0314a6dbcd..e68f455cedf 100644 --- a/doc/en/explanation/pythonpath.rst +++ b/doc/en/explanation/pythonpath.rst @@ -8,7 +8,7 @@ pytest import mechanisms and ``sys.path``/``PYTHONPATH`` Import modes ------------ -pytest as a testing framework that needs to import test modules and ``conftest.py`` files for execution. +pytest as a testing framework needs to import test modules and ``conftest.py`` files for execution. 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 @@ -78,7 +78,7 @@ these values: 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 is happens when plugins import non-test modules (for example doctesting). + This happens when plugins import non-test modules (for example doctesting). If this step succeeds, the module is returned. 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 8b900d30f20..bc5e7d3c515 100644 --- a/doc/en/funcarg_compare.rst +++ b/doc/en/funcarg_compare.rst @@ -16,7 +16,7 @@ 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: @@ -107,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. @@ -164,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 85bee729ba1..5b9f38d7bf7 100644 --- a/doc/en/getting-started.rst +++ b/doc/en/getting-started.rst @@ -22,7 +22,7 @@ Install ``pytest`` .. code-block:: bash $ pytest --version - pytest 8.2.2 + pytest 8.3.5 .. _`simpletest`: diff --git a/doc/en/how-to/capture-stdout-stderr.rst b/doc/en/how-to/capture-stdout-stderr.rst index 5e23f0c024e..d91bef736e7 100644 --- a/doc/en/how-to/capture-stdout-stderr.rst +++ b/doc/en/how-to/capture-stdout-stderr.rst @@ -106,9 +106,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:`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 +126,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 afabad5da14..65a43cec6e8 100644 --- a/doc/en/how-to/capture-warnings.rst +++ b/doc/en/how-to/capture-warnings.rst @@ -128,7 +128,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 +147,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 +179,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 +222,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 @@ -332,10 +359,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 +372,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 +388,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,12 +404,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. -Full API: :class:`~_pytest.recwarn.WarningsRecorder`. .. _`warns use cases`: diff --git a/doc/en/how-to/fixtures.rst b/doc/en/how-to/fixtures.rst index ecd297867c5..8f84e4867a6 100644 --- a/doc/en/how-to/fixtures.rst +++ b/doc/en/how-to/fixtures.rst @@ -1418,7 +1418,7 @@ Running the above tests results in the following test IDs being used: rootdir: /home/sweet/project collected 12 items - + @@ -1487,7 +1487,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: diff --git a/doc/en/how-to/output.rst b/doc/en/how-to/output.rst index 4994ad1af69..8b15f95f0fd 100644 --- a/doc/en/how-to/output.rst +++ b/doc/en/how-to/output.rst @@ -444,14 +444,6 @@ Example: E assert 0 test_example.py:14: AssertionError - ================================ XFAILURES ================================= - ________________________________ test_xfail ________________________________ - - def test_xfail(): - > pytest.xfail("xfailing this test") - E _pytest.outcomes.XFailed: xfailing this test - - test_example.py:26: XFailed ================================= XPASSES ================================== ========================= short test summary info ========================== SKIPPED [1] test_example.py:22: skipping this test diff --git a/doc/en/how-to/parametrize.rst b/doc/en/how-to/parametrize.rst index b6466c491b4..5a16684eb96 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 diff --git a/doc/en/how-to/tmp_path.rst b/doc/en/how-to/tmp_path.rst index 3cc5152e992..d19950431e5 100644 --- a/doc/en/how-to/tmp_path.rst +++ b/doc/en/how-to/tmp_path.rst @@ -133,27 +133,47 @@ API for details. Temporary directory location and retention ------------------------------------------ -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 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: -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. +- By default (when the ``--basetemp`` option is not set), + the temporary directories will follow this template: -You can override the default temporary directory setting like this: + .. code-block:: text -.. code-block:: bash + {temproot}/pytest-of-{user}/pytest-{num}/{testname}/ - pytest --basetemp=mydir + where: -.. warning:: + - ``{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>`. - The contents of ``mydir`` will be completely removed, so make sure to use a directory - for that purpose only. + 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`. + +- When the ``--basetemp`` option is used (e.g. ``pytest --basetemp=mydir``), + it will be used directly as base temporary directory: + + .. code-block:: text + + {basetemp}/{testname}/ + + Note that there is no retention feature in this case: + only the results of the most recent run will be kept. + + .. warning:: + + 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 diff --git a/doc/en/how-to/unittest.rst b/doc/en/how-to/unittest.rst index 508aebde016..62e32b6d28f 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: diff --git a/doc/en/index.rst b/doc/en/index.rst index 95044e8a544..7f3dbdd800d 100644 --- a/doc/en/index.rst +++ b/doc/en/index.rst @@ -2,8 +2,7 @@ .. sidebar:: **Next Open Trainings and Events** - - `pytest: Professionelles Testen (nicht nur) für Python `_, at `CH Open Workshoptage `_, **September 2nd 2024**, HSLU Rotkreuz (CH) - - `Professional Testing with Python `_, via `Python Academy `_ (3 day in-depth training), **March 4th -- 6th 2025**, Leipzig (DE) / Remote + - `Professional Testing with Python `_, via `Python Academy `_ (3 day in-depth training), **March 4th -- 6th 2025**, Remote Also see :doc:`previous talks and blogposts ` diff --git a/doc/en/reference/reference.rst b/doc/en/reference/reference.rst index d1222728e13..5035353fb49 100644 --- a/doc/en/reference/reference.rst +++ b/doc/en/reference/reference.rst @@ -54,7 +54,7 @@ pytest.fail **Tutorial**: :ref:`skipping` -.. autofunction:: pytest.fail(reason, [pytrace=True, msg=None]) +.. autofunction:: pytest.fail(reason, [pytrace=True]) .. class:: pytest.fail.Exception @@ -63,7 +63,7 @@ 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 @@ -88,7 +88,7 @@ pytest.xfail pytest.exit ~~~~~~~~~~~ -.. autofunction:: pytest.exit(reason, [returncode=None, msg=None]) +.. autofunction:: pytest.exit(reason, [returncode=None]) .. class:: pytest.exit.Exception @@ -529,13 +529,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 @@ -1145,6 +1146,11 @@ 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 :std:doc:`entry point packaging @@ -1287,10 +1293,10 @@ 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`. @@ -1866,7 +1872,7 @@ 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`` @@ -1939,18 +1945,19 @@ 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 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 contains 'test_method' or - 'test_other', while -k 'not test_method' matches those - that don't contain 'test_method' in their names. -k 'not - test_method and not test_other' will eliminate the - matches. Additionally keywords are matched to classes - and functions containing extra names in their - 'extra_keyword_matches' set, as well as functions which - have names assigned directly to them. The matching is - case-insensitive. + 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 contains + 'test_method' or 'test_other', while -k 'not + test_method' matches those that don't contain + 'test_method' in their names. -k 'not test_method + and not test_other' will eliminate the matches. + Additionally keywords are matched to classes and + functions containing extra names in their + 'extra_keyword_matches' set, as well as functions + which have names assigned directly to them. The + matching is case-insensitive. -m MARKEXPR Only run tests matching given mark expression. For example: -m 'mark1 and not mark2'. --markers show markers (builtin, plugin and per-project ones). @@ -1968,28 +1975,28 @@ All the command-line flags can be obtained by running ``pytest --help``:: --trace Immediately break when running each test --capture=method Per-test capturing method: one of fd|sys|no|tee-sys -s Shortcut for --capture=no - --runxfail Report the results of xfail tests as if they were not - marked - --lf, --last-failed Rerun only the tests that failed at the last run (or all - if none failed) - --ff, --failed-first Run all tests, but run the last failures first. This may - re-order tests and thus lead to repeated fixture + --runxfail Report the results of xfail tests as if they were + not marked + --lf, --last-failed Rerun only the tests that failed at the last run (or + all if none failed) + --ff, --failed-first Run all tests, but run the last failures first. This + may re-order tests and thus lead to repeated fixture setup/teardown. --nf, --new-first Run tests from new files first, then the rest of the tests sorted by file mtime --cache-show=[CACHESHOW] - Show cache contents, don't perform collection or tests. - Optional argument: glob (default: '*'). + 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} - 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 + --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. @@ -2001,20 +2008,22 @@ All the command-line flags can be obtained by running ``pytest --help``:: -v, --verbose Increase verbosity --no-header Disable header --no-summary Disable summary + --no-fold-skipped Do not fold skipped tests in short summary. -q, --quiet Decrease verbosity --verbosity=VERBOSE Set verbosity. Default: 0. -r chars Show extra test summary info as specified by chars: (f)ailed, (E)rror, (s)kipped, (x)failed, (X)passed, (p)assed, (P)assed with output, (a)ll except passed - (p/P), or (A)ll. (w)arnings are enabled by default (see - --disable-warnings), 'N' can be used to reset the list. - (default: 'fE'). + (p/P), or (A)ll. (w)arnings are enabled by default + (see --disable-warnings), 'N' can be used to reset + the list. (default: 'fE'). --disable-warnings, --disable-pytest-warnings Disable warnings summary -l, --showlocals Show locals in tracebacks (disabled by default) - --no-showlocals Hide locals in tracebacks (negate --showlocals passed - through addopts) - --tb=style Traceback print mode (auto/long/short/line/native/no) + --no-showlocals Hide locals in tracebacks (negate --showlocals + 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 @@ -2022,37 +2031,41 @@ All the command-line flags can be obtained by running ``pytest --help``:: --full-trace Don't cut any tracebacks (default is to cut) --color=color Color terminal output (yes/no/auto) --code-highlight={yes,no} - Whether code should be highlighted (only if --color is - also enabled). Default: yes. + 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 - Set which warnings to report, see -W option of Python - itself + -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-markers Markers not registered in the `markers` section of + the configuration file raise errors --strict (Deprecated) alias to --strict-markers - -c FILE, --config-file=FILE + -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'. + --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 --pyargs Try to interpret all arguments as Python packages --ignore=path Ignore path during collection (multi-allowed) - --ignore-glob=path Ignore path pattern during collection (multi-allowed) + --ignore-glob=path Ignore path pattern during collection (multi- + allowed) --deselect=nodeid_prefix Deselect item (via node id prefix) during collection (multi-allowed) @@ -2062,8 +2075,8 @@ All the command-line flags can be obtained by running ``pytest --help``:: --collect-in-virtualenv Don't ignore tests in a local virtualenv directory --import-mode={prepend,append,importlib} - Prepend/append to sys.path when importing test modules - and conftest files. Default: prepend. + Prepend/append to sys.path when importing test + modules and conftest files. Default: prepend. --doctest-modules Run doctests in all .py modules --doctest-report={none,cdiff,ndiff,udiff,only_first_failure} Choose another output format for diffs on doctest @@ -2076,37 +2089,38 @@ All the command-line flags can be obtained by running ``pytest --help``:: failure test session debugging and configuration: - --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 plugins. - When given twice, also display information about - plugins. + --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 + plugins. When given twice, also display information + about plugins. -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`. + (multi-allowed). To avoid loading of plugins, use + the `no:` prefix, e.g. `no:doctest`. --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 - Override ini option with "option=value" style, e.g. `-o - xfail_strict=True -o cache_dir=cache`. + file. This file is opened with 'w' and truncated as + a result, care advised. Default: pytestdebug.log. + -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. 'plain' performs no assertion debugging. - 'rewrite' (the default) rewrites assert statements in - test modules on import to provide assert expression - information. + 'rewrite' (the default) rewrites assert statements + in test modules on import to provide assert + expression information. --setup-only Only setup fixtures, do not execute tests --setup-show Show setup of fixtures while executing tests - --setup-plan Show what fixtures and tests would be executed but don't - execute anything + --setup-plan Show what fixtures and tests would be executed but + don't execute anything logging: - --log-level=LEVEL Level of messages to catch/display. Not set by default, - so it depends on the root/parent log handler's effective - level, where it is "WARNING" by default. + --log-level=LEVEL Level of messages to catch/display. Not set by + default, so it depends on the root/parent log + handler's effective level, where it is "WARNING" by + default. --log-format=LOG_FORMAT Log format used by the logging module --log-date-format=LOG_DATE_FORMAT @@ -2130,13 +2144,8 @@ All the command-line flags can be obtained by running ``pytest --help``:: Auto-indent multiline messages passed to the logging module. Accepts true|on, false|off or an integer. --log-disable=LOGGER_DISABLE - Disable a logger by name. Can be passed multiple times. - - Custom options: - --lsof Run FD checks if lsof is available - --runpytest={inprocess,subprocess} - Run pytest sub runs in tests using an 'inprocess' or - 'subprocess' (python -m main) method + Disable a logger by name. Can be passed multiple + times. [pytest] ini-options in the first pytest.ini|tox.ini|setup.cfg|pyproject.toml file found: @@ -2151,33 +2160,37 @@ All the command-line flags can be obtained by running ``pytest --help``:: warnings.filterwarnings. Processed after -W/--pythonwarnings. 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 + 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 discovery python_classes (args): - Prefixes or glob names for Python test class discovery + Prefixes or glob names for Python test class + discovery python_functions (args): Prefixes or glob names for Python test function and method discovery disable_test_id_escaping_and_forfeit_all_rights_to_community_support (bool): - Disable string escape non-ASCII characters, might cause - unwanted side effects(use at your own risk) + Disable string escape non-ASCII characters, might + cause unwanted side effects(use at your own risk) console_output_style (string): - Console output: "classic", or with additional progress - information ("progress" (percentage) | "count" | - "progress-even-when-capture-no" (forces progress even - when capture=no) + Console output: "classic", or with additional + 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) + 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): How many sessions should we keep the `tmp_path` - directories, according to `tmp_path_retention_policy`. + directories, according to + `tmp_path_retention_policy`. tmp_path_retention_policy (string): Controls which directories created by the `tmp_path` fixture are kept around, based on test outcome. @@ -2186,9 +2199,9 @@ All the command-line flags can be obtained by running ``pytest --help``:: Enables the pytest_assertion_pass hook. Make sure to delete any previously generated pyc cache files. verbosity_assertions (string): - Specify a verbosity level for assertions, overriding the - main level. Higher levels will provide more detailed - explanation when an assertion fails. + 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): @@ -2210,8 +2223,8 @@ All the command-line flags can be obtained by running ``pytest --help``:: log_format (string): Default value for --log-format log_date_format (string): Default value for --log-date-format - log_cli (bool): Enable log display during test run (also known as "live - logging") + log_cli (bool): Enable log display during test run (also known as + "live logging") log_cli_level (string): Default value for --log-cli-level log_cli_format (string): @@ -2231,18 +2244,16 @@ All the command-line flags can be obtained by running ``pytest --help``:: 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 + 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 required_plugins (args): Plugins that must be present for pytest to run - pytester_example_dir (string): - Directory to take the pytester example files from 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 + 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 0637c967b8a..ddcb7efb99b 100644 --- a/doc/en/requirements.txt +++ b/doc/en/requirements.txt @@ -1,13 +1,10 @@ +-c broken-dep-constraints.txt pluggy>=1.5.0 pygments-pytest>=2.3.0 sphinx-removed-in>=0.2.0 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 furo sphinxcontrib-towncrier sphinx-issues diff --git a/pyproject.toml b/pyproject.toml index f3eba4a08a8..40691d8ef5a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,11 +2,11 @@ build-backend = "setuptools.build_meta" requires = [ "setuptools>=61", - "setuptools-scm[toml]>=6.2.3", ] [project] name = "pytest" +version = "8.3.5" description = "pytest: simple powerful testing with Python" readme = "README.rst" keywords = [ @@ -42,9 +42,6 @@ classifiers = [ "Topic :: Software Development :: Testing", "Topic :: Utilities", ] -dynamic = [ - "version", -] dependencies = [ "colorama; sys_platform=='win32'", "exceptiongroup>=1.0.0rc8; python_version<'3.11'", @@ -64,24 +61,23 @@ optional-dependencies.dev = [ "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" -urls.Twitter = "https://twitter.com/pytestdotorg" scripts."py.test" = "pytest:console_main" scripts.pytest = "pytest:console_main" [tool.setuptools.package-data] "_pytest" = [ "py.typed", + "_version.py", ] "pytest" = [ "py.typed", ] -[tool.setuptools_scm] -write_to = "src/_pytest/_version.py" - [tool.black] target-version = [ 'py38', @@ -306,6 +302,11 @@ disable = [ "wrong-import-position", # handled by isort / ruff ] +[tool.codespell] +ignore-words-list = "afile,asser,assertio,feld,hove,ned,noes,notin,paramete,parth,socio-economic,tesults,varius,wil" +skip = "*/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 @@ -474,6 +475,7 @@ files = [ mypy_path = [ "src", ] +python_version = "3.8" check_untyped_defs = true disallow_any_generics = true disallow_untyped_defs = true 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 545919cd60b..aef5d6d5f73 100644 --- a/scripts/release.py +++ b/scripts/release.py @@ -6,6 +6,7 @@ import argparse import os from pathlib import Path +import re from subprocess import call from subprocess import check_call from subprocess import check_output @@ -16,17 +17,27 @@ 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 + # 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"], encoding="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" } diff --git a/scripts/update-plugin-list.py b/scripts/update-plugin-list.py index 75df0ddba40..556004d9e98 100644 --- a/scripts/update-plugin-list.py +++ b/scripts/update-plugin-list.py @@ -66,6 +66,7 @@ "logot", "nuts", "flask_fixture", + "databricks-labs-pytester", } diff --git a/src/_pytest/_code/code.py b/src/_pytest/_code/code.py index e7452825756..fec627b3a36 100644 --- a/src/_pytest/_code/code.py +++ b/src/_pytest/_code/code.py @@ -56,7 +56,7 @@ TracebackStyle = Literal["long", "short", "line", "no", "native", "value", "auto"] -EXCEPTION_OR_MORE = Union[Type[Exception], Tuple[Type[Exception], ...]] +EXCEPTION_OR_MORE = Union[Type[BaseException], Tuple[Type[BaseException], ...]] class Code: @@ -1221,6 +1221,15 @@ def _write_entry_lines(self, tw: TerminalWriter) -> None: 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" @@ -1236,11 +1245,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) diff --git a/src/_pytest/_io/pprint.py b/src/_pytest/_io/pprint.py index 7213be7ba9b..fc29989be0b 100644 --- a/src/_pytest/_io/pprint.py +++ b/src/_pytest/_io/pprint.py @@ -111,7 +111,7 @@ def _format( p(self, object, stream, indent, allowance, context, level + 1) context.remove(objid) elif ( - _dataclasses.is_dataclass(object) + _dataclasses.is_dataclass(object) # type:ignore[unreachable] and not isinstance(object, type) and object.__dataclass_params__.repr and @@ -119,7 +119,7 @@ def _format( hasattr(object.__repr__, "__wrapped__") and "__create_fn__" in object.__repr__.__wrapped__.__qualname__ ): - context.add(objid) + context.add(objid) # type:ignore[unreachable] self._pprint_dataclass( object, stream, indent, allowance, context, level + 1 ) diff --git a/src/_pytest/_version.py b/src/_pytest/_version.py new file mode 100644 index 00000000000..e6fac424871 --- /dev/null +++ b/src/_pytest/_version.py @@ -0,0 +1,34 @@ +# file generated by setuptools-scm +# don't change, don't track in version control + +__all__ = [ + "__commit_id__", + "__version__", + "__version_tuple__", + "commit_id", + "version", + "version_tuple", +] + +TYPE_CHECKING = False +if TYPE_CHECKING: + from typing import Tuple + from typing import Union + + VERSION_TUPLE = Tuple[Union[int, str], ...] + COMMIT_ID = Union[str, None] +else: + VERSION_TUPLE = object + COMMIT_ID = object + +version: str +__version__: str +__version_tuple__: VERSION_TUPLE +version_tuple: VERSION_TUPLE +commit_id: COMMIT_ID +__commit_id__: COMMIT_ID + +__version__ = version = "8.3.5" +__version_tuple__ = version_tuple = (8, 3, 5) + +__commit_id__ = commit_id = "unknown" diff --git a/src/_pytest/assertion/rewrite.py b/src/_pytest/assertion/rewrite.py index bfcbcbd3f8d..1f11935c70e 100644 --- a/src/_pytest/assertion/rewrite.py +++ b/src/_pytest/assertion/rewrite.py @@ -28,6 +28,7 @@ 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.config import Config @@ -101,6 +102,16 @@ def find_spec( # 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 @@ -422,6 +433,8 @@ def _saferepr(obj: object) -> str: 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") @@ -451,7 +464,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: @@ -782,7 +795,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.""" @@ -965,7 +978,10 @@ def visit_Assert(self, assert_: ast.Assert) -> list[ast.stmt]: # 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: ast.NamedExpr) -> tuple[ast.NamedExpr, str]: @@ -1042,7 +1058,7 @@ def visit_BoolOp(self, boolop: ast.BoolOp) -> 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]: @@ -1050,7 +1066,9 @@ def visit_BinOp(self, binop: ast.BinOp) -> tuple[ast.Name, str]: 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]: @@ -1079,7 +1097,7 @@ 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}}" @@ -1095,7 +1113,9 @@ 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) @@ -1136,7 +1156,7 @@ def visit_Compare(self, comp: ast.Compare) -> tuple[ast.expr, str]: syms.append(ast.Constant(sym)) expl = f"{left_expl} {sym} {next_expl}" expls.append(ast.Constant(expl)) - res_expr = ast.Compare(left_res, [op], [next_res]) + 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. diff --git a/src/_pytest/cacheprovider.py b/src/_pytest/cacheprovider.py index 20bb262e05d..1b236efdc9b 100755 --- a/src/_pytest/cacheprovider.py +++ b/src/_pytest/cacheprovider.py @@ -347,7 +347,7 @@ def get_last_failed_paths(self) -> set[Path]: return {x for x in result if x.exists()} def pytest_report_collectionfinish(self) -> str | None: - if self.active and self.config.getoption("verbose") >= 0: + if self.active and self.config.get_verbosity() >= 0: return f"run-last-failure: {self._report_status}" return None @@ -369,7 +369,7 @@ def pytest_collectreport(self, report: CollectReport) -> None: @hookimpl(wrapper=True, tryfirst=True) def pytest_collection_modifyitems( self, config: Config, items: list[nodes.Item] - ) -> Generator[None, None, None]: + ) -> Generator[None]: res = yield if not self.active: @@ -439,9 +439,7 @@ def __init__(self, config: Config) -> None: self.cached_nodeids = set(config.cache.get("cache/nodeids", [])) @hookimpl(wrapper=True, tryfirst=True) - def pytest_collection_modifyitems( - self, items: list[nodes.Item] - ) -> Generator[None, None, None]: + def pytest_collection_modifyitems(self, items: list[nodes.Item]) -> Generator[None]: res = yield if self.active: diff --git a/src/_pytest/capture.py b/src/_pytest/capture.py index c4dfcc27552..ded8af87fd9 100644 --- a/src/_pytest/capture.py +++ b/src/_pytest/capture.py @@ -79,6 +79,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. @@ -135,11 +152,12 @@ def _reopen_stdio(f, mode): @hookimpl(wrapper=True) -def pytest_load_initial_conftests(early_config: Config) -> Generator[None, None, None]: +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") @@ -202,6 +220,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: @@ -817,7 +836,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 +853,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: @@ -869,17 +888,17 @@ def pytest_make_collect_report( return rep @hookimpl(wrapper=True) - def pytest_runtest_setup(self, item: Item) -> Generator[None, None, None]: + def pytest_runtest_setup(self, item: Item) -> Generator[None]: with self.item_capture("setup", item): return (yield) @hookimpl(wrapper=True) - def pytest_runtest_call(self, item: Item) -> Generator[None, None, None]: + def pytest_runtest_call(self, item: Item) -> Generator[None]: with self.item_capture("call", item): return (yield) @hookimpl(wrapper=True) - def pytest_runtest_teardown(self, item: Item) -> Generator[None, None, None]: + def pytest_runtest_teardown(self, item: Item) -> Generator[None]: with self.item_capture("teardown", item): return (yield) @@ -961,7 +980,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" @@ -974,7 +993,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 @@ -1002,7 +1021,7 @@ def test_output(capsys): @fixture -def capsysbinary(request: SubRequest) -> Generator[CaptureFixture[bytes], None, None]: +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()`` @@ -1030,7 +1049,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 @@ -1058,7 +1077,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/config/__init__.py b/src/_pytest/config/__init__.py index 0c1850df503..c41e5fc8629 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -272,6 +272,7 @@ def directory_arg(path: str, optname: str) -> str: "unraisableexception", "threadexception", "faulthandler", + "subtests", ) builtin_plugins = set(default_plugins) @@ -361,7 +362,7 @@ def _get_legacy_hook_marks( 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] = [] @@ -840,9 +841,9 @@ def import_plugin(self, modname: str, consider_entry_points: bool = False) -> No # "terminal" or "capture". Those plugins are registered under their # basename for historic purposes but must be imported with the # _pytest prefix. - assert isinstance( - modname, str - ), f"module name as text required, got {modname!r}" + assert isinstance(modname, str), ( + f"module name as text required, got {modname!r}" + ) if self.is_blocked(modname) or self.get_plugin(modname) is not None: return @@ -1179,8 +1180,12 @@ def notify_exception( 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 @@ -1481,9 +1486,9 @@ def _get_unknown_ini_keys(self) -> list[str]: 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) ) @@ -1678,11 +1683,12 @@ def _get_override_ini_value(self, name: str) -> str | None: def getoption(self, name: str, default=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: @@ -1711,6 +1717,8 @@ def getvalueorskip(self, name: str, path=None): VERBOSITY_ASSERTIONS: Final = "assertions" #: Verbosity type for test case execution (see :confval:`verbosity_test_cases`). VERBOSITY_TEST_CASES: Final = "test_cases" + #: Verbosity type for failed subtests (see :confval:`verbosity_subtests`). + VERBOSITY_SUBTESTS: Final = "subtests" _VERBOSITY_INI_DEFAULT: Final = "auto" def get_verbosity(self, verbosity_type: str | None = None) -> int: @@ -1744,7 +1752,7 @@ def get_verbosity(self, verbosity_type: str | None = None) -> int: print(config.get_verbosity()) # 1 print(config.get_verbosity(Config.VERBOSITY_ASSERTIONS)) # 2 """ - global_level = self.option.verbose + global_level = self.getoption("verbose", default=0) assert isinstance(global_level, int) if verbosity_type is None: return global_level diff --git a/src/_pytest/debugging.py b/src/_pytest/debugging.py index 3e1463fff26..2dfe321e968 100644 --- a/src/_pytest/debugging.py +++ b/src/_pytest/debugging.py @@ -332,7 +332,7 @@ def maybe_wrap_pytest_function_for_tracing(pyfuncitem) -> None: 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 diff --git a/src/_pytest/deprecated.py b/src/_pytest/deprecated.py index a605c24e58f..778d3614715 100644 --- a/src/_pytest/deprecated.py +++ b/src/_pytest/deprecated.py @@ -24,6 +24,7 @@ "pytest_catchlog", "pytest_capturelog", "pytest_faulthandler", + "pytest_subtests", } diff --git a/src/_pytest/doctest.py b/src/_pytest/doctest.py index cb46d9a3bb5..384dea976ad 100644 --- a/src/_pytest/doctest.py +++ b/src/_pytest/doctest.py @@ -44,7 +44,8 @@ if TYPE_CHECKING: import doctest - from typing import Self + + from typing_extensions import Self DOCTEST_REPORT_CHOICE_NONE = "none" DOCTEST_REPORT_CHOICE_CDIFF = "cdiff" @@ -467,7 +468,7 @@ 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 diff --git a/src/_pytest/faulthandler.py b/src/_pytest/faulthandler.py index 07e60f03fc9..d16aea1eb88 100644 --- a/src/_pytest/faulthandler.py +++ b/src/_pytest/faulthandler.py @@ -64,6 +64,7 @@ def get_stderr_fileno() -> int: # 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() diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index 7d0b40b150a..6b882fa3515 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -53,6 +53,7 @@ from _pytest.compat import NOTSET from _pytest.compat import NotSetType from _pytest.compat import safe_getattr +from _pytest.compat import safe_isclass from _pytest.config import _PluggyPlugin from _pytest.config import Config from _pytest.config import ExitCode @@ -1348,7 +1349,7 @@ def pytestconfig(request: FixtureRequest) -> Config: Example:: def test_foo(pytestconfig): - if pytestconfig.getoption("verbose") > 0: + if pytestconfig.get_verbosity() > 0: ... """ @@ -1724,17 +1725,26 @@ def parsefactories( 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) for name in dir(holderobj): # 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) + # access below can raise. safe_getattr() ignores such exceptions. + obj_ub = safe_getattr(holderobj_tp, name, None) + marker = getfixturemarker(obj_ub) if not isinstance(marker, FixtureFunctionMarker): # Magic globals with __getattr__ might have got us a wrong # fixture attribute. continue + # OK we know it is a fixture -- now safe to look up on the _instance_. + obj = getattr(holderobj, name) + if marker.name: name = marker.name @@ -1807,7 +1817,7 @@ def _show_fixtures_per_test(config: Config, session: Session) -> None: session.perform_collect() invocation_dir = config.invocation_params.dir tw = _pytest.config.create_terminal_writer(config) - verbose = config.getvalue("verbose") + verbose = config.get_verbosity() def get_best_relpath(func) -> str: loc = getlocation(func, invocation_dir) @@ -1866,7 +1876,7 @@ def _showfixtures_main(config: Config, session: Session) -> None: session.perform_collect() invocation_dir = config.invocation_params.dir tw = _pytest.config.create_terminal_writer(config) - verbose = config.getvalue("verbose") + verbose = config.get_verbosity() fm = session._fixturemanager diff --git a/src/_pytest/hookspec.py b/src/_pytest/hookspec.py index 99614899994..ed843606fea 100644 --- a/src/_pytest/hookspec.py +++ b/src/_pytest/hookspec.py @@ -274,6 +274,11 @@ def pytest_collection_modifyitems( """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. @@ -454,6 +459,12 @@ def pytest_collectreport(report: CollectReport) -> 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: diff --git a/src/_pytest/legacypath.py b/src/_pytest/legacypath.py index 61476d68932..59e8ef6e742 100644 --- a/src/_pytest/legacypath.py +++ b/src/_pytest/legacypath.py @@ -304,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:`temporary directory location and retention`. - - 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``. diff --git a/src/_pytest/logging.py b/src/_pytest/logging.py index 44af8ff2041..08c826ff6d4 100644 --- a/src/_pytest/logging.py +++ b/src/_pytest/logging.py @@ -554,9 +554,7 @@ def set_level(self, level: int | str, logger: str | None = None) -> None: self._initial_disabled_logging_level = initial_disabled_logging_level @contextmanager - def at_level( - self, level: int | str, logger: str | None = 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. @@ -580,7 +578,7 @@ def at_level( logging.disable(original_disable_level) @contextmanager - def filtering(self, filter_: logging.Filter) -> Generator[None, None, None]: + 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. @@ -597,7 +595,7 @@ def filtering(self, filter_: logging.Filter) -> Generator[None, None, None]: @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:: @@ -776,7 +774,7 @@ def _log_cli_enabled(self) -> bool: return True @hookimpl(wrapper=True, tryfirst=True) - def pytest_sessionstart(self) -> Generator[None, None, None]: + 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): @@ -784,7 +782,7 @@ def pytest_sessionstart(self) -> Generator[None, None, None]: return (yield) @hookimpl(wrapper=True, tryfirst=True) - def pytest_collection(self) -> Generator[None, None, None]: + 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): @@ -796,7 +794,7 @@ def pytest_runtestloop(self, session: Session) -> Generator[None, object, object if session.config.option.collectonly: 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 @@ -813,7 +811,7 @@ 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]: + 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, @@ -834,7 +832,7 @@ def _runtest_for(self, item: nodes.Item, when: str) -> Generator[None, None, Non item.add_report_section(when, "log", log) @hookimpl(wrapper=True) - def pytest_runtest_setup(self, item: nodes.Item) -> Generator[None, None, None]: + def pytest_runtest_setup(self, item: nodes.Item) -> Generator[None]: self.log_cli_handler.set_when("setup") empty: dict[str, list[logging.LogRecord]] = {} @@ -842,13 +840,13 @@ def pytest_runtest_setup(self, item: nodes.Item) -> Generator[None, None, None]: yield from self._runtest_for(item, "setup") @hookimpl(wrapper=True) - def pytest_runtest_call(self, item: nodes.Item) -> Generator[None, None, None]: + def pytest_runtest_call(self, item: nodes.Item) -> Generator[None]: self.log_cli_handler.set_when("call") yield from self._runtest_for(item, "call") @hookimpl(wrapper=True) - def pytest_runtest_teardown(self, item: nodes.Item) -> Generator[None, None, None]: + def pytest_runtest_teardown(self, item: nodes.Item) -> Generator[None]: self.log_cli_handler.set_when("teardown") try: @@ -862,7 +860,7 @@ def pytest_runtest_logfinish(self) -> None: self.log_cli_handler.set_when("finish") @hookimpl(wrapper=True, tryfirst=True) - def pytest_sessionfinish(self) -> Generator[None, None, None]: + 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): diff --git a/src/_pytest/main.py b/src/_pytest/main.py index 8ec26906003..e5534e98d69 100644 --- a/src/_pytest/main.py +++ b/src/_pytest/main.py @@ -49,7 +49,7 @@ if TYPE_CHECKING: - from typing import Self + from typing_extensions import Self from _pytest.fixtures import FixtureManager @@ -370,9 +370,20 @@ 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 pyvenv.cfg file. - [https://peps.python.org/pep-0405/]""" + + [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: - return path.joinpath("pyvenv.cfg").is_file() + return ( + path.joinpath("pyvenv.cfg").is_file() + or path.joinpath("conda-meta", "history").is_file() + ) except OSError: return False diff --git a/src/_pytest/monkeypatch.py b/src/_pytest/monkeypatch.py index 75b019a3be6..46eb1724e35 100644 --- a/src/_pytest/monkeypatch.py +++ b/src/_pytest/monkeypatch.py @@ -28,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 @@ -135,7 +135,7 @@ def __init__(self) -> 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. diff --git a/src/_pytest/nodes.py b/src/_pytest/nodes.py index bbde2664b90..51bc5174628 100644 --- a/src/_pytest/nodes.py +++ b/src/_pytest/nodes.py @@ -43,7 +43,7 @@ if TYPE_CHECKING: - from typing import Self + from typing_extensions import Self # Imported here due to circular import. from _pytest.main import Session @@ -435,12 +435,12 @@ 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.getoption("verbose", 0) > 2 else 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 diff --git a/src/_pytest/pathlib.py b/src/_pytest/pathlib.py index e4dc4eddc9c..ca8a1d2d4f9 100644 --- a/src/_pytest/pathlib.py +++ b/src/_pytest/pathlib.py @@ -10,6 +10,7 @@ import fnmatch from functools import partial from importlib.machinery import ModuleSpec +from importlib.machinery import PathFinder import importlib.util import itertools import os @@ -37,8 +38,12 @@ 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) @@ -611,50 +616,109 @@ 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 to the .py file, and its - parent location. + 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 - for made-up module names (when importing test files not reachable from sys.path). + 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: - spec = meta_importer.find_spec(module_name, [str(module_location)]) + 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: - spec = importlib.util.spec_from_file_location(module_name, str(module_path)) + 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()) + + 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 - # 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 parent_module is None: - # Find the directory of this module's parent. - parent_dir = ( - module_path.parent.parent - if module_path.name == "__init__.py" - else module_path.parent - ) - # Consider the parent module path as its __init__.py file, if it has one. - parent_module_path = ( - parent_dir / "__init__.py" - if (parent_dir / "__init__.py").is_file() - else parent_dir - ) - parent_module = _import_module_using_spec( - parent_module_name, - parent_module_path, - parent_dir, - insert_modules=insert_modules, - ) - # Find spec and import this module. mod = importlib.util.module_from_spec(spec) sys.modules[module_name] = mod @@ -673,10 +737,21 @@ def _import_module_using_spec( 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 or module_spec.origin is None: + if module_spec is None: return False - return Path(module_spec.origin) == module_path + 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 @@ -880,17 +955,24 @@ def scandir( 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=sort_key) # type: ignore[arg-type] diff --git a/src/_pytest/pytester.py b/src/_pytest/pytester.py index 5c6ce5e889f..3f7520ee4ad 100644 --- a/src/_pytest/pytester.py +++ b/src/_pytest/pytester.py @@ -491,7 +491,7 @@ def pytester( @fixture -def _sys_snapshot() -> Generator[None, None, None]: +def _sys_snapshot() -> Generator[None]: snappaths = SysPathsSnapshot() snapmods = SysModulesSnapshot() yield @@ -500,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() diff --git a/src/_pytest/python.py b/src/_pytest/python.py index 9182ce7dfe9..3478c34c47d 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -78,7 +78,7 @@ if TYPE_CHECKING: - from typing import Self + from typing_extensions import Self def pytest_addoption(parser: Parser) -> None: @@ -512,7 +512,7 @@ def importtestmodule( ) from e except ImportError as e: exc_info = ExceptionInfo.from_current() - if config.getoption("verbose") < 2: + if config.get_verbosity() < 2: exc_info.traceback = exc_info.traceback.filter(filter_traceback) exc_repr = ( exc_info.getrepr(style="short") @@ -568,7 +568,7 @@ def _register_setup_module_fixture(self) -> None: if setup_module is None and teardown_module is None: return - 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, module) @@ -599,7 +599,7 @@ def _register_setup_function_fixture(self) -> None: if setup_function is None and teardown_function is None: return - 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 @@ -780,7 +780,7 @@ def _register_setup_class_fixture(self) -> None: if setup_class is None and teardown_class is None: return - def xunit_setup_class_fixture(request) -> 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) @@ -813,7 +813,7 @@ def _register_setup_method_fixture(self) -> None: if setup_method is None and teardown_method is None: return - def xunit_setup_method_fixture(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: diff --git a/src/_pytest/python_api.py b/src/_pytest/python_api.py index 4174a55b589..f0035f0c393 100644 --- a/src/_pytest/python_api.py +++ b/src/_pytest/python_api.py @@ -262,19 +262,22 @@ def _repr_compare(self, other_side: Mapping[object, float]) -> list[str]: ): if approx_value != other_value: if approx_value.expected is not None and other_value is not None: - 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 - ), + 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 = [ @@ -398,8 +401,10 @@ 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) + 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) @@ -427,14 +432,17 @@ def __eq__(self, actual) -> bool: # 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 + if isinstance(self.expected, bool) and not isinstance(actual, bool): + 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 + # nonnumeric, even though it has the required arithmetic. + if isinstance(self.expected, bool) or not ( isinstance(self.expected, (Complex, Decimal)) and isinstance(actual, (Complex, Decimal)) ): diff --git a/src/_pytest/recwarn.py b/src/_pytest/recwarn.py index 3fc00d94736..0dc002edd94 100644 --- a/src/_pytest/recwarn.py +++ b/src/_pytest/recwarn.py @@ -32,11 +32,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: diff --git a/src/_pytest/reports.py b/src/_pytest/reports.py index 77cbf773e23..9f20fb3498a 100644 --- a/src/_pytest/reports.py +++ b/src/_pytest/reports.py @@ -251,7 +251,6 @@ def _report_unserialization_failure( raise RuntimeError(stream.getvalue()) -@final class TestReport(BaseReport): """Basic test report object (also used for setup and teardown calls if they fail). @@ -361,9 +360,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 diff --git a/src/_pytest/runner.py b/src/_pytest/runner.py index 716c4948f4a..7d63095a86a 100644 --- a/src/_pytest/runner.py +++ b/src/_pytest/runner.py @@ -16,6 +16,7 @@ from typing import TYPE_CHECKING from typing import TypeVar +from .config import Config from .reports import BaseReport from .reports import CollectErrorRepr from .reports import CollectReport @@ -71,7 +72,7 @@ def pytest_addoption(parser: Parser) -> 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 tr = terminalreporter @@ -167,7 +168,7 @@ def pytest_runtest_call(item: Item) -> None: del sys.last_value del sys.last_traceback if sys.version_info >= (3, 12, 0): - del sys.last_exc + del sys.last_exc # type:ignore[attr-defined] except AttributeError: pass try: @@ -177,7 +178,7 @@ def pytest_runtest_call(item: Item) -> None: sys.last_type = type(e) sys.last_value = e if sys.version_info >= (3, 12, 0): - sys.last_exc = e + sys.last_exc = e # type:ignore[attr-defined] assert e.__traceback__ is not None # Skip *this* frame sys.last_traceback = e.__traceback__.tb_next @@ -235,11 +236,11 @@ def call_and_report( 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 + lambda: runtest_hook(item=item, **kwds), + when=when, + reraise=get_reraise_exceptions(item.config), ) report: TestReport = ihook.pytest_runtest_makereport(item=item, call=call) if log: @@ -249,6 +250,14 @@ def call_and_report( return report +def get_reraise_exceptions(config: Config) -> tuple[type[BaseException], ...]: + """Return exception types that should not be suppressed in general.""" + reraise: tuple[type[BaseException], ...] = (Exit,) + if not config.getoption("usepdb", False): + reraise += (KeyboardInterrupt,) + return reraise + + def check_interactive_exception(call: CallInfo[object], report: BaseReport) -> bool: """Check whether the call raised an exception that should be reported as interactive.""" diff --git a/src/_pytest/skipping.py b/src/_pytest/skipping.py index 08fcb283eb2..9818be2ab03 100644 --- a/src/_pytest/skipping.py +++ b/src/_pytest/skipping.py @@ -245,7 +245,7 @@ def pytest_runtest_setup(item: Item) -> None: @hookimpl(wrapper=True) -def pytest_runtest_call(item: Item) -> Generator[None, None, None]: +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) diff --git a/src/_pytest/stepwise.py b/src/_pytest/stepwise.py index bd906ce63c1..c7860808c35 100644 --- a/src/_pytest/stepwise.py +++ b/src/_pytest/stepwise.py @@ -113,7 +113,7 @@ def pytest_runtest_logreport(self, report: TestReport) -> None: self.lastfailed = None def pytest_report_collectionfinish(self) -> str | None: - if self.config.getoption("verbose") >= 0 and self.report_status: + if self.config.get_verbosity() >= 0 and self.report_status: return f"stepwise: {self.report_status}" return None diff --git a/src/_pytest/subtests.py b/src/_pytest/subtests.py new file mode 100644 index 00000000000..d9455c80544 --- /dev/null +++ b/src/_pytest/subtests.py @@ -0,0 +1,418 @@ +"""Builtin plugin that adds subtests support.""" + +from __future__ import annotations + +from collections import defaultdict +from collections.abc import Callable +from collections.abc import Iterator +from collections.abc import Mapping +from contextlib import AbstractContextManager +from contextlib import contextmanager +from contextlib import ExitStack +from contextlib import nullcontext +import dataclasses +import time +from types import TracebackType +from typing import Any +from typing import TYPE_CHECKING + +import pluggy + +from _pytest._code import ExceptionInfo +from _pytest._io.saferepr import saferepr +from _pytest.capture import CaptureFixture +from _pytest.capture import FDCapture +from _pytest.capture import SysCapture +from _pytest.config import Config +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 SubRequest +from _pytest.logging import catching_logs +from _pytest.logging import LogCaptureHandler +from _pytest.logging import LoggingPlugin +from _pytest.reports import TestReport +from _pytest.runner import CallInfo +from _pytest.runner import check_interactive_exception +from _pytest.runner import get_reraise_exceptions +from _pytest.stash import StashKey + + +if TYPE_CHECKING: + from typing_extensions import Self + + +def pytest_addoption(parser: Parser) -> None: + Config._add_verbosity_ini( + parser, + Config.VERBOSITY_SUBTESTS, + help=( + "Specify verbosity level for subtests. " + "Higher levels will generate output for passed subtests. Failed subtests are always reported." + ), + ) + + +@dataclasses.dataclass(frozen=True, slots=True, kw_only=True) +class SubtestContext: + """The values passed to Subtests.test() that are included in the test report.""" + + msg: str | None + kwargs: Mapping[str, Any] + + def __post_init__(self) -> None: + # Brute-force the returned kwargs dict to be JSON serializable (pytest-dev/pytest-xdist#1273). + object.__setattr__( + self, "kwargs", {k: saferepr(v) for (k, v) in self.kwargs.items()} + ) + + def _to_json(self) -> dict[str, Any]: + result = dataclasses.asdict(self) + return result + + @classmethod + def _from_json(cls, d: dict[str, Any]) -> Self: + return cls(msg=d["msg"], kwargs=d["kwargs"]) + + +@dataclasses.dataclass(init=False) +class SubtestReport(TestReport): + context: SubtestContext + + @property + def head_line(self) -> str: + _, _, domain = self.location + return f"{domain} {self._sub_test_description()}" + + def _sub_test_description(self) -> str: + parts = [] + if self.context.msg is not None: + parts.append(f"[{self.context.msg}]") + if self.context.kwargs: + params_desc = ", ".join( + f"{k}={saferepr(v)}" for (k, v) in self.context.kwargs.items() + ) + parts.append(f"({params_desc})") + return " ".join(parts) or "()" + + def _to_json(self) -> dict[str, Any]: + data = super()._to_json() + del data["context"] + data["_report_type"] = "SubTestReport" + data["_subtest.context"] = self.context._to_json() + return data + + @classmethod + def _from_json(cls, reportdict: dict[str, Any]) -> SubtestReport: + report = super()._from_json(reportdict) + report.context = SubtestContext._from_json(reportdict["_subtest.context"]) + return report + + @classmethod + def _new( + cls, + test_report: TestReport, + context: SubtestContext, + captured_output: Captured | None, + captured_logs: CapturedLogs | None, + ) -> Self: + result = super()._from_json(test_report._to_json()) + result.context = context + + if captured_output: + if captured_output.out: + result.sections.append(("Captured stdout call", captured_output.out)) + if captured_output.err: + result.sections.append(("Captured stderr call", captured_output.err)) + + if captured_logs and (log := captured_logs.handler.stream.getvalue()): + result.sections.append(("Captured log call", log)) + + return result + + +@fixture +def subtests(request: SubRequest) -> Subtests: + """Provides subtests functionality.""" + capmam = request.node.config.pluginmanager.get_plugin("capturemanager") + suspend_capture_ctx = ( + capmam.global_and_fixture_disabled if capmam is not None else nullcontext + ) + return Subtests(request.node.ihook, suspend_capture_ctx, request, _ispytest=True) + + +class Subtests: + """Subtests fixture, enables declaring subtests inside test functions via the :meth:`test` method.""" + + def __init__( + self, + ihook: pluggy.HookRelay, + suspend_capture_ctx: Callable[[], AbstractContextManager[None]], + request: SubRequest, + *, + _ispytest: bool = False, + ) -> None: + check_ispytest(_ispytest) + self._ihook = ihook + self._suspend_capture_ctx = suspend_capture_ctx + self._request = request + + def test( + self, + msg: str | None = None, + **kwargs: Any, + ) -> _SubTestContextManager: + """ + Context manager for subtests, capturing exceptions raised inside the subtest scope and + reporting assertion failures and errors individually. + + Usage + ----- + + .. code-block:: python + + def test(subtests): + for i in range(5): + with subtests.test("custom message", i=i): + assert i % 2 == 0 + + :param msg: + If given, the message will be shown in the test report in case of subtest failure. + + :param kwargs: + Arbitrary values that are also added to the subtest report. + """ + return _SubTestContextManager( + self._ihook, + msg, + kwargs, + request=self._request, + suspend_capture_ctx=self._suspend_capture_ctx, + config=self._request.config, + ) + + +@dataclasses.dataclass +class _SubTestContextManager: + """ + Context manager for subtests, capturing exceptions raised inside the subtest scope and handling + them through the pytest machinery. + """ + + # Note: initially the logic for this context manager was implemented directly + # in Subtests.test() as a @contextmanager, however, it is not possible to control the output fully when + # exiting from it due to an exception when in `--exitfirst` mode, so this was refactored into an + # explicit context manager class (pytest-dev/pytest-subtests#134). + + ihook: pluggy.HookRelay + msg: str | None + kwargs: dict[str, Any] + suspend_capture_ctx: Callable[[], AbstractContextManager[None]] + request: SubRequest + config: Config + + def __enter__(self) -> None: + __tracebackhide__ = True + + self._start = time.time() + self._precise_start = time.perf_counter() + self._exc_info = None + + self._exit_stack = ExitStack() + self._captured_output = self._exit_stack.enter_context( + capturing_output(self.request) + ) + self._captured_logs = self._exit_stack.enter_context( + capturing_logs(self.request) + ) + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> bool: + __tracebackhide__ = True + if exc_val is not None: + exc_info = ExceptionInfo.from_exception(exc_val) + else: + exc_info = None + + self._exit_stack.close() + + precise_stop = time.perf_counter() + duration = precise_stop - self._precise_start + stop = time.time() + + call_info = CallInfo[None]( + None, + exc_info, + start=self._start, + stop=stop, + duration=duration, + when="call", + _ispytest=True, + ) + report = self.ihook.pytest_runtest_makereport( + item=self.request.node, call=call_info + ) + sub_report = SubtestReport._new( + report, + SubtestContext(msg=self.msg, kwargs=self.kwargs), + captured_output=self._captured_output, + captured_logs=self._captured_logs, + ) + + if sub_report.failed: + failed_subtests = self.config.stash[failed_subtests_key] + failed_subtests[self.request.node.nodeid] += 1 + + with self.suspend_capture_ctx(): + self.ihook.pytest_runtest_logreport(report=sub_report) + + if check_interactive_exception(call_info, sub_report): + self.ihook.pytest_exception_interact( + node=self.request.node, call=call_info, report=sub_report + ) + + if exc_val is not None: + if isinstance(exc_val, get_reraise_exceptions(self.config)): + return False + if self.request.session.shouldfail: + return False + return True + + +@contextmanager +def capturing_output(request: SubRequest) -> Iterator[Captured]: + option = request.config.getoption("capture", None) + + capman = request.config.pluginmanager.getplugin("capturemanager") + if getattr(capman, "_capture_fixture", None): + # capsys or capfd are active, subtest should not capture. + fixture = None + elif option == "sys": + fixture = CaptureFixture(SysCapture, request, _ispytest=True) + elif option == "fd": + fixture = CaptureFixture(FDCapture, request, _ispytest=True) + else: + fixture = None + + if fixture is not None: + fixture._start() + + captured = Captured() + try: + yield captured + finally: + if fixture is not None: + out, err = fixture.readouterr() + fixture.close() + captured.out = out + captured.err = err + + +@contextmanager +def capturing_logs( + request: SubRequest, +) -> Iterator[CapturedLogs | None]: + logging_plugin: LoggingPlugin | None = request.config.pluginmanager.getplugin( + "logging-plugin" + ) + if logging_plugin is None: + yield None + else: + handler = LogCaptureHandler() + handler.setFormatter(logging_plugin.formatter) + + captured_logs = CapturedLogs(handler) + with catching_logs(handler, level=logging_plugin.log_level): + yield captured_logs + + +@dataclasses.dataclass +class Captured: + out: str = "" + err: str = "" + + +@dataclasses.dataclass +class CapturedLogs: + handler: LogCaptureHandler + + +def pytest_report_to_serializable(report: TestReport) -> dict[str, Any] | None: + if isinstance(report, SubtestReport): + return report._to_json() + return None + + +def pytest_report_from_serializable(data: dict[str, Any]) -> SubtestReport | None: + if data.get("_report_type") == "SubTestReport": + return SubtestReport._from_json(data) + return None + + +# Dict of nodeid -> number of failed subtests. +# Used to fail top-level tests that passed but contain failed subtests. +failed_subtests_key = StashKey[defaultdict[str, int]]() + + +def pytest_configure(config: Config) -> None: + config.stash[failed_subtests_key] = defaultdict(lambda: 0) + + +@hookimpl(tryfirst=True) +def pytest_report_teststatus( + report: TestReport, + config: Config, +) -> tuple[str, str, str | Mapping[str, bool]] | None: + if report.when != "call": + return None + + quiet = config.get_verbosity(Config.VERBOSITY_SUBTESTS) == 0 + if isinstance(report, SubtestReport): + outcome = report.outcome + description = report._sub_test_description() + + if hasattr(report, "wasxfail"): + if quiet: + return "", "", "" + elif outcome == "skipped": + category = "xfailed" + short = "y" # x letter is used for regular xfail, y for subtest xfail + status = "SUBXFAIL" + # outcome == "passed" in an xfail is only possible via a @pytest.mark.xfail mark, which + # is not applicable to a subtest, which only handles pytest.xfail(). + else: # pragma: no cover + # This should not normally happen, unless some plugin is setting wasxfail without + # the correct outcome. Pytest expects the call outcome to be either skipped or + # passed in case of xfail. + # Let's pass this report to the next hook. + return None + return category, short, f"{status}{description}" + + if report.failed: + return outcome, "u", f"SUBFAILED{description}" + else: + if report.passed: + if quiet: + return "", "", "" + else: + return f"subtests {outcome}", "u", f"SUBPASSED{description}" + elif report.skipped: + if quiet: + return "", "", "" + else: + return outcome, "-", f"SUBSKIPPED{description}" + + else: + failed_subtests_count = config.stash[failed_subtests_key][report.nodeid] + # Top-level test, fail if it contains failed subtests and it has passed. + if report.passed and failed_subtests_count > 0: + report.outcome = "failed" + suffix = "s" if failed_subtests_count > 1 else "" + report.longrepr = f"contains {failed_subtests_count} failed subtest{suffix}" + + return None diff --git a/src/_pytest/terminal.py b/src/_pytest/terminal.py index 8c722124d04..0c782bc299e 100644 --- a/src/_pytest/terminal.py +++ b/src/_pytest/terminal.py @@ -68,6 +68,9 @@ "xpassed", "warnings", "error", + "subtests passed", + "subtests failed", + "subtests skipped", ) _REPORTCHARS_DEFAULT = "fE" @@ -889,7 +892,7 @@ def _printcollecteditems(self, items: Sequence[Item]) -> None: @hookimpl(wrapper=True) def pytest_sessionfinish( self, session: Session, exitstatus: int | ExitCode - ) -> Generator[None, None, None]: + ) -> Generator[None]: result = yield self._tw.line("") summary_exit_codes = ( @@ -914,7 +917,7 @@ def pytest_sessionfinish( return result @hookimpl(wrapper=True) - def pytest_terminal_summary(self) -> Generator[None, None, None]: + def pytest_terminal_summary(self) -> Generator[None]: self.summary_errors() self.summary_failures() self.summary_xfailures() @@ -1464,8 +1467,11 @@ def _get_line_with_reprcrash_message( line_width = wcswidth(line) try: - # Type ignored intentionally -- possible AttributeError expected. - msg = rep.longrepr.reprcrash.message # type: ignore[union-attr] + if isinstance(rep.longrepr, str): + msg = rep.longrepr + else: + # Type ignored intentionally -- possible AttributeError expected. + msg = rep.longrepr.reprcrash.message # type: ignore[union-attr] except AttributeError: pass else: @@ -1516,6 +1522,8 @@ def _folded_skips( "error": "red", "warnings": "yellow", "passed": "green", + "subtests passed": "green", + "subtests failed": "red", } _color_for_type_default = "yellow" diff --git a/src/_pytest/threadexception.py b/src/_pytest/threadexception.py index d78c32c852f..c1ed80387aa 100644 --- a/src/_pytest/threadexception.py +++ b/src/_pytest/threadexception.py @@ -62,7 +62,7 @@ def __exit__( del self.args -def thread_exception_runtest_hook() -> Generator[None, None, None]: +def thread_exception_runtest_hook() -> Generator[None]: with catch_threading_exception() as cm: try: yield @@ -83,15 +83,15 @@ def thread_exception_runtest_hook() -> Generator[None, None, None]: @pytest.hookimpl(wrapper=True, trylast=True) -def pytest_runtest_setup() -> Generator[None, None, None]: +def pytest_runtest_setup() -> Generator[None]: yield from thread_exception_runtest_hook() @pytest.hookimpl(wrapper=True, tryfirst=True) -def pytest_runtest_call() -> Generator[None, None, None]: +def pytest_runtest_call() -> Generator[None]: yield from thread_exception_runtest_hook() @pytest.hookimpl(wrapper=True, tryfirst=True) -def pytest_runtest_teardown() -> Generator[None, None, None]: +def pytest_runtest_teardown() -> Generator[None]: yield from thread_exception_runtest_hook() diff --git a/src/_pytest/tmpdir.py b/src/_pytest/tmpdir.py index 91109ea69ef..1731a4b8d0d 100644 --- a/src/_pytest/tmpdir.py +++ b/src/_pytest/tmpdir.py @@ -41,9 +41,8 @@ @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: Path | None @@ -256,19 +255,12 @@ 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:`temporary directory location and retention`. - - 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 diff --git a/src/_pytest/unittest.py b/src/_pytest/unittest.py index aefea1333d9..f10541a9f51 100644 --- a/src/_pytest/unittest.py +++ b/src/_pytest/unittest.py @@ -13,10 +13,14 @@ from typing import Iterable from typing import Tuple from typing import Type +from typing import Any from typing import TYPE_CHECKING +from unittest import TestCase from typing import Union import _pytest._code +from _pytest._code import ExceptionInfo +from _pytest.compat import assert_never from _pytest.compat import is_async_function from _pytest.config import hookimpl from _pytest.fixtures import FixtureRequest @@ -30,6 +34,9 @@ from _pytest.python import Function from _pytest.python import Module from _pytest.runner import CallInfo +from _pytest.runner import check_interactive_exception +from _pytest.subtests import SubtestContext +from _pytest.subtests import SubtestReport import pytest @@ -37,6 +44,7 @@ from exceptiongroup import ExceptionGroup if TYPE_CHECKING: + from types import TracebackType import unittest import twisted.trial.unittest @@ -137,7 +145,7 @@ def process_teardown_exceptions() -> None: def unittest_setup_class_fixture( request: FixtureRequest, - ) -> Generator[None, None, None]: + ) -> Generator[None]: cls = request.cls if _is_skipped(cls): reason = cls.__unittest_skip_why__ @@ -178,7 +186,7 @@ def _register_unittest_setup_method_fixture(self, cls: type) -> None: def unittest_setup_method_fixture( request: FixtureRequest, - ) -> Generator[None, None, None]: + ) -> Generator[None]: self = request.instance if _is_skipped(self): reason = self.__unittest_skip_why__ @@ -201,6 +209,7 @@ def unittest_setup_method_fixture( class TestCaseFunction(Function): nofuncargs = True + failfast = False _excinfo: list[_pytest._code.ExceptionInfo[BaseException]] | None = None def _getinstance(self): @@ -279,11 +288,42 @@ def addFailure( ) -> None: self._addexcinfo(rawexcinfo) - def addSkip(self, testcase: unittest.TestCase, reason: str) -> None: - try: - raise pytest.skip.Exception(reason, _use_item_location=True) - except skip.Exception: - self._addexcinfo(sys.exc_info()) + def addSkip( + self, testcase: unittest.TestCase, reason: str, *, handle_subtests: bool = True + ) -> None: + from unittest.case import _SubTest # type: ignore[attr-defined] + + def add_skip() -> None: + try: + raise skip.Exception(reason, _use_item_location=True) + except skip.Exception: + self._addexcinfo(sys.exc_info()) + + if not handle_subtests: + add_skip() + return + + if isinstance(testcase, _SubTest): + add_skip() + if self._excinfo is not None: + exc_info = self._excinfo[-1] + self.addSubTest(testcase.test_case, testcase, exc_info) + else: + # For python < 3.11: the non-subtest skips have to be added by `add_skip` only after all subtest + # failures are processed by `_addSubTest`: `self.instance._outcome` has no attribute + # `skipped/errors` anymore. + # We also need to check if `self.instance._outcome` is `None` (this happens if the test + # class/method is decorated with `unittest.skip`, see pytest-dev/pytest-subtests#173). + if sys.version_info < (3, 11) and self.instance._outcome is not None: + subtest_errors = [ + x + for x, y in self.instance._outcome.errors + if isinstance(x, _SubTest) and y is not None + ] + if len(subtest_errors) == 0: + add_skip() + else: + add_skip() def addExpectedFailure( self, @@ -363,6 +403,70 @@ def _traceback_filter( ntraceback = traceback return ntraceback + def addSubTest( + self, + test_case: Any, + test: TestCase, + exc_info: ExceptionInfo[BaseException] + | tuple[type[BaseException], BaseException, TracebackType] + | None, + ) -> None: + exception_info: ExceptionInfo[BaseException] | None + match exc_info: + case tuple(): + exception_info = ExceptionInfo(exc_info, _ispytest=True) + case ExceptionInfo() | None: + exception_info = exc_info + case unreachable: + assert_never(unreachable) + + call_info = CallInfo[None]( + None, + exception_info, + start=0, + stop=0, + duration=0, + when="call", + _ispytest=True, + ) + msg = test._message if isinstance(test._message, str) else None # type: ignore[attr-defined] + report = self.ihook.pytest_runtest_makereport(item=self, call=call_info) + sub_report = SubtestReport._new( + report, + SubtestContext(msg=msg, kwargs=dict(test.params)), # type: ignore[attr-defined] + captured_output=None, + captured_logs=None, + ) + self.ihook.pytest_runtest_logreport(report=sub_report) + if check_interactive_exception(call_info, sub_report): + self.ihook.pytest_exception_interact( + node=self, call=call_info, report=sub_report + ) + + # For python < 3.11: add non-subtest skips once all subtest failures are processed by # `_addSubTest`. + if sys.version_info < (3, 11): + from unittest.case import _SubTest # type: ignore[attr-defined] + + non_subtest_skip = [ + (x, y) + for x, y in self.instance._outcome.skipped + if not isinstance(x, _SubTest) + ] + subtest_errors = [ + (x, y) + for x, y in self.instance._outcome.errors + if isinstance(x, _SubTest) and y is not None + ] + # Check if we have non-subtest skips: if there are also sub failures, non-subtest skips are not treated in + # `_addSubTest` and have to be added using `add_skip` after all subtest failures are processed. + if len(non_subtest_skip) > 0 and len(subtest_errors) > 0: + # Make sure we have processed the last subtest failure + last_subset_error = subtest_errors[-1] + if exc_info is last_subset_error[-1]: + # Add non-subtest skips (as they could not be treated in `_addSkip`) + for testcase, reason in non_subtest_skip: + self.addSkip(testcase, reason, handle_subtests=False) + @hookimpl(tryfirst=True) def pytest_runtest_makereport(item: Item, call: CallInfo[None]) -> None: diff --git a/src/_pytest/unraisableexception.py b/src/_pytest/unraisableexception.py index c191703a3de..77a2de20041 100644 --- a/src/_pytest/unraisableexception.py +++ b/src/_pytest/unraisableexception.py @@ -64,7 +64,7 @@ def __exit__( del self.unraisable -def unraisable_exception_runtest_hook() -> Generator[None, None, None]: +def unraisable_exception_runtest_hook() -> Generator[None]: with catch_unraisable_exception() as cm: try: yield @@ -86,15 +86,15 @@ def unraisable_exception_runtest_hook() -> Generator[None, None, None]: @pytest.hookimpl(wrapper=True, tryfirst=True) -def pytest_runtest_setup() -> Generator[None, None, None]: +def pytest_runtest_setup() -> Generator[None]: yield from unraisable_exception_runtest_hook() @pytest.hookimpl(wrapper=True, tryfirst=True) -def pytest_runtest_call() -> Generator[None, None, None]: +def pytest_runtest_call() -> Generator[None]: yield from unraisable_exception_runtest_hook() @pytest.hookimpl(wrapper=True, tryfirst=True) -def pytest_runtest_teardown() -> Generator[None, None, None]: +def pytest_runtest_teardown() -> Generator[None]: yield from unraisable_exception_runtest_hook() diff --git a/src/_pytest/warnings.py b/src/_pytest/warnings.py index 5c59e55c5db..eeb4772649d 100644 --- a/src/_pytest/warnings.py +++ b/src/_pytest/warnings.py @@ -30,7 +30,7 @@ def catch_warnings_for_item( ihook, when: Literal["config", "collect", "runtest"], item: Item | None, -) -> Generator[None, None, None]: +) -> 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. @@ -124,7 +124,7 @@ def pytest_collection(session: Session) -> Generator[None, object, object]: @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 @@ -133,7 +133,7 @@ def pytest_terminal_summary( @pytest.hookimpl(wrapper=True) -def pytest_sessionfinish(session: Session) -> Generator[None, None, None]: +def pytest_sessionfinish(session: Session) -> Generator[None]: config = session.config with catch_warnings_for_item( config=config, ihook=config.hook, when="config", item=None @@ -144,7 +144,7 @@ def pytest_sessionfinish(session: Session) -> Generator[None, None, None]: @pytest.hookimpl(wrapper=True) def pytest_load_initial_conftests( early_config: Config, -) -> Generator[None, None, None]: +) -> Generator[None]: with catch_warnings_for_item( config=early_config, ihook=early_config.hook, when="config", item=None ): diff --git a/src/pytest/__init__.py b/src/pytest/__init__.py index 90abcdab036..a7291582e8a 100644 --- a/src/pytest/__init__.py +++ b/src/pytest/__init__.py @@ -68,6 +68,8 @@ from _pytest.runner import CallInfo from _pytest.stash import Stash from _pytest.stash import StashKey +from _pytest.subtests import SubtestReport +from _pytest.subtests import Subtests from _pytest.terminal import TestShortLogReport from _pytest.tmpdir import TempPathFactory from _pytest.warning_types import PytestAssertRewriteWarning @@ -158,6 +160,8 @@ "skip", "Stash", "StashKey", + "SubtestReport", + "Subtests", "version_tuple", "TempdirFactory", "TempPathFactory", diff --git a/testing/acceptance_test.py b/testing/acceptance_test.py index 01d911e8ca4..64a07ba8554 100644 --- a/testing/acceptance_test.py +++ b/testing/acceptance_test.py @@ -1486,3 +1486,10 @@ def my_fixture(self, request): 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 diff --git a/testing/code/test_excinfo.py b/testing/code/test_excinfo.py index fc60ae9ac99..97c207e9795 100644 --- a/testing/code/test_excinfo.py +++ b/testing/code/test_excinfo.py @@ -1194,6 +1194,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", [ diff --git a/testing/conftest.py b/testing/conftest.py index 24e5d183094..194da0f8309 100644 --- a/testing/conftest.py +++ b/testing/conftest.py @@ -2,10 +2,13 @@ from __future__ import annotations import dataclasses +import importlib.metadata import re import sys from typing import Generator +from packaging.version import Version + from _pytest.monkeypatch import MonkeyPatch from _pytest.pytester import Pytester import pytest @@ -46,7 +49,7 @@ def reset_colors(monkeypatch: pytest.MonkeyPatch) -> None: @pytest.hookimpl(wrapper=True, tryfirst=True) -def pytest_collection_modifyitems(items) -> Generator[None, None, None]: +def pytest_collection_modifyitems(items) -> Generator[None]: """Prefer faster tests. Use a hook wrapper to do this in the beginning, so e.g. --ff still works @@ -119,8 +122,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 @@ -168,6 +171,9 @@ 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 = { @@ -180,6 +186,7 @@ class ColorMapping: "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", diff --git a/testing/io/test_terminalwriter.py b/testing/io/test_terminalwriter.py index 043c2d1d904..92cde240a11 100644 --- a/testing/io/test_terminalwriter.py +++ b/testing/io/test_terminalwriter.py @@ -67,9 +67,7 @@ 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]: if request.param == "path": p = tmp_path.joinpath("tmpfile") f = open(str(p), "w+", encoding="utf8") diff --git a/testing/python/approx.py b/testing/python/approx.py index 69743cdbe17..cb1704f6ec4 100644 --- a/testing/python/approx.py +++ b/testing/python/approx.py @@ -90,12 +90,25 @@ 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*" 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, @@ -590,6 +603,13 @@ 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_list(self): actual = [1 + 1e-7, 2 + 1e-8] expected = [1, 2] @@ -655,6 +675,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}) diff --git a/testing/python/collect.py b/testing/python/collect.py index 06386611279..530f1c340ff 100644 --- a/testing/python/collect.py +++ b/testing/python/collect.py @@ -263,6 +263,43 @@ 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( diff --git a/testing/python/fixtures.py b/testing/python/fixtures.py index 8d2646309a8..c939b221f22 100644 --- a/testing/python/fixtures.py +++ b/testing/python/fixtures.py @@ -2996,7 +2996,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( @@ -4338,7 +4338,7 @@ def test_func(self, f2, f1, m2): assert request.fixturenames == "s1 p1 m1 m2 c1 f2 f1".split() def test_parametrized_package_scope_reordering(self, pytester: Pytester) -> None: - """A paramaterized package-scoped fixture correctly reorders items to + """A parameterized package-scoped fixture correctly reorders items to minimize setups & teardowns. Regression test for #12328. diff --git a/testing/test_assertion.py b/testing/test_assertion.py index 69ca0f73ff2..31192df0f6f 100644 --- a/testing/test_assertion.py +++ b/testing/test_assertion.py @@ -823,7 +823,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])) diff --git a/testing/test_assertrewrite.py b/testing/test_assertrewrite.py index 5ee40ee6568..7be473d897a 100644 --- a/testing/test_assertrewrite.py +++ b/testing/test_assertrewrite.py @@ -2,10 +2,12 @@ from __future__ import annotations import ast +import dis import errno from functools import partial import glob import importlib +import inspect import marshal import os from pathlib import Path @@ -131,10 +133,211 @@ def test_location_is_set(self) -> None: continue for n in [node, *ast.iter_child_nodes(node)]: assert isinstance(n, (ast.stmt, ast.expr)) - assert n.lineno == 3 - assert n.col_offset == 0 - assert n.end_lineno == 6 - assert n.end_col_offset == 3 + 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""" @@ -341,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 @@ -1632,7 +1863,7 @@ 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. """ diff --git a/testing/test_cacheprovider.py b/testing/test_cacheprovider.py index 72b4265cf75..94bc55d3047 100644 --- a/testing/test_cacheprovider.py +++ b/testing/test_cacheprovider.py @@ -69,7 +69,7 @@ def test_cache_writefail_cachefile_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 diff --git a/testing/test_capture.py b/testing/test_capture.py index fe6bd7d14fa..adf1c3b2657 100644 --- a/testing/test_capture.py +++ b/testing/test_capture.py @@ -5,6 +5,7 @@ import io from io import UnsupportedOperation import os +import re import subprocess import sys import textwrap @@ -939,7 +940,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: @@ -1666,3 +1667,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_collection.py b/testing/test_collection.py index f5822240335..aba8f8ea48d 100644 --- a/testing/test_collection.py +++ b/testing/test_collection.py @@ -3,6 +3,7 @@ import os from pathlib import Path +from pathlib import PurePath import pprint import shutil import sys @@ -152,8 +153,17 @@ def test_ignored_certain_directories(self, pytester: Pytester) -> None: assert "test_notfound" not in s assert "test_found" in s - def test_ignored_virtualenvs(self, pytester: Pytester) -> None: - ensure_file(pytester.path / "virtual" / "pyvenv.cfg") + known_environment_types = pytest.mark.parametrize( + "env_path", + [ + pytest.param(PurePath("pyvenv.cfg"), id="venv"), + pytest.param(PurePath("conda-meta", "history"), id="conda"), + ], + ) + + @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") @@ -167,11 +177,12 @@ def test_ignored_virtualenvs(self, pytester: Pytester) -> None: result = pytester.runpytest("virtual") assert "test_invenv" in result.stdout.str() + @known_environment_types def test_ignored_virtualenvs_norecursedirs_precedence( - self, pytester: Pytester + self, pytester: Pytester, env_path ) -> None: # norecursedirs takes priority - ensure_file(pytester.path / ".virtual" / "pyvenv.cfg") + 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") @@ -180,13 +191,14 @@ def test_ignored_virtualenvs_norecursedirs_precedence( result = pytester.runpytest("--collect-in-virtualenv", ".virtual") assert "test_invenv" in result.stdout.str() - def test__in_venv(self, pytester: Pytester) -> None: + @known_environment_types + def test__in_venv(self, pytester: Pytester, env_path: PurePath) -> None: """Directly test the virtual env detection function""" - # no pyvenv.cfg, not a virtualenv + # no env path, not a env base_path = pytester.mkdir("venv") assert _in_venv(base_path) is False - # with pyvenv.cfg, totally a virtualenv - base_path.joinpath("pyvenv.cfg").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: diff --git a/testing/test_config.py b/testing/test_config.py index 232839399e2..bdeaed14721 100644 --- a/testing/test_config.py +++ b/testing/test_config.py @@ -636,7 +636,7 @@ def test_config_trace(self, pytester: Pytester) -> None: 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): @@ -648,6 +648,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( """ @@ -675,12 +687,6 @@ 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") @@ -917,7 +923,7 @@ def pytest_addoption(parser): # default for string is "" value = config.getini("string1") assert value == "" - # should return None if None is explicity set as default 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 diff --git a/testing/test_conftest.py b/testing/test_conftest.py index d51846f2f5f..ea60c1909c2 100644 --- a/testing/test_conftest.py +++ b/testing/test_conftest.py @@ -44,9 +44,7 @@ def conftest_setinitial( @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( diff --git a/testing/test_debugging.py b/testing/test_debugging.py index 37032f92354..73a4b769ff7 100644 --- a/testing/test_debugging.py +++ b/testing/test_debugging.py @@ -768,9 +768,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) @@ -785,9 +789,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) diff --git a/testing/test_monkeypatch.py b/testing/test_monkeypatch.py index 079d8ff60ad..7c62d90f2b9 100644 --- a/testing/test_monkeypatch.py +++ b/testing/test_monkeypatch.py @@ -14,7 +14,7 @@ @pytest.fixture -def mp() -> Generator[MonkeyPatch, None, None]: +def mp() -> Generator[MonkeyPatch]: cwd = os.getcwd() sys_path = list(sys.path) yield MonkeyPatch() @@ -415,7 +415,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: diff --git a/testing/test_pathlib.py b/testing/test_pathlib.py index 81aba25f78f..dbf76887859 100644 --- a/testing/test_pathlib.py +++ b/testing/test_pathlib.py @@ -17,7 +17,9 @@ from typing import Sequence import unittest.mock +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 @@ -36,6 +38,8 @@ 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 @@ -125,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 @@ -416,7 +420,7 @@ def test_no_meta_path_found( 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( @@ -566,6 +570,29 @@ def test_samefile_false_negatives(tmp_path: Path, monkeypatch: MonkeyPatch) -> N 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, ns_param: bool @@ -780,6 +807,62 @@ def test_insert_missing_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 ): @@ -863,6 +946,37 @@ def test_my_test(): 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]: @@ -1372,6 +1486,70 @@ def test_resolve_pkg_root_and_module_name_ns_multiple_levels( ) 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, @@ -1506,6 +1684,19 @@ def test_full_ns_packages_without_init_files( ) == (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() diff --git a/testing/test_runner.py b/testing/test_runner.py index 1b59ff78633..0d9facdcd71 100644 --- a/testing/test_runner.py +++ b/testing/test_runner.py @@ -1030,7 +1030,7 @@ def runtest(self): 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) + assert isinstance(sys.last_exc, IndexError) # type:ignore[attr-defined] assert sys.last_value.args[0] == "TEST" assert sys.last_traceback diff --git a/testing/test_subtests.py b/testing/test_subtests.py new file mode 100644 index 00000000000..13fc4a27391 --- /dev/null +++ b/testing/test_subtests.py @@ -0,0 +1,1014 @@ +from __future__ import annotations + +from enum import Enum +import json +import sys +from typing import Literal + +from _pytest._io.saferepr import saferepr +from _pytest.subtests import SubtestContext +from _pytest.subtests import SubtestReport +import pytest + + +IS_PY311 = sys.version_info[:2] >= (3, 11) + + +def test_failures(pytester: pytest.Pytester, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("COLUMNS", "120") + pytester.makepyfile( + """ + def test_foo(subtests): + with subtests.test("foo subtest"): + assert False, "foo subtest failure" + + def test_bar(subtests): + with subtests.test("bar subtest"): + assert False, "bar subtest failure" + assert False, "test_bar also failed" + + def test_zaz(subtests): + with subtests.test("zaz subtest"): + pass + """ + ) + summary_lines = [ + "*=== FAILURES ===*", + # + "*___ test_foo [[]foo subtest[]] ___*", + "*AssertionError: foo subtest failure", + # + "*___ test_foo ___*", + "contains 1 failed subtest", + # + "*___ test_bar [[]bar subtest[]] ___*", + "*AssertionError: bar subtest failure", + # + "*___ test_bar ___*", + "*AssertionError: test_bar also failed", + # + "*=== short test summary info ===*", + "SUBFAILED[[]foo subtest[]] test_*.py::test_foo - AssertionError*", + "FAILED test_*.py::test_foo - contains 1 failed subtest", + "SUBFAILED[[]bar subtest[]] test_*.py::test_bar - AssertionError*", + "FAILED test_*.py::test_bar - AssertionError*", + ] + result = pytester.runpytest() + result.stdout.fnmatch_lines( + [ + "test_*.py uFuF. * [[]100%[]]", + *summary_lines, + "* 4 failed, 1 passed in *", + ] + ) + + result = pytester.runpytest("-v") + result.stdout.fnmatch_lines( + [ + "test_*.py::test_foo SUBFAILED[[]foo subtest[]] * [[] 33%[]]", + "test_*.py::test_foo FAILED * [[] 33%[]]", + "test_*.py::test_bar SUBFAILED[[]bar subtest[]] * [[] 66%[]]", + "test_*.py::test_bar FAILED * [[] 66%[]]", + "test_*.py::test_zaz SUBPASSED[[]zaz subtest[]] * [[]100%[]]", + "test_*.py::test_zaz PASSED * [[]100%[]]", + *summary_lines, + "* 4 failed, 1 passed, 1 subtests passed in *", + ] + ) + pytester.makeini( + """ + [pytest] + verbosity_subtests = 0 + """ + ) + result = pytester.runpytest("-v") + result.stdout.fnmatch_lines( + [ + "test_*.py::test_foo SUBFAILED[[]foo subtest[]] * [[] 33%[]]", + "test_*.py::test_foo FAILED * [[] 33%[]]", + "test_*.py::test_bar SUBFAILED[[]bar subtest[]] * [[] 66%[]]", + "test_*.py::test_bar FAILED * [[] 66%[]]", + "test_*.py::test_zaz PASSED * [[]100%[]]", + *summary_lines, + "* 4 failed, 1 passed in *", + ] + ) + result.stdout.no_fnmatch_line("test_*.py::test_zaz SUBPASSED[[]zaz subtest[]]*") + + +def test_passes(pytester: pytest.Pytester, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("COLUMNS", "120") + pytester.makepyfile( + """ + def test_foo(subtests): + with subtests.test("foo subtest"): + pass + + def test_bar(subtests): + with subtests.test("bar subtest"): + pass + """ + ) + result = pytester.runpytest() + result.stdout.fnmatch_lines( + [ + "test_*.py .. * [[]100%[]]", + "* 2 passed in *", + ] + ) + + result = pytester.runpytest("-v") + result.stdout.fnmatch_lines( + [ + "*.py::test_foo SUBPASSED[[]foo subtest[]] * [[] 50%[]]", + "*.py::test_foo PASSED * [[] 50%[]]", + "*.py::test_bar SUBPASSED[[]bar subtest[]] * [[]100%[]]", + "*.py::test_bar PASSED * [[]100%[]]", + "* 2 passed, 2 subtests passed in *", + ] + ) + + pytester.makeini( + """ + [pytest] + verbosity_subtests = 0 + """ + ) + result = pytester.runpytest("-v") + result.stdout.fnmatch_lines( + [ + "*.py::test_foo PASSED * [[] 50%[]]", + "*.py::test_bar PASSED * [[]100%[]]", + "* 2 passed in *", + ] + ) + result.stdout.no_fnmatch_line("*.py::test_foo SUBPASSED[[]foo subtest[]]*") + result.stdout.no_fnmatch_line("*.py::test_bar SUBPASSED[[]bar subtest[]]*") + + +def test_skip(pytester: pytest.Pytester, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("COLUMNS", "120") + pytester.makepyfile( + """ + import pytest + def test_foo(subtests): + with subtests.test("foo subtest"): + pytest.skip("skip foo subtest") + + def test_bar(subtests): + with subtests.test("bar subtest"): + pytest.skip("skip bar subtest") + pytest.skip("skip test_bar") + """ + ) + result = pytester.runpytest("-ra") + result.stdout.fnmatch_lines( + [ + "test_*.py .s * [[]100%[]]", + "*=== short test summary info ===*", + "SKIPPED [[]1[]] test_skip.py:9: skip test_bar", + "* 1 passed, 1 skipped in *", + ] + ) + + result = pytester.runpytest("-v", "-ra") + result.stdout.fnmatch_lines( + [ + "*.py::test_foo SUBSKIPPED[[]foo subtest[]] (skip foo subtest) * [[] 50%[]]", + "*.py::test_foo PASSED * [[] 50%[]]", + "*.py::test_bar SUBSKIPPED[[]bar subtest[]] (skip bar subtest) * [[]100%[]]", + "*.py::test_bar SKIPPED (skip test_bar) * [[]100%[]]", + "*=== short test summary info ===*", + "SUBSKIPPED[[]foo subtest[]] [[]1[]] *.py:*: skip foo subtest", + "SUBSKIPPED[[]foo subtest[]] [[]1[]] *.py:*: skip bar subtest", + "SUBSKIPPED[[]foo subtest[]] [[]1[]] *.py:*: skip test_bar", + "* 1 passed, 3 skipped in *", + ] + ) + + pytester.makeini( + """ + [pytest] + verbosity_subtests = 0 + """ + ) + result = pytester.runpytest("-v", "-ra") + result.stdout.fnmatch_lines( + [ + "*.py::test_foo PASSED * [[] 50%[]]", + "*.py::test_bar SKIPPED (skip test_bar) * [[]100%[]]", + "*=== short test summary info ===*", + "* 1 passed, 1 skipped in *", + ] + ) + result.stdout.no_fnmatch_line("*.py::test_foo SUBPASSED[[]foo subtest[]]*") + result.stdout.no_fnmatch_line("*.py::test_bar SUBPASSED[[]bar subtest[]]*") + result.stdout.no_fnmatch_line( + "SUBSKIPPED[[]foo subtest[]] [[]1[]] *.py:*: skip foo subtest" + ) + result.stdout.no_fnmatch_line( + "SUBSKIPPED[[]foo subtest[]] [[]1[]] *.py:*: skip test_bar" + ) + + +def test_xfail(pytester: pytest.Pytester, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("COLUMNS", "120") + pytester.makepyfile( + """ + import pytest + def test_foo(subtests): + with subtests.test("foo subtest"): + pytest.xfail("xfail foo subtest") + + def test_bar(subtests): + with subtests.test("bar subtest"): + pytest.xfail("xfail bar subtest") + pytest.xfail("xfail test_bar") + """ + ) + result = pytester.runpytest("-ra") + result.stdout.fnmatch_lines( + [ + "test_*.py .x * [[]100%[]]", + "*=== short test summary info ===*", + "* 1 passed, 1 xfailed in *", + ] + ) + + result = pytester.runpytest("-v", "-ra") + result.stdout.fnmatch_lines( + [ + "*.py::test_foo SUBXFAIL[[]foo subtest[]] (xfail foo subtest) * [[] 50%[]]", + "*.py::test_foo PASSED * [[] 50%[]]", + "*.py::test_bar SUBXFAIL[[]bar subtest[]] (xfail bar subtest) * [[]100%[]]", + "*.py::test_bar XFAIL (xfail test_bar) * [[]100%[]]", + "*=== short test summary info ===*", + "SUBXFAIL[[]foo subtest[]] *.py::test_foo - xfail foo subtest", + "SUBXFAIL[[]bar subtest[]] *.py::test_bar - xfail bar subtest", + "XFAIL *.py::test_bar - xfail test_bar", + "* 1 passed, 3 xfailed in *", + ] + ) + + pytester.makeini( + """ + [pytest] + verbosity_subtests = 0 + """ + ) + result = pytester.runpytest("-v", "-ra") + result.stdout.fnmatch_lines( + [ + "*.py::test_foo PASSED * [[] 50%[]]", + "*.py::test_bar XFAIL (xfail test_bar) * [[]100%[]]", + "*=== short test summary info ===*", + "* 1 passed, 1 xfailed in *", + ] + ) + result.stdout.no_fnmatch_line( + "SUBXFAIL[[]foo subtest[]] *.py::test_foo - xfail foo subtest" + ) + result.stdout.no_fnmatch_line( + "SUBXFAIL[[]bar subtest[]] *.py::test_bar - xfail bar subtest" + ) + + +def test_typing_exported(pytester: pytest.Pytester) -> None: + pytester.makepyfile( + """ + from pytest import Subtests + + def test_typing_exported(subtests: Subtests) -> None: + assert isinstance(subtests, Subtests) + """ + ) + result = pytester.runpytest() + result.stdout.fnmatch_lines(["*1 passed*"]) + + +def test_subtests_and_parametrization( + pytester: pytest.Pytester, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("COLUMNS", "120") + pytester.makepyfile( + """ + import pytest + + @pytest.mark.parametrize("x", [0, 1]) + def test_foo(subtests, x): + for i in range(3): + with subtests.test("custom", i=i): + assert i % 2 == 0 + assert x == 0 + """ + ) + result = pytester.runpytest("-v") + result.stdout.fnmatch_lines( + [ + "*.py::test_foo[[]0[]] SUBFAILED[[]custom[]] (i='1') *[[] 50%[]]", + "*.py::test_foo[[]0[]] FAILED *[[] 50%[]]", + "*.py::test_foo[[]1[]] SUBFAILED[[]custom[]] (i='1') *[[]100%[]]", + "*.py::test_foo[[]1[]] FAILED *[[]100%[]]", + "contains 1 failed subtest", + "* 4 failed, 4 subtests passed in *", + ] + ) + + pytester.makeini( + """ + [pytest] + verbosity_subtests = 0 + """ + ) + result = pytester.runpytest("-v") + result.stdout.fnmatch_lines( + [ + "*.py::test_foo[[]0[]] SUBFAILED[[]custom[]] (i='1') *[[] 50%[]]", + "*.py::test_foo[[]0[]] FAILED *[[] 50%[]]", + "*.py::test_foo[[]1[]] SUBFAILED[[]custom[]] (i='1') *[[]100%[]]", + "*.py::test_foo[[]1[]] FAILED *[[]100%[]]", + "contains 1 failed subtest", + "* 4 failed in *", + ] + ) + + +def test_subtests_fail_top_level_test(pytester: pytest.Pytester) -> None: + pytester.makepyfile( + """ + import pytest + + def test_foo(subtests): + for i in range(3): + with subtests.test("custom", i=i): + assert i % 2 == 0 + """ + ) + result = pytester.runpytest("-v") + result.stdout.fnmatch_lines( + [ + "* 2 failed, 2 subtests passed in *", + ] + ) + + +def test_subtests_do_not_overwrite_top_level_failure(pytester: pytest.Pytester) -> None: + pytester.makepyfile( + """ + import pytest + + def test_foo(subtests): + for i in range(3): + with subtests.test("custom", i=i): + assert i % 2 == 0 + assert False, "top-level failure" + """ + ) + result = pytester.runpytest("-v") + result.stdout.fnmatch_lines( + [ + "*AssertionError: top-level failure", + "* 2 failed, 2 subtests passed in *", + ] + ) + + +@pytest.mark.parametrize("flag", ["--last-failed", "--stepwise"]) +def test_subtests_last_failed_step_wise(pytester: pytest.Pytester, flag: str) -> None: + """Check that --last-failed and --step-wise correctly rerun tests with failed subtests.""" + pytester.makepyfile( + """ + import pytest + + def test_foo(subtests): + for i in range(3): + with subtests.test("custom", i=i): + assert i % 2 == 0 + """ + ) + result = pytester.runpytest("-v") + result.stdout.fnmatch_lines( + [ + "* 2 failed, 2 subtests passed in *", + ] + ) + + result = pytester.runpytest("-v", flag) + result.stdout.fnmatch_lines( + [ + "* 2 failed, 2 subtests passed in *", + ] + ) + + +class TestUnittestSubTest: + """Test unittest.TestCase.subTest functionality.""" + + def test_failures( + self, pytester: pytest.Pytester, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("COLUMNS", "120") + pytester.makepyfile( + """ + from unittest import TestCase + + class T(TestCase): + def test_foo(self): + with self.subTest("foo subtest"): + assert False, "foo subtest failure" + + def test_bar(self): + with self.subTest("bar subtest"): + assert False, "bar subtest failure" + assert False, "test_bar also failed" + + def test_zaz(self): + with self.subTest("zaz subtest"): + pass + """ + ) + result = pytester.runpytest() + result.stdout.fnmatch_lines( + [ + "* 3 failed, 2 passed in *", + ] + ) + + def test_passes( + self, pytester: pytest.Pytester, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("COLUMNS", "120") + pytester.makepyfile( + """ + from unittest import TestCase + + class T(TestCase): + def test_foo(self): + with self.subTest("foo subtest"): + pass + + def test_bar(self): + with self.subTest("bar subtest"): + pass + + def test_zaz(self): + with self.subTest("zaz subtest"): + pass + """ + ) + result = pytester.runpytest() + result.stdout.fnmatch_lines( + [ + "* 3 passed in *", + ] + ) + + def test_skip( + self, + pytester: pytest.Pytester, + ) -> None: + pytester.makepyfile( + """ + from unittest import TestCase, main + + class T(TestCase): + + def test_foo(self): + for i in range(5): + with self.subTest(msg="custom", i=i): + if i % 2 == 0: + self.skipTest('even number') + """ + ) + # This output might change #13756. + result = pytester.runpytest() + result.stdout.fnmatch_lines(["* 1 passed in *"]) + + def test_non_subtest_skip( + self, pytester: pytest.Pytester, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("COLUMNS", "120") + pytester.makepyfile( + """ + from unittest import TestCase, main + + class T(TestCase): + + def test_foo(self): + with self.subTest(msg="subtest"): + assert False, "failed subtest" + self.skipTest('non-subtest skip') + """ + ) + # This output might change #13756. + result = pytester.runpytest() + result.stdout.fnmatch_lines( + [ + "SUBFAILED[[]subtest[]] test_non_subtest_skip.py::T::test_foo*", + "* 1 failed, 1 skipped in *", + ] + ) + + def test_xfail( + self, + pytester: pytest.Pytester, + ) -> None: + pytester.makepyfile( + """ + import pytest + from unittest import expectedFailure, TestCase + + class T(TestCase): + @expectedFailure + def test_foo(self): + for i in range(5): + with self.subTest(msg="custom", i=i): + if i % 2 == 0: + raise pytest.xfail('even number') + + if __name__ == '__main__': + main() + """ + ) + # This output might change #13756. + result = pytester.runpytest() + result.stdout.fnmatch_lines(["* 1 xfailed in *"]) + + def test_only_original_skip_is_called( + self, + pytester: pytest.Pytester, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Regression test for pytest-dev/pytest-subtests#173.""" + monkeypatch.setenv("COLUMNS", "120") + pytester.makepyfile( + """ + import unittest + from unittest import TestCase + + @unittest.skip("skip this test") + class T(unittest.TestCase): + def test_foo(self): + assert 1 == 2 + """ + ) + result = pytester.runpytest("-v", "-rsf") + result.stdout.fnmatch_lines( + ["SKIPPED [1] test_only_original_skip_is_called.py:6: skip this test"] + ) + + def test_skip_with_failure( + self, + pytester: pytest.Pytester, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setenv("COLUMNS", "120") + pytester.makepyfile( + """ + import pytest + from unittest import TestCase + + class T(TestCase): + def test_foo(self): + with self.subTest("subtest 1"): + self.skipTest(f"skip subtest 1") + with self.subTest("subtest 2"): + assert False, "fail subtest 2" + """ + ) + + result = pytester.runpytest("-ra") + result.stdout.fnmatch_lines( + [ + "*.py u. * [[]100%[]]", + "*=== short test summary info ===*", + "SUBFAILED[[]subtest 2[]] *.py::T::test_foo - AssertionError: fail subtest 2", + "* 1 failed, 1 passed in *", + ] + ) + + result = pytester.runpytest("-v", "-ra") + result.stdout.fnmatch_lines( + [ + "*.py::T::test_foo SUBSKIPPED[[]subtest 1[]] (skip subtest 1) * [[]100%[]]", + "*.py::T::test_foo SUBFAILED[[]subtest 2[]] * [[]100%[]]", + "*.py::T::test_foo PASSED * [[]100%[]]", + "SUBSKIPPED[[]subtest 1[]] [[]1[]] *.py:*: skip subtest 1", + "SUBFAILED[[]subtest 2[]] *.py::T::test_foo - AssertionError: fail subtest 2", + "* 1 failed, 1 passed, 1 skipped in *", + ] + ) + + pytester.makeini( + """ + [pytest] + verbosity_subtests = 0 + """ + ) + result = pytester.runpytest("-v", "-ra") + result.stdout.fnmatch_lines( + [ + "*.py::T::test_foo SUBFAILED[[]subtest 2[]] * [[]100%[]]", + "*.py::T::test_foo PASSED * [[]100%[]]", + "*=== short test summary info ===*", + r"SUBFAILED[[]subtest 2[]] *.py::T::test_foo - AssertionError: fail subtest 2", + r"* 1 failed, 1 passed in *", + ] + ) + result.stdout.no_fnmatch_line( + "*.py::T::test_foo SUBSKIPPED[[]subtest 1[]] (skip subtest 1) * [[]100%[]]" + ) + result.stdout.no_fnmatch_line( + "SUBSKIPPED[[]subtest 1[]] [[]1[]] *.py:*: skip subtest 1" + ) + + +class TestCapture: + def create_file(self, pytester: pytest.Pytester) -> None: + pytester.makepyfile( + """ + import sys + def test(subtests): + print() + print('start test') + + with subtests.test(i='A'): + print("hello stdout A") + print("hello stderr A", file=sys.stderr) + assert 0 + + with subtests.test(i='B'): + print("hello stdout B") + print("hello stderr B", file=sys.stderr) + assert 0 + + print('end test') + assert 0 + """ + ) + + @pytest.mark.parametrize("mode", ["fd", "sys"]) + def test_capturing(self, pytester: pytest.Pytester, mode: str) -> None: + self.create_file(pytester) + result = pytester.runpytest(f"--capture={mode}") + result.stdout.fnmatch_lines( + [ + "*__ test (i=\"'A'\") __*", + "*Captured stdout call*", + "hello stdout A", + "*Captured stderr call*", + "hello stderr A", + "*__ test (i=\"'B'\") __*", + "*Captured stdout call*", + "hello stdout B", + "*Captured stderr call*", + "hello stderr B", + "*__ test __*", + "*Captured stdout call*", + "start test", + "end test", + ] + ) + + def test_no_capture(self, pytester: pytest.Pytester) -> None: + self.create_file(pytester) + result = pytester.runpytest("-s") + result.stdout.fnmatch_lines( + [ + "start test", + "hello stdout A", + "uhello stdout B", + "uend test", + "*__ test (i=\"'A'\") __*", + "*__ test (i=\"'B'\") __*", + "*__ test __*", + ] + ) + result.stderr.fnmatch_lines(["hello stderr A", "hello stderr B"]) + + @pytest.mark.parametrize("fixture", ["capsys", "capfd"]) + def test_capture_with_fixture( + self, pytester: pytest.Pytester, fixture: Literal["capsys", "capfd"] + ) -> None: + pytester.makepyfile( + rf""" + import sys + + def test(subtests, {fixture}): + print('start test') + + with subtests.test(i='A'): + print("hello stdout A") + print("hello stderr A", file=sys.stderr) + + out, err = {fixture}.readouterr() + assert out == 'start test\nhello stdout A\n' + assert err == 'hello stderr A\n' + """ + ) + result = pytester.runpytest() + result.stdout.fnmatch_lines( + [ + "*1 passed*", + ] + ) + + +class TestLogging: + def create_file(self, pytester: pytest.Pytester) -> None: + pytester.makepyfile( + """ + import logging + + def test_foo(subtests): + logging.info("before") + + with subtests.test("sub1"): + print("sub1 stdout") + logging.info("sub1 logging") + logging.debug("sub1 logging debug") + + with subtests.test("sub2"): + print("sub2 stdout") + logging.info("sub2 logging") + logging.debug("sub2 logging debug") + assert False + """ + ) + + def test_capturing_info(self, pytester: pytest.Pytester) -> None: + self.create_file(pytester) + result = pytester.runpytest("--log-level=INFO") + result.stdout.fnmatch_lines( + [ + "*___ test_foo [[]sub2[]] __*", + "*-- Captured stdout call --*", + "sub2 stdout", + "*-- Captured log call ---*", + "INFO * before", + "INFO * sub1 logging", + "INFO * sub2 logging", + "*== short test summary info ==*", + ] + ) + result.stdout.no_fnmatch_line("sub1 logging debug") + result.stdout.no_fnmatch_line("sub2 logging debug") + + def test_capturing_debug(self, pytester: pytest.Pytester) -> None: + self.create_file(pytester) + result = pytester.runpytest("--log-level=DEBUG") + result.stdout.fnmatch_lines( + [ + "*___ test_foo [[]sub2[]] __*", + "*-- Captured stdout call --*", + "sub2 stdout", + "*-- Captured log call ---*", + "INFO * before", + "INFO * sub1 logging", + "DEBUG * sub1 logging debug", + "INFO * sub2 logging", + "DEBUG * sub2 logging debug", + "*== short test summary info ==*", + ] + ) + + def test_caplog(self, pytester: pytest.Pytester) -> None: + pytester.makepyfile( + """ + import logging + + def test(subtests, caplog): + caplog.set_level(logging.INFO) + logging.info("start test") + + with subtests.test("sub1"): + logging.info("inside %s", "subtest1") + + assert len(caplog.records) == 2 + assert caplog.records[0].getMessage() == "start test" + assert caplog.records[1].getMessage() == "inside subtest1" + """ + ) + result = pytester.runpytest() + result.stdout.fnmatch_lines( + [ + "*1 passed*", + ] + ) + + def test_no_logging(self, pytester: pytest.Pytester) -> None: + pytester.makepyfile( + """ + import logging + + def test(subtests): + logging.info("start log line") + + with subtests.test("sub passing"): + logging.info("inside %s", "passing log line") + + with subtests.test("sub failing"): + logging.info("inside %s", "failing log line") + assert False + + logging.info("end log line") + """ + ) + result = pytester.runpytest("-p no:logging") + result.stdout.fnmatch_lines( + [ + "*2 failed in*", + ] + ) + result.stdout.no_fnmatch_line("*root:test_no_logging.py*log line*") + + +class TestDebugging: + """Check --pdb support for subtests fixture and TestCase.subTest.""" + + class _FakePdb: + """Fake debugger class implementation that tracks which methods were called on it.""" + + quitting: bool = False + calls: list[str] = [] + + def __init__(self, *_: object, **__: object) -> None: + self.calls.append("init") + + def reset(self) -> None: + self.calls.append("reset") + + def interaction(self, *_: object) -> None: + self.calls.append("interaction") + + @pytest.fixture(autouse=True) + def cleanup_calls(self) -> None: + self._FakePdb.calls.clear() + + def test_pdb_fixture( + self, pytester: pytest.Pytester, monkeypatch: pytest.MonkeyPatch + ) -> None: + pytester.makepyfile( + """ + def test(subtests): + with subtests.test(): + assert 0 + """ + ) + self.runpytest_and_check_pdb(pytester, monkeypatch) + + def test_pdb_unittest( + self, pytester: pytest.Pytester, monkeypatch: pytest.MonkeyPatch + ) -> None: + pytester.makepyfile( + """ + from unittest import TestCase + class Test(TestCase): + def test(self): + with self.subTest(): + assert 0 + """ + ) + self.runpytest_and_check_pdb(pytester, monkeypatch) + + def runpytest_and_check_pdb( + self, pytester: pytest.Pytester, monkeypatch: pytest.MonkeyPatch + ) -> None: + # Install the fake pdb implementation in _pytest.subtests so we can reference + # it in the command line (any module would do). + import _pytest.subtests + + monkeypatch.setattr( + _pytest.subtests, "_CustomPdb", self._FakePdb, raising=False + ) + result = pytester.runpytest("--pdb", "--pdbcls=_pytest.subtests:_CustomPdb") + + # Ensure pytest entered in debugging mode when encountering the failing + # assert. + result.stdout.fnmatch_lines("*entering PDB*") + assert self._FakePdb.calls == ["init", "reset", "interaction"] + + +def test_exitfirst(pytester: pytest.Pytester) -> None: + """Validate that when passing --exitfirst the test exits after the first failed subtest.""" + pytester.makepyfile( + """ + def test_foo(subtests): + with subtests.test("sub1"): + assert False + + with subtests.test("sub2"): + assert False + """ + ) + result = pytester.runpytest("--exitfirst") + assert result.parseoutcomes()["failed"] == 2 + result.stdout.fnmatch_lines( + [ + "SUBFAILED*[[]sub1[]] *.py::test_foo - assert False*", + "FAILED *.py::test_foo - assert False", + "* stopping after 2 failures*", + ], + consecutive=True, + ) + result.stdout.no_fnmatch_line("*sub2*") # sub2 not executed. + + +def test_do_not_swallow_pytest_exit(pytester: pytest.Pytester) -> None: + pytester.makepyfile( + """ + import pytest + def test(subtests): + with subtests.test(): + pytest.exit() + + def test2(): pass + """ + ) + result = pytester.runpytest_subprocess() + result.stdout.fnmatch_lines( + [ + "* _pytest.outcomes.Exit *", + "* 1 failed in *", + ] + ) + + +def test_nested(pytester: pytest.Pytester) -> None: + """ + Currently we do nothing special with nested subtests. + + This test only sediments how they work now, we might reconsider adding some kind of nesting support in the future. + """ + pytester.makepyfile( + """ + import pytest + def test(subtests): + with subtests.test("a"): + with subtests.test("b"): + assert False, "b failed" + assert False, "a failed" + """ + ) + result = pytester.runpytest_subprocess() + result.stdout.fnmatch_lines( + [ + "SUBFAILED[b] test_nested.py::test - AssertionError: b failed", + "SUBFAILED[a] test_nested.py::test - AssertionError: a failed", + "* 3 failed in *", + ] + ) + + +class MyEnum(Enum): + """Used in test_serialization, needs to be declared at the module level to be pickled.""" + + A = "A" + + +def test_serialization() -> None: + """Ensure subtest's kwargs are serialized using `saferepr` (pytest-dev/pytest-xdist#1273).""" + from _pytest.subtests import pytest_report_from_serializable + from _pytest.subtests import pytest_report_to_serializable + + report = SubtestReport( + "test_foo::test_foo", + ("test_foo.py", 12, ""), + keywords={}, + outcome="passed", + when="call", + longrepr=None, + context=SubtestContext(msg="custom message", kwargs=dict(i=10, a=MyEnum.A)), + ) + data = pytest_report_to_serializable(report) + assert data is not None + # Ensure the report is actually serializable to JSON. + _ = json.dumps(data) + new_report = pytest_report_from_serializable(data) + assert new_report is not None + assert new_report.context == SubtestContext( + msg="custom message", kwargs=dict(i=saferepr(10), a=saferepr(MyEnum.A)) + ) + + +def test_serialization_xdist(pytester: pytest.Pytester) -> None: # pragma: no cover + """Regression test for pytest-dev/pytest-xdist#1273.""" + pytest.importorskip("xdist") + pytester.makepyfile( + """ + from enum import Enum + import unittest + class MyEnum(Enum): + A = "A" + def test(subtests): + with subtests.test(a=MyEnum.A): + pass + class T(unittest.TestCase): + def test(self): + with self.subTest(a=MyEnum.A): + pass + """ + ) + pytester.syspathinsert() + result = pytester.runpytest("-n1", "-pxdist.plugin") + result.assert_outcomes(passed=2) diff --git a/testing/test_terminal.py b/testing/test_terminal.py index 11ad623fb6b..14125560d5a 100644 --- a/testing/test_terminal.py +++ b/testing/test_terminal.py @@ -1299,13 +1299,13 @@ def test_this(): "=*= FAILURES =*=", "{red}{bold}_*_ test_this _*_{reset}", "", - " {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: ", "_ _ * _ _*", "", - " {reset}{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}", "", @@ -2525,6 +2525,35 @@ def test(): ) +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)))}*", + ] + ) + + @pytest.mark.parametrize( "seconds, expected", [ @@ -2585,7 +2614,7 @@ def test_foo(): result.stdout.fnmatch_lines( color_mapping.format_for_fnmatch( [ - " {reset}{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}", ] @@ -2607,7 +2636,7 @@ def test_foo(): result.stdout.fnmatch_lines( color_mapping.format_for_fnmatch( [ - " {reset}{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}", @@ -2630,7 +2659,7 @@ def test_foo(): result.stdout.fnmatch_lines( color_mapping.format_for_fnmatch( [ - " {reset}{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}", ] @@ -3067,3 +3096,38 @@ def 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/tox.ini b/tox.ini index 61563ca2c5f..1a6ab344de8 100644 --- a/tox.ini +++ b/tox.ini @@ -104,13 +104,7 @@ basepython = python3.12 # sync with rtd to get errors usedevelop = True deps = -r{toxinidir}/doc/en/requirements.txt -allowlist_externals = - git commands = - # Retrieve possibly missing commits: - -git fetch --unshallow - -git fetch --tags - sphinx-build \ -j auto \ -W --keep-going \