Compare commits
297 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bf3bcb2bf1 | |||
| ecb23106d8 | |||
| 8174a30164 | |||
| 0142bb6687 | |||
| 52cf700f1b | |||
| 6b3ece5bdb | |||
| 4059000834 | |||
| 7d5207a736 | |||
| 3e65a461c7 | |||
| fc3b5b2610 | |||
| 9335a0b445 | |||
| 0ded3297a9 | |||
| fc9cbbd4c4 | |||
| 1a17539065 | |||
| 32de8e2893 | |||
| 5d4a342a7a | |||
| 1790f17228 | |||
| ee8baa2676 | |||
| ae38b076da | |||
| 85c5bd26b6 | |||
| e1204e1e23 | |||
| 313b61471f | |||
| 1daa8129c6 | |||
| b5ff089d2c | |||
| fda8024622 | |||
| 4b823a42ce | |||
| 6c9b277ce4 | |||
| 3de43e5102 | |||
| c76ae74bd7 | |||
| 24534cdd29 | |||
| 99c78aa93a | |||
| 3a6bdcd76b | |||
| 4a1bba25b9 | |||
| 9e1add75f7 | |||
| 4da9026766 | |||
| 7c231baa64 | |||
| fc538c5766 | |||
| fbfd4b5005 | |||
| ec752537ea | |||
| dd667336ce | |||
| 29d16d2939 | |||
| 5313d50e18 | |||
| 4f3f36c396 | |||
| af124c7f21 | |||
| 4e6d53fef5 | |||
| 751d726d21 | |||
| 9e491f430e | |||
| 63f258f432 | |||
| baaa67dfb9 | |||
| 519f351b4f | |||
| 5d53447a73 | |||
| 1716d3c9bf | |||
| ac699e7b25 | |||
| a5f37199a9 | |||
| 9fa82598a9 | |||
| ba32a3bd87 | |||
| c8641f879f | |||
| 6041511fb4 | |||
| 739408b958 | |||
| 1636322995 | |||
| b8edacb8f1 | |||
| 612489e2bd | |||
| 383774db10 | |||
| 3b5b3cf50e | |||
| 23e343af60 | |||
| f9a995b56d | |||
| d9d78a8aef | |||
| 76d15231f5 | |||
| 4cc05e7bee | |||
| 2d57d5c32f | |||
| faeb16146b | |||
| b241c0b479 | |||
| 78403237cf | |||
| f84fea0888 | |||
| 271bdf6c23 | |||
| fd56968f2b | |||
| 5b75b0d03f | |||
| aac5d5d08b | |||
| b1460f3261 | |||
| a88ae8289c | |||
| 62320e4ff7 | |||
| 6514041a35 | |||
| 7d548c38e2 | |||
| 07eeeb8dfc | |||
| be774667c2 | |||
| 762bb61562 | |||
| 725de3a0d3 | |||
| fcada1ea47 | |||
| 6f7f89f3c4 | |||
| 0a20452f78 | |||
| cc23ec91d0 | |||
| a15f544962 | |||
| 3823ce60dd | |||
| e03f82c359 | |||
| 158f41fdf8 | |||
| fd6a4507ac | |||
| 15156757b6 | |||
| 0860f4e916 | |||
| 11965d1c27 | |||
| 14be71b234 | |||
| 2d6206b89a | |||
| 41f57ef95d | |||
| 4eca6063c8 | |||
| 819f5abd73 | |||
| adf891ab1d | |||
| f08184ba20 | |||
| 28783e5d23 | |||
| 7834b3b07f | |||
| ece756fcb4 | |||
| d380771065 | |||
| b893d2a0fe | |||
| e3b1799766 | |||
| 5d1385320f | |||
| eff54aece1 | |||
| bc1fc3f0fc | |||
| 784ffa0fad | |||
| 90412827c3 | |||
| 424c3eebde | |||
| 9c2247ec1b | |||
| 61f7c27ec0 | |||
| 1b81d636e2 | |||
| 1b196fbeaf | |||
| 22524046cf | |||
| 6dcd652d4a | |||
| be9faa68d8 | |||
| e4e13dd913 | |||
| 5a399030c1 | |||
| 19c8ef63a4 | |||
| 22951bba67 | |||
| ec8e23951d | |||
| bf47357511 | |||
| 2d2dc4a2a8 | |||
| 3683722bcb | |||
| 31d0b51039 | |||
| 2d2f69dab5 | |||
| 2a39ed3461 | |||
| f1c7585184 | |||
| a3b39069bc | |||
| 172c832cbd | |||
| 839b90db45 | |||
| 549cc512f7 | |||
| 2369bed1db | |||
| 54864f0c9b | |||
| ba969d2ae7 | |||
| 407b330fe1 | |||
| 431ec6d34e | |||
| eada68b2b3 | |||
| ab069247cd | |||
| 7af1e4e4ed | |||
| 0ae04ae629 | |||
| 723035be7f | |||
| 6e478b0947 | |||
| a869141b3d | |||
| 5e98aefc92 | |||
| 5f47e423b2 | |||
| 5a61ec3d4a | |||
| b3b44ea814 | |||
| 1d48b3021d | |||
| d5dda84ef3 | |||
| 517e02e59e | |||
| 4e259590c9 | |||
| 97a2761d72 | |||
| 88c9e92258 | |||
| 72ad32411f | |||
| cb9e8be301 | |||
| d72da480c4 | |||
| 07e7deb4a7 | |||
| 572b5657d7 | |||
| 44afed9b13 | |||
| 13ea4780b8 | |||
| 135600fca3 | |||
| c237297b3d | |||
| 9ccae9a8e3 | |||
| 77152d26e7 | |||
| da626e7186 | |||
| 051f8f1f0f | |||
| 31ad577325 | |||
| 835cac8d8b | |||
| 464f29901f | |||
| aa72496d24 | |||
| e9f3a01392 | |||
| 00c94ab01b | |||
| 9048621002 | |||
| 27165cf8db | |||
| 7a829cb57d | |||
| 5e1c3d2477 | |||
| 59e7d2bbc9 | |||
| af99040123 | |||
| a2b7db7655 | |||
| 9c93c96b14 | |||
| 4a46ee8bc9 | |||
| 5dbfb8e108 | |||
| 86a1beba07 | |||
| ca40380e99 | |||
| 05eee78aaa | |||
| 02893139f9 | |||
| 8c53dbf9d7 | |||
| 54b8b40f83 | |||
| 54911acf8d | |||
| c746d2b016 | |||
| a3693ce503 | |||
| af4143729f | |||
| bd7919e03d | |||
| 7d4b40337b | |||
| 6a714d7b70 | |||
| 5a23eeff7a | |||
| 310b67b227 | |||
| 4d4ed42c34 | |||
| 096b942ec4 | |||
| 61cfaacec6 | |||
| 95c62eb527 | |||
| 03b19945fb | |||
| b2ac31cc9f | |||
| 1a96f16401 | |||
| 7421f3bb94 | |||
| 5e0583f4b9 | |||
| 8efb4bb9c1 | |||
| 3ad4344656 | |||
| 6bf7f55555 | |||
| 61f70a5a75 | |||
| 326ae0cd88 | |||
| 10220d3f31 | |||
| a98b00cd09 | |||
| 215ea7fd03 | |||
| b31db4809b | |||
| f6adebb990 | |||
| b90e7b84d0 | |||
| 19807ab79a | |||
| 3e52124185 | |||
| 1eca228bd5 | |||
| cab02e67d7 | |||
| 64dbc7a0a1 | |||
| 60d992677d | |||
| 0079decf29 | |||
| 3a58fc2d44 | |||
| 39b6bb551c | |||
| 9fbd67dd4b | |||
| eca93db05b | |||
| fb701b538c | |||
| 314e623304 | |||
| 62e75c7d55 | |||
| fd30759d94 | |||
| eb984a717a | |||
| 54f0fb3c63 | |||
| 49a4ed14cf | |||
| f513d33d5a | |||
| 857e34ef85 | |||
| 99dfc19fe6 | |||
| 56544c11b5 | |||
| 7710e18b4c | |||
| 791b51d0fa | |||
| bc4e70e048 | |||
| b817aa457c | |||
| 66b28912ac | |||
| cca029d55e | |||
| d5466b3917 | |||
| 4fce29f15d | |||
| 69e3973d86 | |||
| c842893b02 | |||
| 506b10d295 | |||
| 05061493cb | |||
| f97f3dc3a3 | |||
| 3c31b0132f | |||
| 593178d909 | |||
| 54d5a63d14 | |||
| b55e264a67 | |||
| 13d6114c0a | |||
| b635e16d30 | |||
| a092b3ab36 | |||
| a006dabf6e | |||
| aa7e9de91d | |||
| 6aec32163d | |||
| 2f33ea87c8 | |||
| 1ada62e237 | |||
| 50b232b0cb | |||
| 496196b15c | |||
| 0314b50c52 | |||
| 8e2de91bf8 | |||
| 692ab1160b | |||
| 549839bac5 | |||
| 646a46e5f4 | |||
| f07017f91b | |||
| a17d3b0c44 | |||
| bbec1ce67f | |||
| 5a040aef97 | |||
| c1d2168df6 | |||
| bbe7cbae4a | |||
| deae8f47f6 | |||
| 10f55f79af | |||
| a6d244343f | |||
| 2b552c2240 | |||
| 54d7b9a08e | |||
| 6afc02abca | |||
| e75e2d66a0 | |||
| 66db0b7522 | |||
| 3a68c08426 | |||
| 9e1804a6ee |
@@ -9,3 +9,9 @@ updates:
|
||||
allow:
|
||||
- dependency-type: direct
|
||||
- dependency-type: indirect
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
time: "03:00"
|
||||
open-pull-requests-limit: 10
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: true
|
||||
|
||||
@@ -23,30 +23,34 @@ jobs:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Build and Check Package
|
||||
uses: hynek/build-and-inspect-python-package@v1.5
|
||||
|
||||
- name: Download Package
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: Packages
|
||||
path: dist
|
||||
|
||||
- name: Publish package to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
password: ${{ secrets.pypi_token }}
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v2
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: "3.7"
|
||||
|
||||
- name: Install dependencies
|
||||
- name: Install tox
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install --upgrade build tox
|
||||
|
||||
- name: Build package
|
||||
run: |
|
||||
python -m build
|
||||
|
||||
- name: Publish package to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@master
|
||||
with:
|
||||
user: __token__
|
||||
password: ${{ secrets.pypi_token }}
|
||||
pip install --upgrade tox
|
||||
|
||||
- name: Publish GitHub release notes
|
||||
env:
|
||||
|
||||
@@ -27,12 +27,12 @@ jobs:
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v2
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: "3.8"
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
name: close needs-information issues
|
||||
on:
|
||||
schedule:
|
||||
- cron: "30 1 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
close-issues:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
steps:
|
||||
- uses: actions/stale@v8
|
||||
with:
|
||||
debug-only: false
|
||||
days-before-issue-stale: 14
|
||||
days-before-issue-close: 7
|
||||
only-labels: "status: needs information"
|
||||
stale-issue-label: "stale"
|
||||
stale-issue-message: "This issue is stale because it has been open for 14 days with no activity."
|
||||
close-issue-message: "This issue was closed because it has been inactive for 7 days since being marked as stale."
|
||||
days-before-pr-stale: -1
|
||||
days-before-pr-close: -1
|
||||
@@ -18,6 +18,11 @@ on:
|
||||
env:
|
||||
PYTEST_ADDOPTS: "--color=yes"
|
||||
|
||||
# Cancel running jobs for the same workflow and branch.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
# Set permissions at the job level.
|
||||
permissions: {}
|
||||
|
||||
@@ -38,6 +43,7 @@ jobs:
|
||||
"windows-py39",
|
||||
"windows-py310",
|
||||
"windows-py311",
|
||||
"windows-py312",
|
||||
|
||||
"ubuntu-py37",
|
||||
"ubuntu-py37-pluggy",
|
||||
@@ -46,12 +52,13 @@ jobs:
|
||||
"ubuntu-py39",
|
||||
"ubuntu-py310",
|
||||
"ubuntu-py311",
|
||||
"ubuntu-py312",
|
||||
"ubuntu-pypy3",
|
||||
|
||||
"macos-py37",
|
||||
"macos-py38",
|
||||
"macos-py39",
|
||||
"macos-py310",
|
||||
"macos-py312",
|
||||
|
||||
"docs",
|
||||
"doctesting",
|
||||
@@ -81,9 +88,13 @@ jobs:
|
||||
os: windows-latest
|
||||
tox_env: "py310-xdist"
|
||||
- name: "windows-py311"
|
||||
python: "3.11-dev"
|
||||
python: "3.11"
|
||||
os: windows-latest
|
||||
tox_env: "py311"
|
||||
- name: "windows-py312"
|
||||
python: "3.12-dev"
|
||||
os: windows-latest
|
||||
tox_env: "py312"
|
||||
|
||||
- name: "ubuntu-py37"
|
||||
python: "3.7"
|
||||
@@ -111,10 +122,15 @@ jobs:
|
||||
os: ubuntu-latest
|
||||
tox_env: "py310-xdist"
|
||||
- name: "ubuntu-py311"
|
||||
python: "3.11-dev"
|
||||
python: "3.11"
|
||||
os: ubuntu-latest
|
||||
tox_env: "py311"
|
||||
use_coverage: true
|
||||
- name: "ubuntu-py312"
|
||||
python: "3.12-dev"
|
||||
os: ubuntu-latest
|
||||
tox_env: "py312"
|
||||
use_coverage: true
|
||||
- name: "ubuntu-pypy3"
|
||||
python: "pypy-3.7"
|
||||
os: ubuntu-latest
|
||||
@@ -124,19 +140,19 @@ jobs:
|
||||
python: "3.7"
|
||||
os: macos-latest
|
||||
tox_env: "py37-xdist"
|
||||
- name: "macos-py38"
|
||||
python: "3.8"
|
||||
os: macos-latest
|
||||
tox_env: "py38-xdist"
|
||||
use_coverage: true
|
||||
- name: "macos-py39"
|
||||
python: "3.9"
|
||||
os: macos-latest
|
||||
tox_env: "py39-xdist"
|
||||
use_coverage: true
|
||||
- name: "macos-py310"
|
||||
python: "3.10"
|
||||
os: macos-latest
|
||||
tox_env: "py310-xdist"
|
||||
- name: "macos-py312"
|
||||
python: "3.12-dev"
|
||||
os: macos-latest
|
||||
tox_env: "py312-xdist"
|
||||
|
||||
- name: "plugins"
|
||||
python: "3.9"
|
||||
@@ -163,6 +179,7 @@ jobs:
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ matrix.python }}
|
||||
check-latest: ${{ endsWith(matrix.python, '-dev') }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
@@ -189,3 +206,10 @@ jobs:
|
||||
fail_ci_if_error: true
|
||||
files: ./coverage.xml
|
||||
verbose: true
|
||||
|
||||
check-package:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Build and Check Package
|
||||
uses: hynek/build-and-inspect-python-package@v1.5
|
||||
|
||||
@@ -11,7 +11,7 @@ on:
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
createPullRequest:
|
||||
update-plugin-list:
|
||||
if: github.repository_owner == 'pytest-dev'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
@@ -20,12 +20,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v2
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: 3.8
|
||||
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
run: python scripts/update-plugin-list.py
|
||||
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@2455e1596942c2902952003bbb574afbbe2ab2e6
|
||||
uses: peter-evans/create-pull-request@5b4a9f6a9e2af26e5f02351490b90d01eb8ec1e5
|
||||
with:
|
||||
commit-message: '[automated] Update plugin list'
|
||||
author: 'pytest bot <pytestbot@users.noreply.github.com>'
|
||||
|
||||
+13
-15
@@ -1,18 +1,16 @@
|
||||
default_language_version:
|
||||
python: "3.10"
|
||||
repos:
|
||||
- repo: https://github.com/psf/black
|
||||
rev: 22.10.0
|
||||
rev: 23.3.0
|
||||
hooks:
|
||||
- id: black
|
||||
args: [--safe, --quiet]
|
||||
- repo: https://github.com/asottile/blacken-docs
|
||||
rev: v1.12.1
|
||||
rev: 1.13.0
|
||||
hooks:
|
||||
- id: blacken-docs
|
||||
additional_dependencies: [black==20.8b1]
|
||||
additional_dependencies: [black==23.1.0]
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.3.0
|
||||
rev: v4.4.0
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
- id: end-of-file-fixer
|
||||
@@ -23,7 +21,7 @@ repos:
|
||||
exclude: _pytest/(debugging|hookspec).py
|
||||
language_version: python3
|
||||
- repo: https://github.com/PyCQA/autoflake
|
||||
rev: v1.7.6
|
||||
rev: v2.1.1
|
||||
hooks:
|
||||
- id: autoflake
|
||||
name: autoflake
|
||||
@@ -31,34 +29,34 @@ repos:
|
||||
language: python
|
||||
files: \.py$
|
||||
- repo: https://github.com/PyCQA/flake8
|
||||
rev: 5.0.4
|
||||
rev: 6.0.0
|
||||
hooks:
|
||||
- id: flake8
|
||||
language_version: python3
|
||||
additional_dependencies:
|
||||
- flake8-typing-imports==1.12.0
|
||||
- flake8-docstrings==1.5.0
|
||||
- repo: https://github.com/asottile/reorder_python_imports
|
||||
rev: v3.8.5
|
||||
- repo: https://github.com/asottile/reorder-python-imports
|
||||
rev: v3.9.0
|
||||
hooks:
|
||||
- id: reorder-python-imports
|
||||
args: ['--application-directories=.:src', --py37-plus]
|
||||
- repo: https://github.com/asottile/pyupgrade
|
||||
rev: v3.1.0
|
||||
rev: v3.4.0
|
||||
hooks:
|
||||
- id: pyupgrade
|
||||
args: [--py37-plus]
|
||||
- repo: https://github.com/asottile/setup-cfg-fmt
|
||||
rev: v2.1.0
|
||||
rev: v2.3.0
|
||||
hooks:
|
||||
- id: setup-cfg-fmt
|
||||
args: ["--max-py-version=3.10", "--include-version-classifiers"]
|
||||
args: ["--max-py-version=3.12", "--include-version-classifiers"]
|
||||
- repo: https://github.com/pre-commit/pygrep-hooks
|
||||
rev: v1.9.0
|
||||
rev: v1.10.0
|
||||
hooks:
|
||||
- id: python-use-type-annotations
|
||||
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||
rev: v0.982
|
||||
rev: v1.3.0
|
||||
hooks:
|
||||
- id: mypy
|
||||
files: ^(src/|testing/)
|
||||
|
||||
+6
-3
@@ -2,9 +2,12 @@ version: 2
|
||||
|
||||
python:
|
||||
install:
|
||||
- requirements: doc/en/requirements.txt
|
||||
- method: pip
|
||||
path: .
|
||||
# Install pytest first, then doc/en/requirements.txt.
|
||||
# This order is important to honor any pins in doc/en/requirements.txt
|
||||
# when the pinned library is also a dependency of pytest.
|
||||
- method: pip
|
||||
path: .
|
||||
- requirements: doc/en/requirements.txt
|
||||
|
||||
build:
|
||||
os: ubuntu-20.04
|
||||
|
||||
@@ -8,10 +8,14 @@ Abdeali JK
|
||||
Abdelrahman Elbehery
|
||||
Abhijeet Kasurde
|
||||
Adam Johnson
|
||||
Adam Stewart
|
||||
Adam Uhlir
|
||||
Ahn Ki-Wook
|
||||
Akiomi Kamakura
|
||||
Alan Velasco
|
||||
Alessio Izzo
|
||||
Alex Jones
|
||||
Alex Lambson
|
||||
Alexander Johnson
|
||||
Alexander King
|
||||
Alexei Kozlenok
|
||||
@@ -43,6 +47,7 @@ Ariel Pillemer
|
||||
Armin Rigo
|
||||
Aron Coyle
|
||||
Aron Curzon
|
||||
Ashish Kurmi
|
||||
Aviral Verma
|
||||
Aviv Palivoda
|
||||
Babak Keyvani
|
||||
@@ -53,10 +58,12 @@ Benjamin Peterson
|
||||
Bernard Pratz
|
||||
Bob Ippolito
|
||||
Brian Dorsey
|
||||
Brian Larsen
|
||||
Brian Maissy
|
||||
Brian Okken
|
||||
Brianna Laugher
|
||||
Bruno Oliveira
|
||||
Cal Jacobson
|
||||
Cal Leeming
|
||||
Carl Friedrich Bolz
|
||||
Carlos Jenkins
|
||||
@@ -65,6 +72,7 @@ Charles Cloud
|
||||
Charles Machalow
|
||||
Charnjit SiNGH (CCSJ)
|
||||
Cheuk Ting Ho
|
||||
Chris Mahoney
|
||||
Chris Lamb
|
||||
Chris NeJame
|
||||
Chris Rose
|
||||
@@ -88,6 +96,7 @@ Daniel Grana
|
||||
Daniel Hahler
|
||||
Daniel Nuri
|
||||
Daniel Sánchez Castelló
|
||||
Daniel Valenzuela Zenteno
|
||||
Daniel Wandschneider
|
||||
Daniele Procida
|
||||
Danielle Jenkins
|
||||
@@ -122,8 +131,10 @@ Eric Siegerman
|
||||
Erik Aronesty
|
||||
Erik M. Bray
|
||||
Evan Kepner
|
||||
Evgeny Seliverstov
|
||||
Fabien Zarifian
|
||||
Fabio Zadrozny
|
||||
Felix Hofstätter
|
||||
Felix Nieuwenhuizen
|
||||
Feng Ma
|
||||
Florian Bruhin
|
||||
@@ -158,6 +169,7 @@ Ionuț Turturică
|
||||
Itxaso Aizpurua
|
||||
Iwan Briquemont
|
||||
Jaap Broekhuizen
|
||||
Jake VanderPlas
|
||||
Jakob van Santen
|
||||
Jakub Mitoraj
|
||||
James Bourbeau
|
||||
@@ -182,10 +194,11 @@ Joseph Hunkeler
|
||||
Josh Karpel
|
||||
Joshua Bronson
|
||||
Jurko Gospodnetić
|
||||
Justyna Janczyszyn
|
||||
Justice Ndou
|
||||
Justyna Janczyszyn
|
||||
Kale Kundert
|
||||
Kamran Ahmad
|
||||
Kenny Y
|
||||
Karl O. Pinc
|
||||
Karthikeyan Singaravelan
|
||||
Katarzyna Jachim
|
||||
@@ -222,6 +235,7 @@ Marcin Bachry
|
||||
Marco Gorelli
|
||||
Mark Abramowitz
|
||||
Mark Dickinson
|
||||
Marko Pacak
|
||||
Markus Unterwaditzer
|
||||
Martijn Faassen
|
||||
Martin Altmayer
|
||||
@@ -235,7 +249,6 @@ Matthias Hafner
|
||||
Maxim Filipenko
|
||||
Maximilian Cosmo Sitter
|
||||
mbyt
|
||||
Mickey Pashov
|
||||
Michael Aquilina
|
||||
Michael Birtwell
|
||||
Michael Droettboom
|
||||
@@ -244,6 +257,7 @@ Michael Krebs
|
||||
Michael Seifert
|
||||
Michal Wajszczuk
|
||||
Michał Zięba
|
||||
Mickey Pashov
|
||||
Mihai Capotă
|
||||
Mike Hoyle (hoylemd)
|
||||
Mike Lundy
|
||||
@@ -258,9 +272,9 @@ Niclas Olofsson
|
||||
Nicolas Delaby
|
||||
Nikolay Kondratyev
|
||||
Nipunn Koorapati
|
||||
Olga Matoula
|
||||
Oleg Pidsadnyi
|
||||
Oleg Sushchenko
|
||||
Olga Matoula
|
||||
Oliver Bestwalter
|
||||
Omar Kohl
|
||||
Omer Hadari
|
||||
@@ -276,6 +290,7 @@ Paweł Adamczak
|
||||
Pedro Algarvio
|
||||
Petter Strandmark
|
||||
Philipp Loose
|
||||
Pierre Sassoulas
|
||||
Pieter Mulder
|
||||
Piotr Banaszkiewicz
|
||||
Piotr Helm
|
||||
@@ -285,15 +300,18 @@ Prashant Sharma
|
||||
Pulkit Goyal
|
||||
Punyashloka Biswal
|
||||
Quentin Pradet
|
||||
q0w
|
||||
Ralf Schmitt
|
||||
Ram Rachum
|
||||
Ralph Giles
|
||||
Ram Rachum
|
||||
Ran Benita
|
||||
Raphael Castaneda
|
||||
Raphael Pierzina
|
||||
Rafal Semik
|
||||
Raquel Alegre
|
||||
Ravi Chandra
|
||||
Robert Holt
|
||||
Roberto Aldera
|
||||
Roberto Polli
|
||||
Roland Puntaier
|
||||
Romain Dorgueil
|
||||
@@ -310,6 +328,7 @@ Samuel Searles-Bryant
|
||||
Samuele Pedroni
|
||||
Sanket Duthade
|
||||
Sankt Petersbug
|
||||
Saravanan Padmanaban
|
||||
Segev Finer
|
||||
Serhii Mozghovyi
|
||||
Seth Junot
|
||||
@@ -323,6 +342,7 @@ Srinivas Reddy Thatiparthy
|
||||
Stefan Farmbauer
|
||||
Stefan Scherfke
|
||||
Stefan Zimmermann
|
||||
Stefanie Molin
|
||||
Stefano Taschini
|
||||
Steffen Allner
|
||||
Stephan Obermann
|
||||
@@ -341,6 +361,7 @@ Thomas Grainger
|
||||
Thomas Hisch
|
||||
Tim Hoffmann
|
||||
Tim Strazny
|
||||
TJ Bruno
|
||||
Tobias Diez
|
||||
Tom Dalton
|
||||
Tom Viner
|
||||
@@ -371,7 +392,10 @@ Wouter van Ackooy
|
||||
Xixi Zhao
|
||||
Xuan Luong
|
||||
Xuecong Liao
|
||||
Yannick Péroux
|
||||
Yoav Caspi
|
||||
Yuliang Shao
|
||||
Yusuke Kadowaki
|
||||
Yuval Shimon
|
||||
Zac Hatfield-Dodds
|
||||
Zachary Kneupper
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
Terminal Reporting: Fixed bug when running in ``--tb=line`` mode where ``pytest.fail(pytrace=False)`` tests report ``None``.
|
||||
@@ -0,0 +1 @@
|
||||
Update test log report annotation to named tuple and fixed inconsistency in docs for :hook:`pytest_report_teststatus` hook.
|
||||
@@ -0,0 +1,2 @@
|
||||
Added :func:`ExceptionInfo.from_exception() <pytest.ExceptionInfo.from_exception>`, a simpler way to create an :class:`~pytest.ExceptionInfo` from an exception.
|
||||
This can replace :func:`ExceptionInfo.from_exc_info() <pytest.ExceptionInfo.from_exc_info()>` for most uses.
|
||||
@@ -0,0 +1,5 @@
|
||||
When an exception traceback to be displayed is completely filtered out (by mechanisms such as ``__tracebackhide__``, internal frames, and similar), now only the exception string and the following message are shown:
|
||||
|
||||
"All traceback entries are hidden. Pass `--full-trace` to see hidden and internal frames.".
|
||||
|
||||
Previously, the last frame of the traceback was shown, even though it was hidden.
|
||||
@@ -0,0 +1,3 @@
|
||||
Improved verbose output (``-vv``) of ``skip`` and ``xfail`` reasons by performing text wrapping while leaving a clear margin for progress output.
|
||||
|
||||
Added :func:`TerminalReporter.wrap_write() <pytest.TerminalReporter.wrap_write>` as a helper for that.
|
||||
@@ -0,0 +1 @@
|
||||
Added handling of ``%f`` directive to print microseconds in log format options, such as ``log-date-format``.
|
||||
@@ -0,0 +1 @@
|
||||
Added underlying exception to cache provider path creation and write warning messages.
|
||||
@@ -0,0 +1 @@
|
||||
Added warning when :confval:`testpaths` is set, but paths are not found by glob. In this case, pytest will fall back to searching from the current directory.
|
||||
@@ -0,0 +1 @@
|
||||
Enhanced the CLI flag for ``-c`` to now include ``--config-file`` to make it clear that this flag applies to the usage of a custom config file.
|
||||
@@ -0,0 +1,3 @@
|
||||
When `--confcutdir` is not specified, and there is no config file present, the conftest cutoff directory (`--confcutdir`) is now set to the :ref:`rootdir`.
|
||||
Previously in such cases, `conftest.py` files would be probed all the way to the root directory of the filesystem.
|
||||
If you are badly affected by this change, consider adding an empty config file to your desired cutoff directory, or explicitly set `--confcutdir`.
|
||||
@@ -0,0 +1 @@
|
||||
Fixed the ``--last-failed`` whole-file skipping functionality ("skipped N files") for :ref:`non-python test files <non-python tests>`.
|
||||
@@ -0,0 +1,7 @@
|
||||
The :confval:`norecursedir` check is now performed in a :hook:`pytest_ignore_collect` implementation, so plugins can affect it.
|
||||
|
||||
If after updating to this version you see that your `norecursedir` setting is not being respected,
|
||||
it means that a conftest or a plugin you use has a bad `pytest_ignore_collect` implementation.
|
||||
Most likely, your hook returns `False` for paths it does not want to ignore,
|
||||
which ends the processing and doesn't allow other plugins, including pytest itself, to ignore the path.
|
||||
The fix is to return `None` instead of `False` for paths your hook doesn't want to ignore.
|
||||
@@ -0,0 +1 @@
|
||||
Fixed traceback entries hidden with ``__tracebackhide__ = True`` still being shown for chained exceptions (parts after "... the above exception ..." message).
|
||||
@@ -0,0 +1,3 @@
|
||||
:func:`_pytest.logging.LogCaptureFixture.set_level` and :func:`_pytest.logging.LogCaptureFixture.at_level`
|
||||
will temporarily enable the requested ``level`` if ``level`` was disabled globally via
|
||||
``logging.disable(LEVEL)``.
|
||||
@@ -6,6 +6,11 @@ Release announcements
|
||||
:maxdepth: 2
|
||||
|
||||
|
||||
release-7.3.2
|
||||
release-7.3.1
|
||||
release-7.3.0
|
||||
release-7.2.2
|
||||
release-7.2.1
|
||||
release-7.2.0
|
||||
release-7.1.3
|
||||
release-7.1.2
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
pytest-7.2.1
|
||||
=======================================
|
||||
|
||||
pytest 7.2.1 has just been released to PyPI.
|
||||
|
||||
This is a bug-fix release, being a drop-in replacement. To upgrade::
|
||||
|
||||
pip install --upgrade pytest
|
||||
|
||||
The full changelog is available at https://docs.pytest.org/en/stable/changelog.html.
|
||||
|
||||
Thanks to all of the contributors to this release:
|
||||
|
||||
* Anthony Sottile
|
||||
* Bruno Oliveira
|
||||
* Daniel Valenzuela
|
||||
* Kadino
|
||||
* Prerak Patel
|
||||
* Ronny Pfannschmidt
|
||||
* Santiago Castro
|
||||
* s-padmanaban
|
||||
|
||||
|
||||
Happy testing,
|
||||
The pytest Development Team
|
||||
@@ -0,0 +1,25 @@
|
||||
pytest-7.2.2
|
||||
=======================================
|
||||
|
||||
pytest 7.2.2 has just been released to PyPI.
|
||||
|
||||
This is a bug-fix release, being a drop-in replacement. To upgrade::
|
||||
|
||||
pip install --upgrade pytest
|
||||
|
||||
The full changelog is available at https://docs.pytest.org/en/stable/changelog.html.
|
||||
|
||||
Thanks to all of the contributors to this release:
|
||||
|
||||
* Bruno Oliveira
|
||||
* Garvit Shubham
|
||||
* Mahesh Vashishtha
|
||||
* Ramsey
|
||||
* Ronny Pfannschmidt
|
||||
* Teejay
|
||||
* q0w
|
||||
* vin01
|
||||
|
||||
|
||||
Happy testing,
|
||||
The pytest Development Team
|
||||
@@ -0,0 +1,130 @@
|
||||
pytest-7.3.0
|
||||
=======================================
|
||||
|
||||
The pytest team is proud to announce the 7.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:
|
||||
|
||||
* Aaron Berdy
|
||||
* Adam Turner
|
||||
* Albert Villanova del Moral
|
||||
* Alessio Izzo
|
||||
* Alex Hadley
|
||||
* Alice Purcell
|
||||
* Anthony Sottile
|
||||
* Anton Yakutovich
|
||||
* Ashish Kurmi
|
||||
* Babak Keyvani
|
||||
* Billy
|
||||
* Brandon Chinn
|
||||
* Bruno Oliveira
|
||||
* Cal Jacobson
|
||||
* Chanvin Xiao
|
||||
* Cheuk Ting Ho
|
||||
* Chris Wheeler
|
||||
* Daniel Garcia Moreno
|
||||
* Daniel Scheffler
|
||||
* Daniel Valenzuela
|
||||
* EmptyRabbit
|
||||
* Ezio Melotti
|
||||
* Felix Hofstätter
|
||||
* Florian Best
|
||||
* Florian Bruhin
|
||||
* Fredrik Berndtsson
|
||||
* Gabriel Landau
|
||||
* Garvit Shubham
|
||||
* Gergely Kalmár
|
||||
* HTRafal
|
||||
* Hugo van Kemenade
|
||||
* Ilya Konstantinov
|
||||
* Itxaso Aizpurua
|
||||
* James Gerity
|
||||
* Jay
|
||||
* John Litborn
|
||||
* Jon Parise
|
||||
* Jouke Witteveen
|
||||
* Kadino
|
||||
* Kevin C
|
||||
* Kian Eliasi
|
||||
* Klaus Rettinghaus
|
||||
* Kodi Arfer
|
||||
* Mahesh Vashishtha
|
||||
* Manuel Jacob
|
||||
* Marko Pacak
|
||||
* MatthewFlamm
|
||||
* Miro Hrončok
|
||||
* Nate Meyvis
|
||||
* Neil Girdhar
|
||||
* Nhieuvu1802
|
||||
* Nipunn Koorapati
|
||||
* Ofek Lev
|
||||
* Paul Kehrer
|
||||
* Paul Müller
|
||||
* Paul Reece
|
||||
* Pax
|
||||
* Pete Baughman
|
||||
* Peyman Salehi
|
||||
* Philipp A
|
||||
* Pierre Sassoulas
|
||||
* Prerak Patel
|
||||
* Ramsey
|
||||
* Ran Benita
|
||||
* Robert O'Shea
|
||||
* Ronny Pfannschmidt
|
||||
* Rowin
|
||||
* Ruth Comer
|
||||
* Samuel Colvin
|
||||
* Samuel Gaist
|
||||
* Sandro Tosi
|
||||
* Santiago Castro
|
||||
* Shantanu
|
||||
* Simon K
|
||||
* Stefanie Molin
|
||||
* Stephen Rosen
|
||||
* Sviatoslav Sydorenko
|
||||
* Tatiana Ovary
|
||||
* Teejay
|
||||
* Thierry Moisan
|
||||
* Thomas Grainger
|
||||
* Tim Hoffmann
|
||||
* Tobias Diez
|
||||
* Tony Narlock
|
||||
* Vivaan Verma
|
||||
* Wolfremium
|
||||
* Yannick PÉROUX
|
||||
* Yusuke Kadowaki
|
||||
* Zac Hatfield-Dodds
|
||||
* Zach OBrien
|
||||
* aizpurua23a
|
||||
* bitzge
|
||||
* bluthej
|
||||
* gresm
|
||||
* holesch
|
||||
* itxasos23
|
||||
* johnkangw
|
||||
* q0w
|
||||
* rdb
|
||||
* s-padmanaban
|
||||
* skhomuti
|
||||
* sommersoft
|
||||
* vin01
|
||||
* wim glenn
|
||||
* wodny
|
||||
* zx.qiu
|
||||
|
||||
|
||||
Happy testing,
|
||||
The pytest Development Team
|
||||
@@ -0,0 +1,18 @@
|
||||
pytest-7.3.1
|
||||
=======================================
|
||||
|
||||
pytest 7.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:
|
||||
|
||||
* Ran Benita
|
||||
|
||||
|
||||
Happy testing,
|
||||
The pytest Development Team
|
||||
@@ -0,0 +1,21 @@
|
||||
pytest-7.3.2
|
||||
=======================================
|
||||
|
||||
pytest 7.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:
|
||||
|
||||
* Adam J. Stewart
|
||||
* Alessio Izzo
|
||||
* Bruno Oliveira
|
||||
* Ran Benita
|
||||
|
||||
|
||||
Happy testing,
|
||||
The pytest Development Team
|
||||
@@ -92,3 +92,5 @@ pytest version min. Python version
|
||||
5.0 - 6.1 3.5+
|
||||
3.3 - 4.6 2.7, 3.4+
|
||||
============== ===================
|
||||
|
||||
`Status of Python Versions <https://devguide.python.org/versions/>`__.
|
||||
|
||||
+31
-29
@@ -33,25 +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.
|
||||
|
||||
capsys -- .../_pytest/capture.py:905
|
||||
Enable text capturing of writes to ``sys.stdout`` and ``sys.stderr``.
|
||||
|
||||
The captured output is made available via ``capsys.readouterr()`` method
|
||||
calls, which return a ``(out, err)`` namedtuple.
|
||||
``out`` and ``err`` will be ``text`` objects.
|
||||
|
||||
Returns an instance of :class:`CaptureFixture[str] <pytest.CaptureFixture>`.
|
||||
|
||||
Example:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
def test_output(capsys):
|
||||
print("hello")
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out == "hello\n"
|
||||
|
||||
capsysbinary -- .../_pytest/capture.py:933
|
||||
capsysbinary -- .../_pytest/capture.py:1001
|
||||
Enable bytes capturing of writes to ``sys.stdout`` and ``sys.stderr``.
|
||||
|
||||
The captured output is made available via ``capsysbinary.readouterr()``
|
||||
@@ -69,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:961
|
||||
capfd -- .../_pytest/capture.py:1029
|
||||
Enable text capturing of writes to file descriptors ``1`` and ``2``.
|
||||
|
||||
The captured output is made available via ``capfd.readouterr()`` method
|
||||
@@ -87,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:989
|
||||
capfdbinary -- .../_pytest/capture.py:1057
|
||||
Enable bytes capturing of writes to file descriptors ``1`` and ``2``.
|
||||
|
||||
The captured output is made available via ``capfd.readouterr()`` method
|
||||
@@ -105,7 +87,25 @@ For information about fixtures, see :ref:`fixtures`. To see a complete list of a
|
||||
captured = capfdbinary.readouterr()
|
||||
assert captured.out == b"hello\n"
|
||||
|
||||
doctest_namespace [session scope] -- .../_pytest/doctest.py:738
|
||||
capsys -- .../_pytest/capture.py:973
|
||||
Enable text capturing of writes to ``sys.stdout`` and ``sys.stderr``.
|
||||
|
||||
The captured output is made available via ``capsys.readouterr()`` method
|
||||
calls, which return a ``(out, err)`` namedtuple.
|
||||
``out`` and ``err`` will be ``text`` objects.
|
||||
|
||||
Returns an instance of :class:`CaptureFixture[str] <pytest.CaptureFixture>`.
|
||||
|
||||
Example:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
def test_output(capsys):
|
||||
print("hello")
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out == "hello\n"
|
||||
|
||||
doctest_namespace [session scope] -- .../_pytest/doctest.py:737
|
||||
Fixture that returns a :py:class:`dict` that will be injected into the
|
||||
namespace of doctests.
|
||||
|
||||
@@ -119,7 +119,7 @@ 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:1351
|
||||
pytestconfig [session scope] -- .../_pytest/fixtures.py:1360
|
||||
Session-scoped fixture that returns the session's :class:`pytest.Config`
|
||||
object.
|
||||
|
||||
@@ -196,7 +196,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:491
|
||||
caplog -- .../_pytest/logging.py:498
|
||||
Access and control log capturing.
|
||||
|
||||
Captured logs are available through the following properties/methods::
|
||||
@@ -207,7 +207,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:29
|
||||
monkeypatch -- .../_pytest/monkeypatch.py:30
|
||||
A convenient fixture for monkey-patching.
|
||||
|
||||
The fixture provides these methods to modify objects, dictionaries, or
|
||||
@@ -237,17 +237,19 @@ For information about fixtures, see :ref:`fixtures`. To see a complete list of a
|
||||
See https://docs.pytest.org/en/latest/how-to/capture-warnings.html for information
|
||||
on warning categories.
|
||||
|
||||
tmp_path_factory [session scope] -- .../_pytest/tmpdir.py:188
|
||||
tmp_path_factory [session scope] -- .../_pytest/tmpdir.py:245
|
||||
Return a :class:`pytest.TempPathFactory` instance for the test session.
|
||||
|
||||
tmp_path -- .../_pytest/tmpdir.py:203
|
||||
tmp_path -- .../_pytest/tmpdir.py:260
|
||||
Return a temporary directory path object which is unique to each test
|
||||
function invocation, created as a sub directory of the base temporary
|
||||
directory.
|
||||
|
||||
By default, a new base temporary directory is created each test session,
|
||||
and old bases are removed after 3 sessions, to aid in debugging. If
|
||||
``--basetemp`` is used then it is cleared each session. See :ref:`base
|
||||
and old bases are removed after 3 sessions, to aid in debugging.
|
||||
This behavior can be configured with :confval:`tmp_path_retention_count` and
|
||||
:confval:`tmp_path_retention_policy`.
|
||||
If ``--basetemp`` is used then it is cleared each session. See :ref:`base
|
||||
temporary directory`.
|
||||
|
||||
The returned object is a :class:`pathlib.Path` object.
|
||||
|
||||
+187
-12
@@ -28,6 +28,186 @@ with advance notice in the **Deprecations** section of releases.
|
||||
|
||||
.. towncrier release notes start
|
||||
|
||||
pytest 7.3.2 (2023-06-10)
|
||||
=========================
|
||||
|
||||
Bug Fixes
|
||||
---------
|
||||
|
||||
- `#10169 <https://github.com/pytest-dev/pytest/issues/10169>`_: Fix bug where very long option names could cause pytest to break with ``OSError: [Errno 36] File name too long`` on some systems.
|
||||
|
||||
|
||||
- `#10894 <https://github.com/pytest-dev/pytest/issues/10894>`_: Support for Python 3.12 (beta at the time of writing).
|
||||
|
||||
|
||||
- `#10987 <https://github.com/pytest-dev/pytest/issues/10987>`_: :confval:`testpaths` is now honored to load root ``conftests``.
|
||||
|
||||
|
||||
- `#10999 <https://github.com/pytest-dev/pytest/issues/10999>`_: The `monkeypatch` `setitem`/`delitem` type annotations now allow `TypedDict` arguments.
|
||||
|
||||
|
||||
- `#11028 <https://github.com/pytest-dev/pytest/issues/11028>`_: Fixed bug in assertion rewriting where a variable assigned with the walrus operator could not be used later in a function call.
|
||||
|
||||
|
||||
- `#11054 <https://github.com/pytest-dev/pytest/issues/11054>`_: Fixed ``--last-failed``'s "(skipped N files)" functionality for files inside of packages (directories with `__init__.py` files).
|
||||
|
||||
|
||||
pytest 7.3.1 (2023-04-14)
|
||||
=========================
|
||||
|
||||
Improvements
|
||||
------------
|
||||
|
||||
- `#10875 <https://github.com/pytest-dev/pytest/issues/10875>`_: Python 3.12 support: fixed ``RuntimeError: TestResult has no addDuration method`` when running ``unittest`` tests.
|
||||
|
||||
|
||||
- `#10890 <https://github.com/pytest-dev/pytest/issues/10890>`_: Python 3.12 support: fixed ``shutil.rmtree(onerror=...)`` deprecation warning when using :fixture:`tmp_path`.
|
||||
|
||||
|
||||
|
||||
Bug Fixes
|
||||
---------
|
||||
|
||||
- `#10896 <https://github.com/pytest-dev/pytest/issues/10896>`_: Fixed performance regression related to :fixture:`tmp_path` and the new :confval:`tmp_path_retention_policy` option.
|
||||
|
||||
|
||||
- `#10903 <https://github.com/pytest-dev/pytest/issues/10903>`_: Fix crash ``INTERNALERROR IndexError: list index out of range`` which happens when displaying an exception where all entries are hidden.
|
||||
This reverts the change "Correctly handle ``__tracebackhide__`` for chained exceptions." introduced in version 7.3.0.
|
||||
|
||||
|
||||
pytest 7.3.0 (2023-04-08)
|
||||
=========================
|
||||
|
||||
Features
|
||||
--------
|
||||
|
||||
- `#10525 <https://github.com/pytest-dev/pytest/issues/10525>`_: Test methods decorated with ``@classmethod`` can now be discovered as tests, following the same rules as normal methods. This fills the gap that static methods were discoverable as tests but not class methods.
|
||||
|
||||
|
||||
- `#10755 <https://github.com/pytest-dev/pytest/issues/10755>`_: :confval:`console_output_style` now supports ``progress-even-when-capture-no`` to force the use of the progress output even when capture is disabled. This is useful in large test suites where capture may have significant performance impact.
|
||||
|
||||
|
||||
- `#7431 <https://github.com/pytest-dev/pytest/issues/7431>`_: ``--log-disable`` CLI option added to disable individual loggers.
|
||||
|
||||
|
||||
- `#8141 <https://github.com/pytest-dev/pytest/issues/8141>`_: Added :confval:`tmp_path_retention_count` and :confval:`tmp_path_retention_policy` configuration options to control how directories created by the :fixture:`tmp_path` fixture are kept.
|
||||
|
||||
|
||||
|
||||
Improvements
|
||||
------------
|
||||
|
||||
- `#10226 <https://github.com/pytest-dev/pytest/issues/10226>`_: If multiple errors are raised in teardown, we now re-raise an ``ExceptionGroup`` of them instead of discarding all but the last.
|
||||
|
||||
|
||||
- `#10658 <https://github.com/pytest-dev/pytest/issues/10658>`_: Allow ``-p`` arguments to include spaces (eg: ``-p no:logging`` instead of
|
||||
``-pno:logging``). Mostly useful in the ``addopts`` section of the configuration
|
||||
file.
|
||||
|
||||
|
||||
- `#10710 <https://github.com/pytest-dev/pytest/issues/10710>`_: Added ``start`` and ``stop`` timestamps to ``TestReport`` objects.
|
||||
|
||||
|
||||
- `#10727 <https://github.com/pytest-dev/pytest/issues/10727>`_: Split the report header for ``rootdir``, ``config file`` and ``testpaths`` so each has its own line.
|
||||
|
||||
|
||||
- `#10840 <https://github.com/pytest-dev/pytest/issues/10840>`_: pytest should no longer crash on AST with pathological position attributes, for example testing AST produced by `Hylang <https://github.com/hylang/hy>__`.
|
||||
|
||||
|
||||
- `#6267 <https://github.com/pytest-dev/pytest/issues/6267>`_: The full output of a test is no longer truncated if the truncation message would be longer than
|
||||
the hidden text. The line number shown has also been fixed.
|
||||
|
||||
|
||||
|
||||
Bug Fixes
|
||||
---------
|
||||
|
||||
- `#10743 <https://github.com/pytest-dev/pytest/issues/10743>`_: The assertion rewriting mechanism now works correctly when assertion expressions contain the walrus operator.
|
||||
|
||||
|
||||
- `#10765 <https://github.com/pytest-dev/pytest/issues/10765>`_: Fixed :fixture:`tmp_path` fixture always raising :class:`OSError` on ``emscripten`` platform due to missing :func:`os.getuid`.
|
||||
|
||||
|
||||
- `#1904 <https://github.com/pytest-dev/pytest/issues/1904>`_: Correctly handle ``__tracebackhide__`` for chained exceptions.
|
||||
NOTE: This change was reverted in version 7.3.1.
|
||||
|
||||
|
||||
|
||||
Improved Documentation
|
||||
----------------------
|
||||
|
||||
- `#10782 <https://github.com/pytest-dev/pytest/issues/10782>`_: Fixed the minimal example in :ref:`goodpractices`: ``pip install -e .`` requires a ``version`` entry in ``pyproject.toml`` to run successfully.
|
||||
|
||||
|
||||
|
||||
Trivial/Internal Changes
|
||||
------------------------
|
||||
|
||||
- `#10669 <https://github.com/pytest-dev/pytest/issues/10669>`_: pytest no longer directly depends on the `attrs <https://www.attrs.org/en/stable/>`__ package. While
|
||||
we at pytest all love the package dearly and would like to thank the ``attrs`` team for many years of cooperation and support,
|
||||
it makes sense for ``pytest`` to have as little external dependencies as possible, as this helps downstream projects.
|
||||
With that in mind, we have replaced the pytest's limited internal usage to use the standard library's ``dataclasses`` instead.
|
||||
|
||||
Nice diffs for ``attrs`` classes are still supported though.
|
||||
|
||||
|
||||
pytest 7.2.2 (2023-03-03)
|
||||
=========================
|
||||
|
||||
Bug Fixes
|
||||
---------
|
||||
|
||||
- `#10533 <https://github.com/pytest-dev/pytest/issues/10533>`_: Fixed :func:`pytest.approx` handling of dictionaries containing one or more values of `0.0`.
|
||||
|
||||
|
||||
- `#10592 <https://github.com/pytest-dev/pytest/issues/10592>`_: Fixed crash if `--cache-show` and `--help` are passed at the same time.
|
||||
|
||||
|
||||
- `#10597 <https://github.com/pytest-dev/pytest/issues/10597>`_: Fixed bug where a fixture method named ``teardown`` would be called as part of ``nose`` teardown stage.
|
||||
|
||||
|
||||
- `#10626 <https://github.com/pytest-dev/pytest/issues/10626>`_: Fixed crash if ``--fixtures`` and ``--help`` are passed at the same time.
|
||||
|
||||
|
||||
- `#10660 <https://github.com/pytest-dev/pytest/issues/10660>`_: Fixed :py:func:`pytest.raises` to return a 'ContextManager' so that type-checkers could narrow
|
||||
:code:`pytest.raises(...) if ... else nullcontext()` down to 'ContextManager' rather than 'object'.
|
||||
|
||||
|
||||
|
||||
Improved Documentation
|
||||
----------------------
|
||||
|
||||
- `#10690 <https://github.com/pytest-dev/pytest/issues/10690>`_: Added `CI` and `BUILD_NUMBER` environment variables to the documentation.
|
||||
|
||||
|
||||
- `#10721 <https://github.com/pytest-dev/pytest/issues/10721>`_: Fixed entry-points declaration in the documentation example using Hatch.
|
||||
|
||||
|
||||
- `#10753 <https://github.com/pytest-dev/pytest/issues/10753>`_: Changed wording of the module level skip to be very explicit
|
||||
about not collecting tests and not executing the rest of the module.
|
||||
|
||||
|
||||
pytest 7.2.1 (2023-01-13)
|
||||
=========================
|
||||
|
||||
Bug Fixes
|
||||
---------
|
||||
|
||||
- `#10452 <https://github.com/pytest-dev/pytest/issues/10452>`_: Fix 'importlib.abc.TraversableResources' deprecation warning in Python 3.12.
|
||||
|
||||
|
||||
- `#10457 <https://github.com/pytest-dev/pytest/issues/10457>`_: If a test is skipped from inside a fixture, the test summary now shows the test location instead of the fixture location.
|
||||
|
||||
|
||||
- `#10506 <https://github.com/pytest-dev/pytest/issues/10506>`_: Fix bug where sometimes pytest would use the file system root directory as :ref:`rootdir <rootdir>` on Windows.
|
||||
|
||||
|
||||
- `#10607 <https://github.com/pytest-dev/pytest/issues/10607>`_: Fix a race condition when creating junitxml reports, which could occur when multiple instances of pytest execute in parallel.
|
||||
|
||||
|
||||
- `#10641 <https://github.com/pytest-dev/pytest/issues/10641>`_: Fix a race condition when creating or updating the stepwise plugin's cache, which could occur when multiple xdist worker nodes try to simultaneously update the stepwise plugin's cache.
|
||||
|
||||
|
||||
pytest 7.2.0 (2022-10-23)
|
||||
=========================
|
||||
|
||||
@@ -57,6 +237,7 @@ Deprecations
|
||||
|
||||
.. _`with-setup-nose`: https://nose.readthedocs.io/en/latest/testing_tools.html?highlight=with_setup#nose.tools.with_setup
|
||||
|
||||
- `#7337 <https://github.com/pytest-dev/pytest/issues/7337>`_: A deprecation warning is now emitted if a test function returns something other than `None`. This prevents a common mistake among beginners that expect that returning a `bool` (for example `return foo(a, b) == result`) would cause a test to pass or fail, instead of using `assert`. The plan is to make returning non-`None` from tests an error in the future.
|
||||
|
||||
|
||||
Features
|
||||
@@ -79,10 +260,7 @@ Improvements
|
||||
- `#10381 <https://github.com/pytest-dev/pytest/issues/10381>`_: The ``--no-showlocals`` flag has been added. This can be passed directly to tests to override ``--showlocals`` declared through ``addopts``.
|
||||
|
||||
|
||||
- `#3426 <https://github.com/pytest-dev/pytest/issues/3426>`_: Assertion failures with strings in NFC and NFD forms that normalize to the same string now have a dedicated error message detailing the issue, and their utf-8 representation is expresed instead.
|
||||
|
||||
|
||||
- `#7337 <https://github.com/pytest-dev/pytest/issues/7337>`_: A warning is now emitted if a test function returns something other than `None`. This prevents a common mistake among beginners that expect that returning a `bool` (for example `return foo(a, b) == result`) would cause a test to pass or fail, instead of using `assert`.
|
||||
- `#3426 <https://github.com/pytest-dev/pytest/issues/3426>`_: Assertion failures with strings in NFC and NFD forms that normalize to the same string now have a dedicated error message detailing the issue, and their utf-8 representation is expressed instead.
|
||||
|
||||
|
||||
- `#8508 <https://github.com/pytest-dev/pytest/issues/8508>`_: Introduce multiline display for warning matching via :py:func:`pytest.warns` and
|
||||
@@ -95,7 +273,7 @@ Improvements
|
||||
|
||||
- `#9741 <https://github.com/pytest-dev/pytest/issues/9741>`_: On Python 3.11, use the standard library's :mod:`tomllib` to parse TOML.
|
||||
|
||||
:mod:`tomli`` is no longer a dependency on Python 3.11.
|
||||
:mod:`tomli` is no longer a dependency on Python 3.11.
|
||||
|
||||
|
||||
- `#9742 <https://github.com/pytest-dev/pytest/issues/9742>`_: Display assertion message without escaped newline characters with ``-vv``.
|
||||
@@ -110,7 +288,7 @@ Improvements
|
||||
- `#9883 <https://github.com/pytest-dev/pytest/issues/9883>`_: Normalize the help description of all command-line options.
|
||||
|
||||
|
||||
- `#9920 <https://github.com/pytest-dev/pytest/issues/9920>`_: Display full crash messages in ``short test summary info``, when runng in a CI environment.
|
||||
- `#9920 <https://github.com/pytest-dev/pytest/issues/9920>`_: Display full crash messages in ``short test summary info``, when running in a CI environment.
|
||||
|
||||
|
||||
- `#9987 <https://github.com/pytest-dev/pytest/issues/9987>`_: Added support for hidden configuration file by allowing ``.pytest.ini`` as an alternative to ``pytest.ini``.
|
||||
@@ -156,9 +334,6 @@ Improved Documentation
|
||||
Trivial/Internal Changes
|
||||
------------------------
|
||||
|
||||
- `#10196 <https://github.com/pytest-dev/pytest/issues/10196>`_: :class:`~pytest.PytestReturnNotNoneWarning` is now a subclass of :class:`~pytest.PytestRemovedIn8Warning`: the plan is to make returning non-``None`` from tests an error in the future.
|
||||
|
||||
|
||||
- `#10313 <https://github.com/pytest-dev/pytest/issues/10313>`_: Made ``_pytest.doctest.DoctestItem`` export ``pytest.DoctestItem`` for
|
||||
type check and runtime purposes. Made `_pytest.doctest` use internal APIs
|
||||
to avoid circular imports.
|
||||
@@ -173,7 +348,7 @@ Trivial/Internal Changes
|
||||
- `#9984 <https://github.com/pytest-dev/pytest/issues/9984>`_: Improve the error message when we attempt to access a fixture that has been
|
||||
torn down.
|
||||
Add an additional sentence to the docstring explaining when it's not a good
|
||||
idea to call getfixturevalue.
|
||||
idea to call ``getfixturevalue``.
|
||||
|
||||
|
||||
pytest 7.1.3 (2022-08-31)
|
||||
@@ -416,7 +591,7 @@ Breaking Changes
|
||||
- `#7259 <https://github.com/pytest-dev/pytest/issues/7259>`_: The :ref:`Node.reportinfo() <non-python tests>` function first return value type has been expanded from `py.path.local | str` to `os.PathLike[str] | str`.
|
||||
|
||||
Most plugins which refer to `reportinfo()` only define it as part of a custom :class:`pytest.Item` implementation.
|
||||
Since `py.path.local` is a `os.PathLike[str]`, these plugins are unaffacted.
|
||||
Since `py.path.local` is an `os.PathLike[str]`, these plugins are unaffacted.
|
||||
|
||||
Plugins and users which call `reportinfo()`, use the first return value and interact with it as a `py.path.local`, would need to adjust by calling `py.path.local(fspath)`.
|
||||
Although preferably, avoid the legacy `py.path.local` and use `pathlib.Path`, or use `item.location` or `item.path`, instead.
|
||||
@@ -3916,7 +4091,7 @@ Removals
|
||||
See our :ref:`docs <calling fixtures directly deprecated>` on information on how to update your code.
|
||||
|
||||
|
||||
- :issue:`4546`: Remove ``Node.get_marker(name)`` the return value was not usable for more than a existence check.
|
||||
- :issue:`4546`: Remove ``Node.get_marker(name)`` the return value was not usable for more than an existence check.
|
||||
|
||||
Use ``Node.get_closest_marker(name)`` as a replacement.
|
||||
|
||||
|
||||
+1
-1
@@ -341,7 +341,7 @@ epub_copyright = "2013, holger krekel et alii"
|
||||
# The scheme of the identifier. Typical schemes are ISBN or URL.
|
||||
# epub_scheme = ''
|
||||
|
||||
# The unique identifier of the text. This can be a ISBN number
|
||||
# The unique identifier of the text. This can be an ISBN number
|
||||
# or the project homepage.
|
||||
# epub_identifier = ''
|
||||
|
||||
|
||||
@@ -1052,7 +1052,7 @@ that are then turned into proper test methods. Example:
|
||||
.. code-block:: python
|
||||
|
||||
def check(x, y):
|
||||
assert x ** x == y
|
||||
assert x**x == y
|
||||
|
||||
|
||||
def test_squared():
|
||||
@@ -1067,7 +1067,7 @@ This form of test function doesn't support fixtures properly, and users should s
|
||||
|
||||
@pytest.mark.parametrize("x, y", [(2, 4), (3, 9)])
|
||||
def test_squared(x, y):
|
||||
assert x ** x == y
|
||||
assert x**x == y
|
||||
|
||||
.. _internal classes accessed through node deprecated:
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ def b(a, order):
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def c(a, b, order):
|
||||
def c(b, order):
|
||||
order.append("c")
|
||||
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ class YamlItem(pytest.Item):
|
||||
" no further details known at this point.",
|
||||
]
|
||||
)
|
||||
return super().repr_failure(excinfo)
|
||||
|
||||
def reportinfo(self):
|
||||
return self.path, 0, f"usecase: {self.name}"
|
||||
|
||||
@@ -504,9 +504,9 @@ Running it results in some skips if we don't have all the python interpreters in
|
||||
. $ pytest -rs -q multipython.py
|
||||
sssssssssssssssssssssssssss [100%]
|
||||
========================= short test summary info ==========================
|
||||
SKIPPED [9] multipython.py:29: 'python3.5' not found
|
||||
SKIPPED [9] multipython.py:29: 'python3.6' not found
|
||||
SKIPPED [9] multipython.py:29: 'python3.7' not found
|
||||
SKIPPED [9] multipython.py:69: 'python3.5' not found
|
||||
SKIPPED [9] multipython.py:69: 'python3.6' not found
|
||||
SKIPPED [9] multipython.py:69: 'python3.7' not found
|
||||
27 skipped in 0.12s
|
||||
|
||||
Indirect parametrization of optional implementations/imports
|
||||
@@ -574,7 +574,7 @@ If you run this with reporting for skips enabled:
|
||||
test_module.py .s [100%]
|
||||
|
||||
========================= short test summary info ==========================
|
||||
SKIPPED [1] conftest.py:12: could not import 'opt2': No module named 'opt2'
|
||||
SKIPPED [1] test_module.py:3: could not import 'opt2': No module named 'opt2'
|
||||
======================= 1 passed, 1 skipped in 0.12s =======================
|
||||
|
||||
You'll see that we don't have an ``opt2`` module and thus the second test run
|
||||
|
||||
@@ -148,7 +148,8 @@ The test collection would look like this:
|
||||
$ pytest --collect-only
|
||||
=========================== test session starts ============================
|
||||
platform linux -- Python 3.x.y, pytest-7.x.y, pluggy-1.x.y
|
||||
rootdir: /home/sweet/project, configfile: pytest.ini
|
||||
rootdir: /home/sweet/project
|
||||
configfile: pytest.ini
|
||||
collected 2 items
|
||||
|
||||
<Module check_myapp.py>
|
||||
@@ -209,7 +210,8 @@ You can always peek at the collection tree without running tests like this:
|
||||
. $ pytest --collect-only pythoncollection.py
|
||||
=========================== test session starts ============================
|
||||
platform linux -- Python 3.x.y, pytest-7.x.y, pluggy-1.x.y
|
||||
rootdir: /home/sweet/project, configfile: pytest.ini
|
||||
rootdir: /home/sweet/project
|
||||
configfile: pytest.ini
|
||||
collected 3 items
|
||||
|
||||
<Module CWD/pythoncollection.py>
|
||||
@@ -290,7 +292,8 @@ file will be left out:
|
||||
$ pytest --collect-only
|
||||
=========================== test session starts ============================
|
||||
platform linux -- Python 3.x.y, pytest-7.x.y, pluggy-1.x.y
|
||||
rootdir: /home/sweet/project, configfile: pytest.ini
|
||||
rootdir: /home/sweet/project
|
||||
configfile: pytest.ini
|
||||
collected 0 items
|
||||
|
||||
======================= no tests collected in 0.12s ========================
|
||||
|
||||
@@ -144,7 +144,7 @@ Here is a nice run of several failures and how ``pytest`` presents things:
|
||||
E 1
|
||||
E 1...
|
||||
E
|
||||
E ...Full output truncated (7 lines hidden), use '-vv' to show
|
||||
E ...Full output truncated (6 lines hidden), use '-vv' to show
|
||||
|
||||
failure_demo.py:60: AssertionError
|
||||
_________________ TestSpecialisedExplanations.test_eq_list _________________
|
||||
@@ -184,9 +184,8 @@ Here is a nice run of several failures and how ``pytest`` presents things:
|
||||
E Left contains 1 more item:
|
||||
E {'c': 0}
|
||||
E Right contains 1 more item:
|
||||
E {'d': 0}...
|
||||
E
|
||||
E ...Full output truncated (2 lines hidden), use '-vv' to show
|
||||
E {'d': 0}
|
||||
E Use -v to get more diff
|
||||
|
||||
failure_demo.py:71: AssertionError
|
||||
_________________ TestSpecialisedExplanations.test_eq_set __________________
|
||||
@@ -195,16 +194,15 @@ Here is a nice run of several failures and how ``pytest`` presents things:
|
||||
|
||||
def test_eq_set(self):
|
||||
> assert {0, 10, 11, 12} == {0, 20, 21}
|
||||
E AssertionError: assert {0, 10, 11, 12} == {0, 20, 21}
|
||||
E assert {0, 10, 11, 12} == {0, 20, 21}
|
||||
E Extra items in the left set:
|
||||
E 10
|
||||
E 11
|
||||
E 12
|
||||
E Extra items in the right set:
|
||||
E 20
|
||||
E 21...
|
||||
E
|
||||
E ...Full output truncated (2 lines hidden), use '-vv' to show
|
||||
E 21
|
||||
E Use -v to get more diff
|
||||
|
||||
failure_demo.py:74: AssertionError
|
||||
_____________ TestSpecialisedExplanations.test_eq_longer_list ______________
|
||||
@@ -241,9 +239,8 @@ Here is a nice run of several failures and how ``pytest`` presents things:
|
||||
E which
|
||||
E includes foo
|
||||
E ? +++
|
||||
E and a...
|
||||
E
|
||||
E ...Full output truncated (2 lines hidden), use '-vv' to show
|
||||
E and a
|
||||
E tail
|
||||
|
||||
failure_demo.py:84: AssertionError
|
||||
___________ TestSpecialisedExplanations.test_not_in_text_single ____________
|
||||
@@ -307,9 +304,9 @@ Here is a nice run of several failures and how ``pytest`` presents things:
|
||||
E ['b']
|
||||
E
|
||||
E Drill down into differing attribute b:
|
||||
E b: 'b' != 'c'...
|
||||
E
|
||||
E ...Full output truncated (3 lines hidden), use '-vv' to show
|
||||
E b: 'b' != 'c'
|
||||
E - c
|
||||
E + b
|
||||
|
||||
failure_demo.py:108: AssertionError
|
||||
________________ TestSpecialisedExplanations.test_eq_attrs _________________
|
||||
@@ -334,9 +331,9 @@ Here is a nice run of several failures and how ``pytest`` presents things:
|
||||
E ['b']
|
||||
E
|
||||
E Drill down into differing attribute b:
|
||||
E b: 'b' != 'c'...
|
||||
E
|
||||
E ...Full output truncated (3 lines hidden), use '-vv' to show
|
||||
E b: 'b' != 'c'
|
||||
E - c
|
||||
E + b
|
||||
|
||||
failure_demo.py:120: AssertionError
|
||||
______________________________ test_attribute ______________________________
|
||||
@@ -673,7 +670,7 @@ Here is a nice run of several failures and how ``pytest`` presents things:
|
||||
FAILED failure_demo.py::TestSpecialisedExplanations::test_eq_list - asser...
|
||||
FAILED failure_demo.py::TestSpecialisedExplanations::test_eq_list_long - ...
|
||||
FAILED failure_demo.py::TestSpecialisedExplanations::test_eq_dict - Asser...
|
||||
FAILED failure_demo.py::TestSpecialisedExplanations::test_eq_set - Assert...
|
||||
FAILED failure_demo.py::TestSpecialisedExplanations::test_eq_set - assert...
|
||||
FAILED failure_demo.py::TestSpecialisedExplanations::test_eq_longer_list
|
||||
FAILED failure_demo.py::TestSpecialisedExplanations::test_in_list - asser...
|
||||
FAILED failure_demo.py::TestSpecialisedExplanations::test_not_in_text_multiline
|
||||
|
||||
+14
-12
@@ -691,7 +691,7 @@ Here is an example for making a ``db`` fixture available in a directory:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
@pytest.fixture(scope="package")
|
||||
def db():
|
||||
return DB()
|
||||
|
||||
@@ -892,8 +892,11 @@ here is a little example implemented via a local plugin:
|
||||
.. code-block:: python
|
||||
|
||||
# content of conftest.py
|
||||
|
||||
from typing import Dict
|
||||
import pytest
|
||||
from pytest import StashKey, CollectReport
|
||||
|
||||
phase_report_key = StashKey[Dict[str, CollectReport]]()
|
||||
|
||||
|
||||
@pytest.hookimpl(tryfirst=True, hookwrapper=True)
|
||||
@@ -902,10 +905,9 @@ here is a little example implemented via a local plugin:
|
||||
outcome = yield
|
||||
rep = outcome.get_result()
|
||||
|
||||
# set a report attribute for each phase of a call, which can
|
||||
# store test results for each phase of a call, which can
|
||||
# be "setup", "call", "teardown"
|
||||
|
||||
setattr(item, "rep_" + rep.when, rep)
|
||||
item.stash.setdefault(phase_report_key, {})[rep.when] = rep
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -913,11 +915,11 @@ here is a little example implemented via a local plugin:
|
||||
yield
|
||||
# request.node is an "item" because we use the default
|
||||
# "function" scope
|
||||
if request.node.rep_setup.failed:
|
||||
print("setting up a test failed!", request.node.nodeid)
|
||||
elif request.node.rep_setup.passed:
|
||||
if request.node.rep_call.failed:
|
||||
print("executing test failed", request.node.nodeid)
|
||||
report = request.node.stash[phase_report_key]
|
||||
if report["setup"].failed:
|
||||
print("setting up a test failed or skipped", request.node.nodeid)
|
||||
elif ("call" not in report) or report["call"].failed:
|
||||
print("executing test failed or skipped", request.node.nodeid)
|
||||
|
||||
|
||||
if you then have failing tests:
|
||||
@@ -955,8 +957,8 @@ and run it:
|
||||
rootdir: /home/sweet/project
|
||||
collected 3 items
|
||||
|
||||
test_module.py Esetting up a test failed! test_module.py::test_setup_fails
|
||||
Fexecuting test failed test_module.py::test_call_fails
|
||||
test_module.py Esetting up a test failed or skipped test_module.py::test_setup_fails
|
||||
Fexecuting test failed or skipped test_module.py::test_call_fails
|
||||
F
|
||||
|
||||
================================== ERRORS ==================================
|
||||
|
||||
@@ -24,8 +24,9 @@ The first few lines should look like this:
|
||||
|
||||
[project]
|
||||
name = "PACKAGENAME"
|
||||
version = "PACKAGEVERSION"
|
||||
|
||||
where ``PACKAGENAME`` is the name of your package.
|
||||
where ``PACKAGENAME`` and ``PACKAGEVERSION`` are the name and version of your package respectively.
|
||||
|
||||
You can then install your package in "editable" mode by running from the same directory:
|
||||
|
||||
@@ -50,8 +51,8 @@ Conventions for Python test discovery
|
||||
* In those directories, search for ``test_*.py`` or ``*_test.py`` files, imported by their `test package name`_.
|
||||
* From those files, collect test items:
|
||||
|
||||
* ``test`` prefixed test functions or methods outside of class
|
||||
* ``test`` prefixed test functions or methods inside ``Test`` prefixed test classes (without an ``__init__`` method)
|
||||
* ``test`` prefixed test functions or methods outside of class.
|
||||
* ``test`` prefixed test functions or methods inside ``Test`` prefixed test classes (without an ``__init__`` method). Methods decorated with ``@staticmethod`` and ``@classmethods`` are also considered.
|
||||
|
||||
For examples of how to customize your test discovery :doc:`/example/pythoncollection`.
|
||||
|
||||
@@ -270,8 +271,8 @@ tox
|
||||
|
||||
Once you are done with your work and want to make sure that your actual
|
||||
package passes all tests you may want to look into :doc:`tox <tox:index>`, the
|
||||
virtualenv test automation tool and its :doc:`pytest support <tox:example/pytest>`.
|
||||
tox helps you to setup virtualenv environments with pre-defined
|
||||
virtualenv test automation tool.
|
||||
``tox`` helps you to setup virtualenv environments with pre-defined
|
||||
dependencies and then executing a pre-configured test command with
|
||||
options. It will run tests against the installed package and not
|
||||
against your source code checkout, helping to detect packaging
|
||||
@@ -293,3 +294,20 @@ See also `pypa/setuptools#1684 <https://github.com/pypa/setuptools/issues/1684>`
|
||||
|
||||
setuptools intends to
|
||||
`remove the test command <https://github.com/pypa/setuptools/issues/931>`_.
|
||||
|
||||
Checking with flake8-pytest-style
|
||||
---------------------------------
|
||||
|
||||
In order to ensure that pytest is being used correctly in your project,
|
||||
it can be helpful to use the `flake8-pytest-style <https://github.com/m-burst/flake8-pytest-style>`_ flake8 plugin.
|
||||
|
||||
flake8-pytest-style checks for common mistakes and coding style violations in pytest code,
|
||||
such as incorrect use of fixtures, test function names, and markers.
|
||||
By using this plugin, you can catch these errors early in the development process
|
||||
and ensure that your pytest code is consistent and easy to maintain.
|
||||
|
||||
A list of the lints detected by flake8-pytest-style can be found on its `PyPI page <https://pypi.org/project/flake8-pytest-style/>`_.
|
||||
|
||||
.. note::
|
||||
|
||||
flake8-pytest-style is not an official pytest project. Some of the rules enforce certain style choices, such as using `@pytest.fixture()` over `@pytest.fixture`, but you can configure the plugin to fit your preferred style.
|
||||
|
||||
@@ -16,7 +16,7 @@ import process can be controlled through the ``--import-mode`` command-line flag
|
||||
these values:
|
||||
|
||||
* ``prepend`` (default): the directory path containing each module will be inserted into the *beginning*
|
||||
of :py:data:`sys.path` if not already there, and then imported with the :func:`__import__ <__import__>` builtin.
|
||||
of :py:data:`sys.path` if not already there, and then imported with the :func:`importlib.import_module <importlib.import_module>` function.
|
||||
|
||||
This requires test module names to be unique when the test directory tree is not arranged in
|
||||
packages, because the modules will put in :py:data:`sys.modules` after importing.
|
||||
@@ -24,7 +24,7 @@ these values:
|
||||
This is the classic mechanism, dating back from the time Python 2 was still supported.
|
||||
|
||||
* ``append``: the directory containing each module is appended to the end of :py:data:`sys.path` if not already
|
||||
there, and imported with ``__import__``.
|
||||
there, and imported with :func:`importlib.import_module <importlib.import_module>`.
|
||||
|
||||
This better allows to run test modules against installed versions of a package even if the
|
||||
package under test has the same import root. For example:
|
||||
@@ -43,7 +43,7 @@ these values:
|
||||
Same as ``prepend``, requires test module names to be unique when the test directory tree is
|
||||
not arranged in packages, because the modules will put in :py:data:`sys.modules` after importing.
|
||||
|
||||
* ``importlib``: new in pytest-6.0, this mode uses :mod:`importlib` to import test modules. This gives full control over the import process, and doesn't require changing :py:data:`sys.path`.
|
||||
* ``importlib``: new in pytest-6.0, this mode uses more fine control mechanisms provided by :mod:`importlib` to import test modules. This gives full control over the import process, and doesn't require changing :py:data:`sys.path`.
|
||||
|
||||
For this reason this doesn't require test module names to be unique.
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ Install ``pytest``
|
||||
.. code-block:: bash
|
||||
|
||||
$ pytest --version
|
||||
pytest 7.2.0
|
||||
pytest 7.3.2
|
||||
|
||||
.. _`simpletest`:
|
||||
|
||||
|
||||
@@ -109,6 +109,18 @@ When a warning matches more than one option in the list, the action for the last
|
||||
is performed.
|
||||
|
||||
|
||||
.. note::
|
||||
|
||||
The ``-W`` flag and the ``filterwarnings`` ini option use warning filters that are
|
||||
similar in structure, but each configuration option interprets its filter
|
||||
differently. For example, *message* in ``filterwarnings`` is a string containing a
|
||||
regular expression that the start of the warning message must match,
|
||||
case-insensitively, while *message* in ``-W`` is a literal string that the start of
|
||||
the warning message must contain (case-insensitively), ignoring any whitespace at
|
||||
the start or end of message. Consult the `warning filter`_ documentation for more
|
||||
details.
|
||||
|
||||
|
||||
.. _`filterwarnings`:
|
||||
|
||||
``@pytest.mark.filterwarnings``
|
||||
@@ -270,20 +282,34 @@ which works in a similar manner to :ref:`raises <assertraises>` (except that
|
||||
warnings.warn("my warning", UserWarning)
|
||||
|
||||
The test will fail if the warning in question is not raised. Use the keyword
|
||||
argument ``match`` to assert that the warning matches a text or regex::
|
||||
argument ``match`` to assert that the warning matches a text or regex.
|
||||
To match a literal string that may contain regular expression metacharacters like ``(`` or ``.``, the pattern can
|
||||
first be escaped with ``re.escape``.
|
||||
|
||||
>>> with warns(UserWarning, match='must be 0 or None'):
|
||||
Some examples:
|
||||
|
||||
.. code-block:: pycon
|
||||
|
||||
|
||||
>>> with warns(UserWarning, match="must be 0 or None"):
|
||||
... warnings.warn("value must be 0 or None", UserWarning)
|
||||
...
|
||||
|
||||
>>> with warns(UserWarning, match=r'must be \d+$'):
|
||||
>>> with warns(UserWarning, match=r"must be \d+$"):
|
||||
... warnings.warn("value must be 42", UserWarning)
|
||||
...
|
||||
|
||||
>>> with warns(UserWarning, match=r'must be \d+$'):
|
||||
>>> with warns(UserWarning, match=r"must be \d+$"):
|
||||
... warnings.warn("this is not here", UserWarning)
|
||||
...
|
||||
Traceback (most recent call last):
|
||||
...
|
||||
Failed: DID NOT WARN. No warnings of type ...UserWarning... were emitted...
|
||||
|
||||
>>> with warns(UserWarning, match=re.escape("issue with foo() func")):
|
||||
... warnings.warn("issue with foo() func")
|
||||
...
|
||||
|
||||
You can also call :func:`pytest.warns` on a function or code string:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
@@ -1237,7 +1237,6 @@ If the data created by the factory requires managing, the fixture can take care
|
||||
|
||||
@pytest.fixture
|
||||
def make_customer_record():
|
||||
|
||||
created_records = []
|
||||
|
||||
def _make_customer_record(name):
|
||||
|
||||
@@ -55,6 +55,13 @@ These options can also be customized through ``pytest.ini`` file:
|
||||
log_format = %(asctime)s %(levelname)s %(message)s
|
||||
log_date_format = %Y-%m-%d %H:%M:%S
|
||||
|
||||
Specific loggers can be disabled via ``--log-disable={logger_name}``.
|
||||
This argument can be passed multiple times:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
pytest --log-disable=main --log-disable=testing
|
||||
|
||||
Further it is possible to disable reporting of captured content (stdout,
|
||||
stderr and logs) on failed tests completely with:
|
||||
|
||||
|
||||
@@ -135,10 +135,10 @@ This can be done in our test file by defining a class to represent ``r``.
|
||||
# this is the previous code block example
|
||||
import app
|
||||
|
||||
|
||||
# custom class to be the mock return value
|
||||
# will override the requests.Response returned from requests.get
|
||||
class MockResponse:
|
||||
|
||||
# mock json() method always returns a specific testing dictionary
|
||||
@staticmethod
|
||||
def json():
|
||||
@@ -146,7 +146,6 @@ This can be done in our test file by defining a class to represent ``r``.
|
||||
|
||||
|
||||
def test_get_json(monkeypatch):
|
||||
|
||||
# Any arguments may be passed and mock_get() will always return our
|
||||
# mocked object, which only has the .json() method.
|
||||
def mock_get(*args, **kwargs):
|
||||
@@ -181,6 +180,7 @@ This mock can be shared across tests using a ``fixture``:
|
||||
# app.py that includes the get_json() function
|
||||
import app
|
||||
|
||||
|
||||
# custom class to be the mock return value of requests.get()
|
||||
class MockResponse:
|
||||
@staticmethod
|
||||
@@ -358,7 +358,6 @@ For testing purposes we can patch the ``DEFAULT_CONFIG`` dictionary to specific
|
||||
|
||||
|
||||
def test_connection(monkeypatch):
|
||||
|
||||
# Patch the values of DEFAULT_CONFIG to specific
|
||||
# testing values only for this test.
|
||||
monkeypatch.setitem(app.DEFAULT_CONFIG, "user", "test_user")
|
||||
@@ -383,7 +382,6 @@ You can use the :py:meth:`monkeypatch.delitem <MonkeyPatch.delitem>` to remove v
|
||||
|
||||
|
||||
def test_missing_user(monkeypatch):
|
||||
|
||||
# patch the DEFAULT_CONFIG t be missing the 'user' key
|
||||
monkeypatch.delitem(app.DEFAULT_CONFIG, "user", raising=False)
|
||||
|
||||
@@ -404,6 +402,7 @@ separate fixtures for each potential mock and reference them in the needed tests
|
||||
# app.py with the connection string function
|
||||
import app
|
||||
|
||||
|
||||
# all of the mocks are moved into separated fixtures
|
||||
@pytest.fixture
|
||||
def mock_test_user(monkeypatch):
|
||||
@@ -425,7 +424,6 @@ separate fixtures for each potential mock and reference them in the needed tests
|
||||
|
||||
# tests reference only the fixture mocks that are needed
|
||||
def test_connection(mock_test_user, mock_test_database):
|
||||
|
||||
expected = "User Id=test_user; Location=test_db;"
|
||||
|
||||
result = app.create_connection_string()
|
||||
@@ -433,7 +431,6 @@ separate fixtures for each potential mock and reference them in the needed tests
|
||||
|
||||
|
||||
def test_missing_user(mock_missing_default_user):
|
||||
|
||||
with pytest.raises(KeyError):
|
||||
_ = app.create_connection_string()
|
||||
|
||||
|
||||
@@ -167,9 +167,9 @@ Now we can increase pytest's verbosity:
|
||||
E Right contains 4 more items:
|
||||
E {'10': 10, '20': 20, '30': 30, '40': 40}
|
||||
E Full diff:
|
||||
E - {'0': 0, '10': 10, '20': 20, '30': 30, '40': 40}...
|
||||
E
|
||||
E ...Full output truncated (3 lines hidden), use '-vv' to show
|
||||
E - {'0': 0, '10': 10, '20': 20, '30': 30, '40': 40}
|
||||
E ? - - - - - - - -
|
||||
E + {'0': 0, '1': 1, '2': 2, '3': 3, '4': 4}
|
||||
|
||||
test_verbosity_example.py:14: AssertionError
|
||||
___________________________ test_long_text_fail ____________________________
|
||||
|
||||
@@ -131,10 +131,12 @@ The default base temporary directory
|
||||
|
||||
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. Moreover, entries older
|
||||
than 3 temporary directories will be removed.
|
||||
``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 number of entries currently cannot be changed, but using the ``--basetemp``
|
||||
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.
|
||||
|
||||
|
||||
@@ -35,11 +35,12 @@ Pytest supports several ways to run and select tests from the command-line.
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
pytest -k "MyClass and not method"
|
||||
pytest -k 'MyClass and not method'
|
||||
|
||||
This will run tests which contain names that match the given *string expression* (case-insensitive),
|
||||
which can include Python operators that use filenames, class names and function names as variables.
|
||||
The example above will run ``TestMyClass.test_something`` but not ``TestMyClass.test_method_simple``.
|
||||
Use ``""`` instead of ``''`` in expression when running this on Windows
|
||||
|
||||
.. _nodeids:
|
||||
|
||||
|
||||
@@ -249,6 +249,7 @@ and use pytest_addoption as follows:
|
||||
|
||||
# contents of hooks.py
|
||||
|
||||
|
||||
# Use firstresult=True because we only want one plugin to define this
|
||||
# default value
|
||||
@hookspec(firstresult=True)
|
||||
|
||||
@@ -167,13 +167,8 @@ it in your ``pyproject.toml`` file.
|
||||
"Framework :: Pytest",
|
||||
]
|
||||
|
||||
[tool.setuptools]
|
||||
packages = ["myproject"]
|
||||
|
||||
[project.entry_points]
|
||||
pytest11 = [
|
||||
"myproject = myproject.pluginmodule",
|
||||
]
|
||||
[project.entry-points.pytest11]
|
||||
myproject = "myproject.pluginmodule"
|
||||
|
||||
If a package is installed this way, ``pytest`` will load
|
||||
``myproject.pluginmodule`` as a plugin which can define
|
||||
@@ -454,7 +449,8 @@ in our ``pytest.ini`` to tell pytest where to look for example files.
|
||||
$ pytest
|
||||
=========================== test session starts ============================
|
||||
platform linux -- Python 3.x.y, pytest-7.x.y, pluggy-1.x.y
|
||||
rootdir: /home/sweet/project, configfile: pytest.ini
|
||||
rootdir: /home/sweet/project
|
||||
configfile: pytest.ini
|
||||
collected 2 items
|
||||
|
||||
test_example.py .. [100%]
|
||||
|
||||
+1
-2
@@ -2,8 +2,7 @@
|
||||
|
||||
.. sidebar:: Next Open Trainings
|
||||
|
||||
- Professionelles Testen für Python mit pytest, part of `enterPy <https://www.enterpy.de/>`__ (German), `October 28th <https://www.enterpy.de/veranstaltung-15409-se-0-professionelles-testen-fuer-python-mit-pytest.html>`__ (sold out) and `November 4th <https://www.enterpy.de/veranstaltung-15557-se-0-professionelles-testen-fuer-python-mit-pytest-zusatztermin.html>`__, online
|
||||
- `Professional Testing with Python <https://python-academy.com/courses/python_course_testing.html>`_, via `Python Academy <https://www.python-academy.com/>`_, March 7th to 9th 2023 (3 day in-depth training), Remote and Leipzig, Germany
|
||||
- `Professional Testing with Python <https://python-academy.com/courses/python_course_testing.html>`_, via `Python Academy <https://www.python-academy.com/>`_, March 5th to 7th 2024 (3 day in-depth training), Leipzig/Remote
|
||||
|
||||
Also see :doc:`previous talks and blogposts <talks>`.
|
||||
|
||||
|
||||
@@ -335,7 +335,7 @@ For example:
|
||||
|
||||
.. literalinclude:: /example/fixtures/test_fixtures_order_dependencies.py
|
||||
|
||||
If we map out what depends on what, we get something that look like this:
|
||||
If we map out what depends on what, we get something that looks like this:
|
||||
|
||||
.. image:: /example/fixtures/test_fixtures_order_dependencies.*
|
||||
:align: center
|
||||
|
||||
+2654
-1492
File diff suppressed because it is too large
Load Diff
@@ -956,6 +956,12 @@ TestReport
|
||||
:show-inheritance:
|
||||
:inherited-members:
|
||||
|
||||
TestShortLogReport
|
||||
~~~~~~~~~~~~~~~~~~
|
||||
|
||||
.. autoclass:: pytest.TestShortLogReport()
|
||||
:members:
|
||||
|
||||
_Result
|
||||
~~~~~~~
|
||||
|
||||
@@ -1047,6 +1053,14 @@ Environment Variables
|
||||
|
||||
Environment variables that can be used to change pytest's behavior.
|
||||
|
||||
.. envvar:: CI
|
||||
|
||||
When set (regardless of value), pytest acknowledges that is running in a CI process. Alternative to ``BUILD_NUMBER`` variable.
|
||||
|
||||
.. envvar:: BUILD_NUMBER
|
||||
|
||||
When set (regardless of value), pytest acknowledges that is running in a CI process. Alternative to CI variable.
|
||||
|
||||
.. envvar:: PYTEST_ADDOPTS
|
||||
|
||||
This contains a command-line (parsed by the py:mod:`shlex` module) that will be **prepended** to the command line given
|
||||
@@ -1212,6 +1226,7 @@ passed multiple times. The expected format is ``name=value``. For example::
|
||||
|
||||
* ``classic``: classic pytest output.
|
||||
* ``progress``: like classic pytest output, but with a progress indicator.
|
||||
* ``progress-even-when-capture-no``: allows the use of the progress indicator even when ``capture=no``.
|
||||
* ``count``: like progress, but shows progress as the number of tests completed instead of a percent.
|
||||
|
||||
The default is ``progress``, but you can fallback to ``classic`` if you prefer or
|
||||
@@ -1704,13 +1719,12 @@ passed multiple times. The expected format is ``name=value``. For example::
|
||||
|
||||
.. confval:: testpaths
|
||||
|
||||
|
||||
|
||||
Sets list of directories that should be searched for tests when
|
||||
no specific directories, files or test ids are given in the command line when
|
||||
executing pytest from the :ref:`rootdir <rootdir>` directory.
|
||||
File system paths may use shell-style wildcards, including the recursive
|
||||
``**`` pattern.
|
||||
|
||||
Useful when all project tests are in a known location to speed up
|
||||
test collection and to avoid picking up undesired tests by accident.
|
||||
|
||||
@@ -1719,8 +1733,51 @@ passed multiple times. The expected format is ``name=value``. For example::
|
||||
[pytest]
|
||||
testpaths = testing doc
|
||||
|
||||
This tells pytest to only look for tests in ``testing`` and ``doc``
|
||||
directories when executing from the root directory.
|
||||
This configuration means that executing:
|
||||
|
||||
.. code-block:: console
|
||||
|
||||
pytest
|
||||
|
||||
has the same practical effects as executing:
|
||||
|
||||
.. code-block:: console
|
||||
|
||||
pytest testing doc
|
||||
|
||||
|
||||
.. confval:: tmp_path_retention_count
|
||||
|
||||
|
||||
|
||||
How many sessions should we keep the `tmp_path` directories,
|
||||
according to `tmp_path_retention_policy`.
|
||||
|
||||
.. code-block:: ini
|
||||
|
||||
[pytest]
|
||||
tmp_path_retention_count = 3
|
||||
|
||||
Default: ``3``
|
||||
|
||||
|
||||
.. confval:: tmp_path_retention_policy
|
||||
|
||||
|
||||
|
||||
Controls which directories created by the `tmp_path` fixture are kept around,
|
||||
based on test outcome.
|
||||
|
||||
* `all`: retains directories for all tests, regardless of the outcome.
|
||||
* `failed`: retains directories only for tests with outcome `error` or `failed`.
|
||||
* `none`: directories are always removed after each test ends, regardless of the outcome.
|
||||
|
||||
.. code-block:: ini
|
||||
|
||||
[pytest]
|
||||
tmp_path_retention_policy = "all"
|
||||
|
||||
Default: ``all``
|
||||
|
||||
|
||||
.. confval:: usefixtures
|
||||
@@ -1861,7 +1918,8 @@ All the command-line flags can be obtained by running ``pytest --help``::
|
||||
--strict-markers Markers not registered in the `markers` section of
|
||||
the configuration file raise errors
|
||||
--strict (Deprecated) alias to --strict-markers
|
||||
-c file Load configuration from `file` instead of trying to
|
||||
-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
|
||||
@@ -1952,8 +2010,11 @@ All the command-line flags can be obtained by running ``pytest --help``::
|
||||
--log-auto-indent=LOG_AUTO_INDENT
|
||||
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.
|
||||
|
||||
[pytest] ini-options in the first pytest.ini|tox.ini|setup.cfg file found:
|
||||
[pytest] ini-options in the first pytest.ini|tox.ini|setup.cfg|pyproject.toml file found:
|
||||
|
||||
markers (linelist): Markers for test functions
|
||||
empty_parameter_set_mark (string):
|
||||
@@ -1981,9 +2042,18 @@ All the command-line flags can be obtained by running ``pytest --help``::
|
||||
console_output_style (string):
|
||||
Console output: "classic", or with additional
|
||||
progress information ("progress" (percentage) |
|
||||
"count")
|
||||
"count" | "progress-even-when-capture-no" (forces
|
||||
progress even when capture=no)
|
||||
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`.
|
||||
tmp_path_retention_policy (string):
|
||||
Controls which directories created by the `tmp_path`
|
||||
fixture are kept around, based on test outcome.
|
||||
(all/failed/none)
|
||||
enable_assertion_pass_hook (bool):
|
||||
Enables the pytest_assertion_pass hook. Make sure to
|
||||
delete any previously generated pyc cache files.
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
pallets-sphinx-themes
|
||||
pluggy>=1.0
|
||||
pygments-pytest>=2.2.0
|
||||
pygments-pytest>=2.3.0
|
||||
sphinx-removed-in>=0.2.0
|
||||
sphinx>=5,<6
|
||||
sphinxcontrib-trio
|
||||
sphinxcontrib-svg2pdfconverter
|
||||
# Pin packaging because it no longer handles 'latest' version, which
|
||||
# is the version that is assigned to the docs.
|
||||
# See https://github.com/pytest-dev/pytest/pull/10578#issuecomment-1348249045.
|
||||
packaging <22
|
||||
|
||||
@@ -114,3 +114,8 @@ template = "changelog/_template.rst"
|
||||
|
||||
[tool.black]
|
||||
target-version = ['py37']
|
||||
|
||||
# check-wheel-contents is executed by the build-and-inspect-python-package action.
|
||||
[tool.check-wheel-contents]
|
||||
# W009: Wheel contains multiple toplevel library entries
|
||||
ignore = "W009"
|
||||
|
||||
@@ -17,7 +17,9 @@ Plugin List
|
||||
===========
|
||||
|
||||
PyPI projects that match "pytest-\*" are considered plugins and are listed
|
||||
automatically. Packages classified as inactive are excluded.
|
||||
automatically together with a manually-maintained list in `the source
|
||||
code <https://github.com/pytest-dev/pytest/blob/main/scripts/update-plugin-list.py>`_.
|
||||
Packages classified as inactive are excluded.
|
||||
|
||||
.. The following conditional uses a different format for this list when
|
||||
creating a PDF, because otherwise the table gets far too wide for the
|
||||
@@ -33,6 +35,9 @@ DEVELOPMENT_STATUS_CLASSIFIERS = (
|
||||
"Development Status :: 6 - Mature",
|
||||
"Development Status :: 7 - Inactive",
|
||||
)
|
||||
ADDITIONAL_PROJECTS = { # set of additional projects to consider as plugins
|
||||
"logassert",
|
||||
}
|
||||
|
||||
|
||||
def escape_rst(text: str) -> str:
|
||||
@@ -52,18 +57,18 @@ def iter_plugins():
|
||||
regex = r">([\d\w-]*)</a>"
|
||||
response = requests.get("https://pypi.org/simple")
|
||||
|
||||
matches = list(
|
||||
match
|
||||
for match in re.finditer(regex, response.text)
|
||||
if match.groups()[0].startswith("pytest-")
|
||||
)
|
||||
match_names = (match.groups()[0] for match in re.finditer(regex, response.text))
|
||||
plugin_names = [
|
||||
name
|
||||
for name in match_names
|
||||
if name.startswith("pytest-") or name in ADDITIONAL_PROJECTS
|
||||
]
|
||||
|
||||
for match in tqdm(matches, smoothing=0):
|
||||
name = match.groups()[0]
|
||||
for name in tqdm(plugin_names, smoothing=0):
|
||||
response = requests.get(f"https://pypi.org/pypi/{name}/json")
|
||||
if response.status_code == 404:
|
||||
# Some packages, like pytest-azurepipelines42, are included in https://pypi.org/simple but
|
||||
# return 404 on the JSON API. Skip.
|
||||
# Some packages, like pytest-azurepipelines42, are included in https://pypi.org/simple
|
||||
# but return 404 on the JSON API. Skip.
|
||||
continue
|
||||
response.raise_for_status()
|
||||
info = response.json()["info"]
|
||||
@@ -78,11 +83,23 @@ def iter_plugins():
|
||||
requires = "N/A"
|
||||
if info["requires_dist"]:
|
||||
for requirement in info["requires_dist"]:
|
||||
if requirement == "pytest" or "pytest " in requirement:
|
||||
if re.match(r"pytest(?![-.\w])", requirement):
|
||||
requires = requirement
|
||||
break
|
||||
|
||||
def version_sort_key(version_string):
|
||||
"""
|
||||
Return the sort key for the given version string
|
||||
returned by the API.
|
||||
"""
|
||||
try:
|
||||
return packaging.version.parse(version_string)
|
||||
except packaging.version.InvalidVersion:
|
||||
# Use a hard-coded pre-release version.
|
||||
return packaging.version.Version("0.0.0alpha")
|
||||
|
||||
releases = response.json()["releases"]
|
||||
for release in sorted(releases, key=packaging.version.parse, reverse=True):
|
||||
for release in sorted(releases, key=version_sort_key, reverse=True):
|
||||
if releases[release]:
|
||||
release_date = datetime.date.fromisoformat(
|
||||
releases[release][-1]["upload_time_iso_8601"].split("T")[0]
|
||||
@@ -90,7 +107,9 @@ def iter_plugins():
|
||||
last_release = release_date.strftime("%b %d, %Y")
|
||||
break
|
||||
name = f':pypi:`{info["name"]}`'
|
||||
summary = escape_rst(info["summary"].replace("\n", ""))
|
||||
summary = ""
|
||||
if info["summary"]:
|
||||
summary = escape_rst(info["summary"].replace("\n", ""))
|
||||
yield {
|
||||
"name": name,
|
||||
"summary": summary.strip(),
|
||||
@@ -122,7 +141,7 @@ def main():
|
||||
reference_dir = pathlib.Path("doc", "en", "reference")
|
||||
|
||||
plugin_list = reference_dir / "plugin_list.rst"
|
||||
with plugin_list.open("w") as f:
|
||||
with plugin_list.open("w", encoding="UTF-8") as f:
|
||||
f.write(FILE_HEAD)
|
||||
f.write(f"This list contains {len(plugins)} plugins.\n\n")
|
||||
f.write(".. only:: not latex\n\n")
|
||||
|
||||
@@ -6,7 +6,7 @@ long_description_content_type = text/x-rst
|
||||
url = https://docs.pytest.org/en/latest/
|
||||
author = Holger Krekel, Bruno Oliveira, Ronny Pfannschmidt, Floris Bruynooghe, Brianna Laugher, Florian Bruhin and others
|
||||
license = MIT
|
||||
license_file = LICENSE
|
||||
license_files = LICENSE
|
||||
platforms = unix, linux, osx, cygwin, win32
|
||||
classifiers =
|
||||
Development Status :: 6 - Mature
|
||||
@@ -21,6 +21,8 @@ classifiers =
|
||||
Programming Language :: Python :: 3.8
|
||||
Programming Language :: Python :: 3.9
|
||||
Programming Language :: Python :: 3.10
|
||||
Programming Language :: Python :: 3.11
|
||||
Programming Language :: Python :: 3.12
|
||||
Topic :: Software Development :: Libraries
|
||||
Topic :: Software Development :: Testing
|
||||
Topic :: Utilities
|
||||
@@ -43,7 +45,6 @@ packages =
|
||||
pytest
|
||||
py_modules = py
|
||||
install_requires =
|
||||
attrs>=19.2.0
|
||||
iniconfig
|
||||
packaging
|
||||
pluggy>=0.12,<2.0
|
||||
@@ -67,11 +68,13 @@ console_scripts =
|
||||
[options.extras_require]
|
||||
testing =
|
||||
argcomplete
|
||||
attrs>=19.2.0
|
||||
hypothesis>=3.56
|
||||
mock
|
||||
nose
|
||||
pygments>=2.7.2
|
||||
requests
|
||||
setuptools
|
||||
xmlschema
|
||||
|
||||
[options.package_data]
|
||||
@@ -95,7 +98,6 @@ mypy_path = src
|
||||
check_untyped_defs = True
|
||||
disallow_any_generics = True
|
||||
ignore_missing_imports = True
|
||||
no_implicit_optional = True
|
||||
show_error_codes = True
|
||||
strict_equality = True
|
||||
warn_redundant_casts = True
|
||||
|
||||
@@ -78,15 +78,15 @@ class FastFilesCompleter:
|
||||
|
||||
def __call__(self, prefix: str, **kwargs: Any) -> List[str]:
|
||||
# Only called on non option completions.
|
||||
if os.path.sep in prefix[1:]:
|
||||
prefix_dir = len(os.path.dirname(prefix) + os.path.sep)
|
||||
if os.sep in prefix[1:]:
|
||||
prefix_dir = len(os.path.dirname(prefix) + os.sep)
|
||||
else:
|
||||
prefix_dir = 0
|
||||
completion = []
|
||||
globbed = []
|
||||
if "*" not in prefix and "?" not in prefix:
|
||||
# We are on unix, otherwise no bash.
|
||||
if not prefix or prefix[-1] == os.path.sep:
|
||||
if not prefix or prefix[-1] == os.sep:
|
||||
globbed.extend(glob(prefix + ".*"))
|
||||
prefix += "*"
|
||||
globbed.extend(glob(prefix))
|
||||
|
||||
+156
-111
@@ -1,4 +1,5 @@
|
||||
import ast
|
||||
import dataclasses
|
||||
import inspect
|
||||
import os
|
||||
import re
|
||||
@@ -30,9 +31,7 @@ from typing import Type
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TypeVar
|
||||
from typing import Union
|
||||
from weakref import ref
|
||||
|
||||
import attr
|
||||
import pluggy
|
||||
|
||||
import _pytest
|
||||
@@ -50,9 +49,9 @@ from _pytest.pathlib import absolutepath
|
||||
from _pytest.pathlib import bestrelpath
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing_extensions import Final
|
||||
from typing_extensions import Literal
|
||||
from typing_extensions import SupportsIndex
|
||||
from weakref import ReferenceType
|
||||
|
||||
_TracebackStyle = Literal["long", "short", "line", "no", "native", "value", "auto"]
|
||||
|
||||
@@ -194,25 +193,25 @@ class Frame:
|
||||
class TracebackEntry:
|
||||
"""A single entry in a Traceback."""
|
||||
|
||||
__slots__ = ("_rawentry", "_excinfo", "_repr_style")
|
||||
__slots__ = ("_rawentry", "_repr_style")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
rawentry: TracebackType,
|
||||
excinfo: Optional["ReferenceType[ExceptionInfo[BaseException]]"] = None,
|
||||
repr_style: Optional['Literal["short", "long"]'] = None,
|
||||
) -> None:
|
||||
self._rawentry = rawentry
|
||||
self._excinfo = excinfo
|
||||
self._repr_style: Optional['Literal["short", "long"]'] = None
|
||||
self._rawentry: "Final" = rawentry
|
||||
self._repr_style: "Final" = repr_style
|
||||
|
||||
def with_repr_style(
|
||||
self, repr_style: Optional['Literal["short", "long"]']
|
||||
) -> "TracebackEntry":
|
||||
return TracebackEntry(self._rawentry, repr_style)
|
||||
|
||||
@property
|
||||
def lineno(self) -> int:
|
||||
return self._rawentry.tb_lineno - 1
|
||||
|
||||
def set_repr_style(self, mode: "Literal['short', 'long']") -> None:
|
||||
assert mode in ("short", "long")
|
||||
self._repr_style = mode
|
||||
|
||||
@property
|
||||
def frame(self) -> Frame:
|
||||
return Frame(self._rawentry.tb_frame)
|
||||
@@ -272,7 +271,7 @@ class TracebackEntry:
|
||||
|
||||
source = property(getsource)
|
||||
|
||||
def ishidden(self) -> bool:
|
||||
def ishidden(self, excinfo: Optional["ExceptionInfo[BaseException]"]) -> bool:
|
||||
"""Return True if the current frame has a var __tracebackhide__
|
||||
resolving to True.
|
||||
|
||||
@@ -296,7 +295,7 @@ class TracebackEntry:
|
||||
else:
|
||||
break
|
||||
if tbh and callable(tbh):
|
||||
return tbh(None if self._excinfo is None else self._excinfo())
|
||||
return tbh(excinfo)
|
||||
return tbh
|
||||
|
||||
def __str__(self) -> str:
|
||||
@@ -329,16 +328,14 @@ class Traceback(List[TracebackEntry]):
|
||||
def __init__(
|
||||
self,
|
||||
tb: Union[TracebackType, Iterable[TracebackEntry]],
|
||||
excinfo: Optional["ReferenceType[ExceptionInfo[BaseException]]"] = None,
|
||||
) -> None:
|
||||
"""Initialize from given python traceback object and ExceptionInfo."""
|
||||
self._excinfo = excinfo
|
||||
if isinstance(tb, TracebackType):
|
||||
|
||||
def f(cur: TracebackType) -> Iterable[TracebackEntry]:
|
||||
cur_: Optional[TracebackType] = cur
|
||||
while cur_ is not None:
|
||||
yield TracebackEntry(cur_, excinfo=excinfo)
|
||||
yield TracebackEntry(cur_)
|
||||
cur_ = cur_.tb_next
|
||||
|
||||
super().__init__(f(tb))
|
||||
@@ -378,7 +375,7 @@ class Traceback(List[TracebackEntry]):
|
||||
continue
|
||||
if firstlineno is not None and x.frame.code.firstlineno != firstlineno:
|
||||
continue
|
||||
return Traceback(x._rawentry, self._excinfo)
|
||||
return Traceback(x._rawentry)
|
||||
return self
|
||||
|
||||
@overload
|
||||
@@ -398,26 +395,27 @@ class Traceback(List[TracebackEntry]):
|
||||
return super().__getitem__(key)
|
||||
|
||||
def filter(
|
||||
self, fn: Callable[[TracebackEntry], bool] = lambda x: not x.ishidden()
|
||||
self,
|
||||
# TODO(py38): change to positional only.
|
||||
_excinfo_or_fn: Union[
|
||||
"ExceptionInfo[BaseException]",
|
||||
Callable[[TracebackEntry], bool],
|
||||
],
|
||||
) -> "Traceback":
|
||||
"""Return a Traceback instance with certain items removed
|
||||
"""Return a Traceback instance with certain items removed.
|
||||
|
||||
fn is a function that gets a single argument, a TracebackEntry
|
||||
instance, and should return True when the item should be added
|
||||
to the Traceback, False when not.
|
||||
If the filter is an `ExceptionInfo`, removes all the ``TracebackEntry``s
|
||||
which are hidden (see ishidden() above).
|
||||
|
||||
By default this removes all the TracebackEntries which are hidden
|
||||
(see ishidden() above).
|
||||
Otherwise, the filter is a function that gets a single argument, a
|
||||
``TracebackEntry`` instance, and should return True when the item should
|
||||
be added to the ``Traceback``, False when not.
|
||||
"""
|
||||
return Traceback(filter(fn, self), self._excinfo)
|
||||
|
||||
def getcrashentry(self) -> TracebackEntry:
|
||||
"""Return last non-hidden traceback entry that lead to the exception of a traceback."""
|
||||
for i in range(-1, -len(self) - 1, -1):
|
||||
entry = self[i]
|
||||
if not entry.ishidden():
|
||||
return entry
|
||||
return self[-1]
|
||||
if isinstance(_excinfo_or_fn, ExceptionInfo):
|
||||
fn = lambda x: not x.ishidden(_excinfo_or_fn) # noqa: E731
|
||||
else:
|
||||
fn = _excinfo_or_fn
|
||||
return Traceback(filter(fn, self))
|
||||
|
||||
def recursionindex(self) -> Optional[int]:
|
||||
"""Return the index of the frame/TracebackEntry where recursion originates if
|
||||
@@ -445,7 +443,7 @@ E = TypeVar("E", bound=BaseException, covariant=True)
|
||||
|
||||
|
||||
@final
|
||||
@attr.s(repr=False, init=False, auto_attribs=True)
|
||||
@dataclasses.dataclass
|
||||
class ExceptionInfo(Generic[E]):
|
||||
"""Wraps sys.exc_info() objects and offers help for navigating the traceback."""
|
||||
|
||||
@@ -469,22 +467,41 @@ class ExceptionInfo(Generic[E]):
|
||||
self._traceback = traceback
|
||||
|
||||
@classmethod
|
||||
def from_exc_info(
|
||||
def from_exception(
|
||||
cls,
|
||||
exc_info: Tuple[Type[E], E, TracebackType],
|
||||
# Ignoring error: "Cannot use a covariant type variable as a parameter".
|
||||
# This is OK to ignore because this class is (conceptually) readonly.
|
||||
# See https://github.com/python/mypy/issues/7049.
|
||||
exception: E, # type: ignore[misc]
|
||||
exprinfo: Optional[str] = None,
|
||||
) -> "ExceptionInfo[E]":
|
||||
"""Return an ExceptionInfo for an existing exc_info tuple.
|
||||
"""Return an ExceptionInfo for an existing exception.
|
||||
|
||||
.. warning::
|
||||
|
||||
Experimental API
|
||||
The exception must have a non-``None`` ``__traceback__`` attribute,
|
||||
otherwise this function fails with an assertion error. This means that
|
||||
the exception must have been raised, or added a traceback with the
|
||||
:py:meth:`~BaseException.with_traceback()` method.
|
||||
|
||||
:param exprinfo:
|
||||
A text string helping to determine if we should strip
|
||||
``AssertionError`` from the output. Defaults to the exception
|
||||
message/``__str__()``.
|
||||
|
||||
.. versionadded:: 7.4
|
||||
"""
|
||||
assert (
|
||||
exception.__traceback__
|
||||
), "Exceptions passed to ExcInfo.from_exception(...) must have a non-None __traceback__."
|
||||
exc_info = (type(exception), exception, exception.__traceback__)
|
||||
return cls.from_exc_info(exc_info, exprinfo)
|
||||
|
||||
@classmethod
|
||||
def from_exc_info(
|
||||
cls,
|
||||
exc_info: Tuple[Type[E], E, TracebackType],
|
||||
exprinfo: Optional[str] = None,
|
||||
) -> "ExceptionInfo[E]":
|
||||
"""Like :func:`from_exception`, but using old-style exc_info tuple."""
|
||||
_striptext = ""
|
||||
if exprinfo is None and isinstance(exc_info[1], AssertionError):
|
||||
exprinfo = getattr(exc_info[1], "msg", None)
|
||||
@@ -563,7 +580,7 @@ class ExceptionInfo(Generic[E]):
|
||||
def traceback(self) -> Traceback:
|
||||
"""The traceback."""
|
||||
if self._traceback is None:
|
||||
self._traceback = Traceback(self.tb, excinfo=ref(self))
|
||||
self._traceback = Traceback(self.tb)
|
||||
return self._traceback
|
||||
|
||||
@traceback.setter
|
||||
@@ -602,18 +619,25 @@ class ExceptionInfo(Generic[E]):
|
||||
"""
|
||||
return isinstance(self.value, exc)
|
||||
|
||||
def _getreprcrash(self) -> "ReprFileLocation":
|
||||
exconly = self.exconly(tryshort=True)
|
||||
entry = self.traceback.getcrashentry()
|
||||
path, lineno = entry.frame.code.raw.co_filename, entry.lineno
|
||||
return ReprFileLocation(path, lineno + 1, exconly)
|
||||
def _getreprcrash(self) -> Optional["ReprFileLocation"]:
|
||||
# Find last non-hidden traceback entry that led to the exception of the
|
||||
# traceback, or None if all hidden.
|
||||
for i in range(-1, -len(self.traceback) - 1, -1):
|
||||
entry = self.traceback[i]
|
||||
if not entry.ishidden(self):
|
||||
path, lineno = entry.frame.code.raw.co_filename, entry.lineno
|
||||
exconly = self.exconly(tryshort=True)
|
||||
return ReprFileLocation(path, lineno + 1, exconly)
|
||||
return None
|
||||
|
||||
def getrepr(
|
||||
self,
|
||||
showlocals: bool = False,
|
||||
style: "_TracebackStyle" = "long",
|
||||
abspath: bool = False,
|
||||
tbfilter: bool = True,
|
||||
tbfilter: Union[
|
||||
bool, Callable[["ExceptionInfo[BaseException]"], Traceback]
|
||||
] = True,
|
||||
funcargs: bool = False,
|
||||
truncate_locals: bool = True,
|
||||
chain: bool = True,
|
||||
@@ -625,14 +649,20 @@ class ExceptionInfo(Generic[E]):
|
||||
Ignored if ``style=="native"``.
|
||||
|
||||
:param str style:
|
||||
long|short|no|native|value traceback style.
|
||||
long|short|line|no|native|value traceback style.
|
||||
|
||||
:param bool abspath:
|
||||
If paths should be changed to absolute or left unchanged.
|
||||
|
||||
:param bool tbfilter:
|
||||
Hide entries that contain a local variable ``__tracebackhide__==True``.
|
||||
Ignored if ``style=="native"``.
|
||||
:param tbfilter:
|
||||
A filter for traceback entries.
|
||||
|
||||
* If false, don't hide any entries.
|
||||
* If true, hide internal entries and entries that contain a local
|
||||
variable ``__tracebackhide__ = True``.
|
||||
* If a callable, delegates the filtering to the callable.
|
||||
|
||||
Ignored if ``style`` is ``"native"``.
|
||||
|
||||
:param bool funcargs:
|
||||
Show fixtures ("funcargs" for legacy purposes) per traceback entry.
|
||||
@@ -649,12 +679,14 @@ class ExceptionInfo(Generic[E]):
|
||||
"""
|
||||
if style == "native":
|
||||
return ReprExceptionInfo(
|
||||
ReprTracebackNative(
|
||||
reprtraceback=ReprTracebackNative(
|
||||
traceback.format_exception(
|
||||
self.type, self.value, self.traceback[0]._rawentry
|
||||
self.type,
|
||||
self.value,
|
||||
self.traceback[0]._rawentry if self.traceback else None,
|
||||
)
|
||||
),
|
||||
self._getreprcrash(),
|
||||
reprcrash=self._getreprcrash(),
|
||||
)
|
||||
|
||||
fmt = FormattedExcinfo(
|
||||
@@ -684,7 +716,7 @@ class ExceptionInfo(Generic[E]):
|
||||
return True
|
||||
|
||||
|
||||
@attr.s(auto_attribs=True)
|
||||
@dataclasses.dataclass
|
||||
class FormattedExcinfo:
|
||||
"""Presenting information about failing Functions and Generators."""
|
||||
|
||||
@@ -695,12 +727,12 @@ class FormattedExcinfo:
|
||||
showlocals: bool = False
|
||||
style: "_TracebackStyle" = "long"
|
||||
abspath: bool = True
|
||||
tbfilter: bool = True
|
||||
tbfilter: Union[bool, Callable[[ExceptionInfo[BaseException]], Traceback]] = True
|
||||
funcargs: bool = False
|
||||
truncate_locals: bool = True
|
||||
chain: bool = True
|
||||
astcache: Dict[Union[str, Path], ast.AST] = attr.ib(
|
||||
factory=dict, init=False, repr=False
|
||||
astcache: Dict[Union[str, Path], ast.AST] = dataclasses.field(
|
||||
default_factory=dict, init=False, repr=False
|
||||
)
|
||||
|
||||
def _getindent(self, source: "Source") -> int:
|
||||
@@ -741,11 +773,13 @@ class FormattedExcinfo:
|
||||
) -> List[str]:
|
||||
"""Return formatted and marked up source lines."""
|
||||
lines = []
|
||||
if source is None or line_index >= len(source.lines):
|
||||
if source is not None and line_index < 0:
|
||||
line_index += len(source)
|
||||
if source is None or line_index >= len(source.lines) or line_index < 0:
|
||||
# `line_index` could still be outside `range(len(source.lines))` if
|
||||
# we're processing AST with pathological position attributes.
|
||||
source = Source("???")
|
||||
line_index = 0
|
||||
if line_index < 0:
|
||||
line_index += len(source)
|
||||
space_prefix = " "
|
||||
if short:
|
||||
lines.append(space_prefix + source.lines[line_index].strip())
|
||||
@@ -805,12 +839,16 @@ class FormattedExcinfo:
|
||||
|
||||
def repr_traceback_entry(
|
||||
self,
|
||||
entry: TracebackEntry,
|
||||
entry: Optional[TracebackEntry],
|
||||
excinfo: Optional[ExceptionInfo[BaseException]] = None,
|
||||
) -> "ReprEntry":
|
||||
lines: List[str] = []
|
||||
style = entry._repr_style if entry._repr_style is not None else self.style
|
||||
if style in ("short", "long"):
|
||||
style = (
|
||||
entry._repr_style
|
||||
if entry is not None and entry._repr_style is not None
|
||||
else self.style
|
||||
)
|
||||
if style in ("short", "long") and entry is not None:
|
||||
source = self._getentrysource(entry)
|
||||
if source is None:
|
||||
source = Source("???")
|
||||
@@ -851,25 +889,31 @@ class FormattedExcinfo:
|
||||
|
||||
def repr_traceback(self, excinfo: ExceptionInfo[BaseException]) -> "ReprTraceback":
|
||||
traceback = excinfo.traceback
|
||||
if self.tbfilter:
|
||||
traceback = traceback.filter()
|
||||
if callable(self.tbfilter):
|
||||
traceback = self.tbfilter(excinfo)
|
||||
elif self.tbfilter:
|
||||
traceback = traceback.filter(excinfo)
|
||||
|
||||
if isinstance(excinfo.value, RecursionError):
|
||||
traceback, extraline = self._truncate_recursive_traceback(traceback)
|
||||
else:
|
||||
extraline = None
|
||||
|
||||
if not traceback:
|
||||
if extraline is None:
|
||||
extraline = "All traceback entries are hidden. Pass `--full-trace` to see hidden and internal frames."
|
||||
entries = [self.repr_traceback_entry(None, excinfo)]
|
||||
return ReprTraceback(entries, extraline, style=self.style)
|
||||
|
||||
last = traceback[-1]
|
||||
entries = []
|
||||
if self.style == "value":
|
||||
reprentry = self.repr_traceback_entry(last, excinfo)
|
||||
entries.append(reprentry)
|
||||
entries = [self.repr_traceback_entry(last, excinfo)]
|
||||
return ReprTraceback(entries, None, style=self.style)
|
||||
|
||||
for index, entry in enumerate(traceback):
|
||||
einfo = (last == entry) and excinfo or None
|
||||
reprentry = self.repr_traceback_entry(entry, einfo)
|
||||
entries.append(reprentry)
|
||||
entries = [
|
||||
self.repr_traceback_entry(entry, excinfo if last == entry else None)
|
||||
for entry in traceback
|
||||
]
|
||||
return ReprTraceback(entries, extraline, style=self.style)
|
||||
|
||||
def _truncate_recursive_traceback(
|
||||
@@ -926,6 +970,7 @@ class FormattedExcinfo:
|
||||
seen: Set[int] = set()
|
||||
while e is not None and id(e) not in seen:
|
||||
seen.add(id(e))
|
||||
|
||||
if excinfo_:
|
||||
# Fall back to native traceback as a temporary workaround until
|
||||
# full support for exception groups added to ExceptionInfo.
|
||||
@@ -942,9 +987,7 @@ class FormattedExcinfo:
|
||||
)
|
||||
else:
|
||||
reprtraceback = self.repr_traceback(excinfo_)
|
||||
reprcrash: Optional[ReprFileLocation] = (
|
||||
excinfo_._getreprcrash() if self.style != "value" else None
|
||||
)
|
||||
reprcrash = excinfo_._getreprcrash()
|
||||
else:
|
||||
# Fallback to native repr if the exception doesn't have a traceback:
|
||||
# ExceptionInfo objects require a full traceback to work.
|
||||
@@ -952,25 +995,17 @@ class FormattedExcinfo:
|
||||
traceback.format_exception(type(e), e, None)
|
||||
)
|
||||
reprcrash = None
|
||||
|
||||
repr_chain += [(reprtraceback, reprcrash, descr)]
|
||||
|
||||
if e.__cause__ is not None and self.chain:
|
||||
e = e.__cause__
|
||||
excinfo_ = (
|
||||
ExceptionInfo.from_exc_info((type(e), e, e.__traceback__))
|
||||
if e.__traceback__
|
||||
else None
|
||||
)
|
||||
excinfo_ = ExceptionInfo.from_exception(e) if e.__traceback__ else None
|
||||
descr = "The above exception was the direct cause of the following exception:"
|
||||
elif (
|
||||
e.__context__ is not None and not e.__suppress_context__ and self.chain
|
||||
):
|
||||
e = e.__context__
|
||||
excinfo_ = (
|
||||
ExceptionInfo.from_exc_info((type(e), e, e.__traceback__))
|
||||
if e.__traceback__
|
||||
else None
|
||||
)
|
||||
excinfo_ = ExceptionInfo.from_exception(e) if e.__traceback__ else None
|
||||
descr = "During handling of the above exception, another exception occurred:"
|
||||
else:
|
||||
e = None
|
||||
@@ -978,7 +1013,7 @@ class FormattedExcinfo:
|
||||
return ExceptionChainRepr(repr_chain)
|
||||
|
||||
|
||||
@attr.s(eq=False, auto_attribs=True)
|
||||
@dataclasses.dataclass(eq=False)
|
||||
class TerminalRepr:
|
||||
def __str__(self) -> str:
|
||||
# FYI this is called from pytest-xdist's serialization of exception
|
||||
@@ -996,14 +1031,14 @@ class TerminalRepr:
|
||||
|
||||
|
||||
# This class is abstract -- only subclasses are instantiated.
|
||||
@attr.s(eq=False)
|
||||
@dataclasses.dataclass(eq=False)
|
||||
class ExceptionRepr(TerminalRepr):
|
||||
# Provided by subclasses.
|
||||
reprcrash: Optional["ReprFileLocation"]
|
||||
reprtraceback: "ReprTraceback"
|
||||
|
||||
def __attrs_post_init__(self) -> None:
|
||||
self.sections: List[Tuple[str, str, str]] = []
|
||||
reprcrash: Optional["ReprFileLocation"]
|
||||
sections: List[Tuple[str, str, str]] = dataclasses.field(
|
||||
init=False, default_factory=list
|
||||
)
|
||||
|
||||
def addsection(self, name: str, content: str, sep: str = "-") -> None:
|
||||
self.sections.append((name, content, sep))
|
||||
@@ -1014,16 +1049,23 @@ class ExceptionRepr(TerminalRepr):
|
||||
tw.line(content)
|
||||
|
||||
|
||||
@attr.s(eq=False, auto_attribs=True)
|
||||
@dataclasses.dataclass(eq=False)
|
||||
class ExceptionChainRepr(ExceptionRepr):
|
||||
chain: Sequence[Tuple["ReprTraceback", Optional["ReprFileLocation"], Optional[str]]]
|
||||
|
||||
def __attrs_post_init__(self) -> None:
|
||||
super().__attrs_post_init__()
|
||||
def __init__(
|
||||
self,
|
||||
chain: Sequence[
|
||||
Tuple["ReprTraceback", Optional["ReprFileLocation"], Optional[str]]
|
||||
],
|
||||
) -> None:
|
||||
# reprcrash and reprtraceback of the outermost (the newest) exception
|
||||
# in the chain.
|
||||
self.reprtraceback = self.chain[-1][0]
|
||||
self.reprcrash = self.chain[-1][1]
|
||||
super().__init__(
|
||||
reprtraceback=chain[-1][0],
|
||||
reprcrash=chain[-1][1],
|
||||
)
|
||||
self.chain = chain
|
||||
|
||||
def toterminal(self, tw: TerminalWriter) -> None:
|
||||
for element in self.chain:
|
||||
@@ -1034,17 +1076,17 @@ class ExceptionChainRepr(ExceptionRepr):
|
||||
super().toterminal(tw)
|
||||
|
||||
|
||||
@attr.s(eq=False, auto_attribs=True)
|
||||
@dataclasses.dataclass(eq=False)
|
||||
class ReprExceptionInfo(ExceptionRepr):
|
||||
reprtraceback: "ReprTraceback"
|
||||
reprcrash: "ReprFileLocation"
|
||||
reprcrash: Optional["ReprFileLocation"]
|
||||
|
||||
def toterminal(self, tw: TerminalWriter) -> None:
|
||||
self.reprtraceback.toterminal(tw)
|
||||
super().toterminal(tw)
|
||||
|
||||
|
||||
@attr.s(eq=False, auto_attribs=True)
|
||||
@dataclasses.dataclass(eq=False)
|
||||
class ReprTraceback(TerminalRepr):
|
||||
reprentries: Sequence[Union["ReprEntry", "ReprEntryNative"]]
|
||||
extraline: Optional[str]
|
||||
@@ -1073,12 +1115,12 @@ class ReprTraceback(TerminalRepr):
|
||||
|
||||
class ReprTracebackNative(ReprTraceback):
|
||||
def __init__(self, tblines: Sequence[str]) -> None:
|
||||
self.style = "native"
|
||||
self.reprentries = [ReprEntryNative(tblines)]
|
||||
self.extraline = None
|
||||
self.style = "native"
|
||||
|
||||
|
||||
@attr.s(eq=False, auto_attribs=True)
|
||||
@dataclasses.dataclass(eq=False)
|
||||
class ReprEntryNative(TerminalRepr):
|
||||
lines: Sequence[str]
|
||||
|
||||
@@ -1088,7 +1130,7 @@ class ReprEntryNative(TerminalRepr):
|
||||
tw.write("".join(self.lines))
|
||||
|
||||
|
||||
@attr.s(eq=False, auto_attribs=True)
|
||||
@dataclasses.dataclass(eq=False)
|
||||
class ReprEntry(TerminalRepr):
|
||||
lines: Sequence[str]
|
||||
reprfuncargs: Optional["ReprFuncArgs"]
|
||||
@@ -1142,8 +1184,8 @@ class ReprEntry(TerminalRepr):
|
||||
|
||||
def toterminal(self, tw: TerminalWriter) -> None:
|
||||
if self.style == "short":
|
||||
assert self.reprfileloc is not None
|
||||
self.reprfileloc.toterminal(tw)
|
||||
if self.reprfileloc:
|
||||
self.reprfileloc.toterminal(tw)
|
||||
self._write_entry_lines(tw)
|
||||
if self.reprlocals:
|
||||
self.reprlocals.toterminal(tw, indent=" " * 8)
|
||||
@@ -1168,12 +1210,15 @@ class ReprEntry(TerminalRepr):
|
||||
)
|
||||
|
||||
|
||||
@attr.s(eq=False, auto_attribs=True)
|
||||
@dataclasses.dataclass(eq=False)
|
||||
class ReprFileLocation(TerminalRepr):
|
||||
path: str = attr.ib(converter=str)
|
||||
path: str
|
||||
lineno: int
|
||||
message: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.path = str(self.path)
|
||||
|
||||
def toterminal(self, tw: TerminalWriter) -> None:
|
||||
# Filename and lineno output for each entry, using an output format
|
||||
# that most editors understand.
|
||||
@@ -1185,7 +1230,7 @@ class ReprFileLocation(TerminalRepr):
|
||||
tw.line(f":{self.lineno}: {msg}")
|
||||
|
||||
|
||||
@attr.s(eq=False, auto_attribs=True)
|
||||
@dataclasses.dataclass(eq=False)
|
||||
class ReprLocals(TerminalRepr):
|
||||
lines: Sequence[str]
|
||||
|
||||
@@ -1194,7 +1239,7 @@ class ReprLocals(TerminalRepr):
|
||||
tw.line(indent + line)
|
||||
|
||||
|
||||
@attr.s(eq=False, auto_attribs=True)
|
||||
@dataclasses.dataclass(eq=False)
|
||||
class ReprFuncArgs(TerminalRepr):
|
||||
args: Sequence[Tuple[str, object]]
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ from stat import S_ISLNK
|
||||
from stat import S_ISREG
|
||||
from typing import Any
|
||||
from typing import Callable
|
||||
from typing import cast
|
||||
from typing import overload
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
@@ -146,7 +147,7 @@ class Visitor:
|
||||
self.fil = fil
|
||||
self.ignore = ignore
|
||||
self.breadthfirst = bf
|
||||
self.optsort = sort and sorted or (lambda x: x)
|
||||
self.optsort = cast(Callable[[Any], Any], sorted) if sort else (lambda x: x)
|
||||
|
||||
def gen(self, path):
|
||||
try:
|
||||
@@ -224,7 +225,7 @@ class Stat:
|
||||
raise NotImplementedError("XXX win32")
|
||||
import pwd
|
||||
|
||||
entry = error.checked_call(pwd.getpwuid, self.uid)
|
||||
entry = error.checked_call(pwd.getpwuid, self.uid) # type:ignore[attr-defined]
|
||||
return entry[0]
|
||||
|
||||
@property
|
||||
@@ -234,7 +235,7 @@ class Stat:
|
||||
raise NotImplementedError("XXX win32")
|
||||
import grp
|
||||
|
||||
entry = error.checked_call(grp.getgrgid, self.gid)
|
||||
entry = error.checked_call(grp.getgrgid, self.gid) # type:ignore[attr-defined]
|
||||
return entry[0]
|
||||
|
||||
def isdir(self):
|
||||
@@ -252,7 +253,7 @@ def getuserid(user):
|
||||
import pwd
|
||||
|
||||
if not isinstance(user, int):
|
||||
user = pwd.getpwnam(user)[2]
|
||||
user = pwd.getpwnam(user)[2] # type:ignore[attr-defined]
|
||||
return user
|
||||
|
||||
|
||||
@@ -260,7 +261,7 @@ def getgroupid(group):
|
||||
import grp
|
||||
|
||||
if not isinstance(group, int):
|
||||
group = grp.getgrnam(group)[2]
|
||||
group = grp.getgrnam(group)[2] # type:ignore[attr-defined]
|
||||
return group
|
||||
|
||||
|
||||
@@ -795,7 +796,7 @@ class LocalPath:
|
||||
kw = {"exists": 1}
|
||||
return Checkers(self)._evaluate(kw)
|
||||
|
||||
_patternchars = set("*?[" + os.path.sep)
|
||||
_patternchars = set("*?[" + os.sep)
|
||||
|
||||
def listdir(self, fil=None, sort=None):
|
||||
"""List directory contents, possibly filter by the given fil func
|
||||
@@ -952,7 +953,7 @@ class LocalPath:
|
||||
else:
|
||||
p.dirpath()._ensuredirs()
|
||||
if not p.check(file=1):
|
||||
p.open("w").close()
|
||||
p.open("wb").close()
|
||||
return p
|
||||
|
||||
@overload
|
||||
@@ -1127,7 +1128,7 @@ class LocalPath:
|
||||
modfile = modfile[:-1]
|
||||
elif modfile.endswith("$py.class"):
|
||||
modfile = modfile[:-9] + ".py"
|
||||
if modfile.endswith(os.path.sep + "__init__.py"):
|
||||
if modfile.endswith(os.sep + "__init__.py"):
|
||||
if self.basename != "__init__.py":
|
||||
modfile = modfile[:-12]
|
||||
try:
|
||||
|
||||
@@ -44,10 +44,20 @@ from _pytest.stash import StashKey
|
||||
if TYPE_CHECKING:
|
||||
from _pytest.assertion import AssertionState
|
||||
|
||||
if sys.version_info >= (3, 8):
|
||||
namedExpr = ast.NamedExpr
|
||||
astNameConstant = ast.Constant
|
||||
astStr = ast.Constant
|
||||
astNum = ast.Constant
|
||||
else:
|
||||
namedExpr = ast.Expr
|
||||
astNameConstant = ast.NameConstant
|
||||
astStr = ast.Str
|
||||
astNum = ast.Num
|
||||
|
||||
|
||||
assertstate_key = StashKey["AssertionState"]()
|
||||
|
||||
|
||||
# pytest caches rewritten pycs in pycache dirs
|
||||
PYTEST_TAG = f"{sys.implementation.cache_tag}-pytest-{version}"
|
||||
PYC_EXT = ".py" + (__debug__ and "c" or "o")
|
||||
@@ -180,7 +190,7 @@ class AssertionRewritingHook(importlib.abc.MetaPathFinder, importlib.abc.Loader)
|
||||
for initial_path in self.session._initialpaths:
|
||||
# Make something as c:/projects/my_project/path.py ->
|
||||
# ['c:', 'projects', 'my_project', 'path.py']
|
||||
parts = str(initial_path).split(os.path.sep)
|
||||
parts = str(initial_path).split(os.sep)
|
||||
# add 'path' to basenames to be checked.
|
||||
self._basenames_to_check_rewrite.add(os.path.splitext(parts[-1])[0])
|
||||
|
||||
@@ -274,8 +284,12 @@ class AssertionRewritingHook(importlib.abc.MetaPathFinder, importlib.abc.Loader)
|
||||
return f.read()
|
||||
|
||||
if sys.version_info >= (3, 10):
|
||||
if sys.version_info >= (3, 12):
|
||||
from importlib.resources.abc import TraversableResources
|
||||
else:
|
||||
from importlib.abc import TraversableResources
|
||||
|
||||
def get_resource_reader(self, name: str) -> importlib.abc.TraversableResources: # type: ignore
|
||||
def get_resource_reader(self, name: str) -> TraversableResources: # type: ignore
|
||||
if sys.version_info < (3, 11):
|
||||
from importlib.readers import FileReader
|
||||
else:
|
||||
@@ -631,8 +645,12 @@ class AssertionRewriter(ast.NodeVisitor):
|
||||
.push_format_context() and .pop_format_context() which allows
|
||||
to build another %-formatted string while already building one.
|
||||
|
||||
This state is reset on every new assert statement visited and used
|
||||
by the other visitors.
|
||||
:variables_overwrite: A dict filled with references to variables
|
||||
that change value within an assert. This happens when a variable is
|
||||
reassigned with the walrus operator
|
||||
|
||||
This state, except the variables_overwrite, is reset on every new assert
|
||||
statement visited and used by the other visitors.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -648,6 +666,7 @@ class AssertionRewriter(ast.NodeVisitor):
|
||||
else:
|
||||
self.enable_assertion_pass_hook = False
|
||||
self.source = source
|
||||
self.variables_overwrite: Dict[str, str] = {}
|
||||
|
||||
def run(self, mod: ast.Module) -> None:
|
||||
"""Find all assert statements in *mod* and rewrite them."""
|
||||
@@ -662,14 +681,17 @@ class AssertionRewriter(ast.NodeVisitor):
|
||||
if doc is not None and self.is_rewrite_disabled(doc):
|
||||
return
|
||||
pos = 0
|
||||
lineno = 1
|
||||
item = None
|
||||
for item in mod.body:
|
||||
if (
|
||||
expect_docstring
|
||||
and isinstance(item, ast.Expr)
|
||||
and isinstance(item.value, ast.Str)
|
||||
and isinstance(item.value, astStr)
|
||||
):
|
||||
doc = item.value.s
|
||||
if sys.version_info >= (3, 8):
|
||||
doc = item.value.value
|
||||
else:
|
||||
doc = item.value.s
|
||||
if self.is_rewrite_disabled(doc):
|
||||
return
|
||||
expect_docstring = False
|
||||
@@ -801,7 +823,7 @@ class AssertionRewriter(ast.NodeVisitor):
|
||||
current = self.stack.pop()
|
||||
if self.stack:
|
||||
self.explanation_specifiers = self.stack[-1]
|
||||
keys = [ast.Str(key) for key in current.keys()]
|
||||
keys = [astStr(key) for key in current.keys()]
|
||||
format_dict = ast.Dict(keys, list(current.values()))
|
||||
form = ast.BinOp(expl_expr, ast.Mod(), format_dict)
|
||||
name = "@py_format" + str(next(self.variable_counter))
|
||||
@@ -855,16 +877,16 @@ class AssertionRewriter(ast.NodeVisitor):
|
||||
negation = ast.UnaryOp(ast.Not(), top_condition)
|
||||
|
||||
if self.enable_assertion_pass_hook: # Experimental pytest_assertion_pass hook
|
||||
msg = self.pop_format_context(ast.Str(explanation))
|
||||
msg = self.pop_format_context(astStr(explanation))
|
||||
|
||||
# Failed
|
||||
if assert_.msg:
|
||||
assertmsg = self.helper("_format_assertmsg", assert_.msg)
|
||||
gluestr = "\n>assert "
|
||||
else:
|
||||
assertmsg = ast.Str("")
|
||||
assertmsg = astStr("")
|
||||
gluestr = "assert "
|
||||
err_explanation = ast.BinOp(ast.Str(gluestr), ast.Add(), msg)
|
||||
err_explanation = ast.BinOp(astStr(gluestr), ast.Add(), msg)
|
||||
err_msg = ast.BinOp(assertmsg, ast.Add(), err_explanation)
|
||||
err_name = ast.Name("AssertionError", ast.Load())
|
||||
fmt = self.helper("_format_explanation", err_msg)
|
||||
@@ -880,8 +902,8 @@ class AssertionRewriter(ast.NodeVisitor):
|
||||
hook_call_pass = ast.Expr(
|
||||
self.helper(
|
||||
"_call_assertion_pass",
|
||||
ast.Num(assert_.lineno),
|
||||
ast.Str(orig),
|
||||
astNum(assert_.lineno),
|
||||
astStr(orig),
|
||||
fmt_pass,
|
||||
)
|
||||
)
|
||||
@@ -900,7 +922,7 @@ class AssertionRewriter(ast.NodeVisitor):
|
||||
variables = [
|
||||
ast.Name(name, ast.Store()) for name in self.format_variables
|
||||
]
|
||||
clear_format = ast.Assign(variables, ast.NameConstant(None))
|
||||
clear_format = ast.Assign(variables, astNameConstant(None))
|
||||
self.statements.append(clear_format)
|
||||
|
||||
else: # Original assertion rewriting
|
||||
@@ -911,9 +933,9 @@ class AssertionRewriter(ast.NodeVisitor):
|
||||
assertmsg = self.helper("_format_assertmsg", assert_.msg)
|
||||
explanation = "\n>assert " + explanation
|
||||
else:
|
||||
assertmsg = ast.Str("")
|
||||
assertmsg = astStr("")
|
||||
explanation = "assert " + explanation
|
||||
template = ast.BinOp(assertmsg, ast.Add(), ast.Str(explanation))
|
||||
template = ast.BinOp(assertmsg, ast.Add(), astStr(explanation))
|
||||
msg = self.pop_format_context(template)
|
||||
fmt = self.helper("_format_explanation", msg)
|
||||
err_name = ast.Name("AssertionError", ast.Load())
|
||||
@@ -925,7 +947,7 @@ class AssertionRewriter(ast.NodeVisitor):
|
||||
# Clear temporary variables by setting them to None.
|
||||
if self.variables:
|
||||
variables = [ast.Name(name, ast.Store()) for name in self.variables]
|
||||
clear = ast.Assign(variables, ast.NameConstant(None))
|
||||
clear = ast.Assign(variables, astNameConstant(None))
|
||||
self.statements.append(clear)
|
||||
# Fix locations (line numbers/column offsets).
|
||||
for stmt in self.statements:
|
||||
@@ -933,14 +955,26 @@ class AssertionRewriter(ast.NodeVisitor):
|
||||
ast.copy_location(node, assert_)
|
||||
return self.statements
|
||||
|
||||
def visit_NamedExpr(self, name: namedExpr) -> Tuple[namedExpr, str]:
|
||||
# This method handles the 'walrus operator' repr of the target
|
||||
# name if it's a local variable or _should_repr_global_name()
|
||||
# thinks it's acceptable.
|
||||
locs = ast.Call(self.builtin("locals"), [], [])
|
||||
target_id = name.target.id # type: ignore[attr-defined]
|
||||
inlocs = ast.Compare(astStr(target_id), [ast.In()], [locs])
|
||||
dorepr = self.helper("_should_repr_global_name", name)
|
||||
test = ast.BoolOp(ast.Or(), [inlocs, dorepr])
|
||||
expr = ast.IfExp(test, self.display(name), astStr(target_id))
|
||||
return name, self.explanation_param(expr)
|
||||
|
||||
def visit_Name(self, name: ast.Name) -> Tuple[ast.Name, str]:
|
||||
# Display the repr of the name if it's a local variable or
|
||||
# _should_repr_global_name() thinks it's acceptable.
|
||||
locs = ast.Call(self.builtin("locals"), [], [])
|
||||
inlocs = ast.Compare(ast.Str(name.id), [ast.In()], [locs])
|
||||
inlocs = ast.Compare(astStr(name.id), [ast.In()], [locs])
|
||||
dorepr = self.helper("_should_repr_global_name", name)
|
||||
test = ast.BoolOp(ast.Or(), [inlocs, dorepr])
|
||||
expr = ast.IfExp(test, self.display(name), ast.Str(name.id))
|
||||
expr = ast.IfExp(test, self.display(name), astStr(name.id))
|
||||
return name, self.explanation_param(expr)
|
||||
|
||||
def visit_BoolOp(self, boolop: ast.BoolOp) -> Tuple[ast.Name, str]:
|
||||
@@ -959,10 +993,26 @@ class AssertionRewriter(ast.NodeVisitor):
|
||||
# cond is set in a prior loop iteration below
|
||||
self.expl_stmts.append(ast.If(cond, fail_inner, [])) # noqa
|
||||
self.expl_stmts = fail_inner
|
||||
# Check if the left operand is a namedExpr and the value has already been visited
|
||||
if (
|
||||
isinstance(v, ast.Compare)
|
||||
and isinstance(v.left, namedExpr)
|
||||
and v.left.target.id
|
||||
in [
|
||||
ast_expr.id
|
||||
for ast_expr in boolop.values[:i]
|
||||
if hasattr(ast_expr, "id")
|
||||
]
|
||||
):
|
||||
pytest_temp = self.variable()
|
||||
self.variables_overwrite[
|
||||
v.left.target.id
|
||||
] = v.left # type:ignore[assignment]
|
||||
v.left.target.id = pytest_temp
|
||||
self.push_format_context()
|
||||
res, expl = self.visit(v)
|
||||
body.append(ast.Assign([ast.Name(res_var, ast.Store())], res))
|
||||
expl_format = self.pop_format_context(ast.Str(expl))
|
||||
expl_format = self.pop_format_context(astStr(expl))
|
||||
call = ast.Call(app, [expl_format], [])
|
||||
self.expl_stmts.append(ast.Expr(call))
|
||||
if i < levels:
|
||||
@@ -974,7 +1024,7 @@ class AssertionRewriter(ast.NodeVisitor):
|
||||
self.statements = body = inner
|
||||
self.statements = save
|
||||
self.expl_stmts = fail_save
|
||||
expl_template = self.helper("_format_boolop", expl_list, ast.Num(is_or))
|
||||
expl_template = self.helper("_format_boolop", expl_list, astNum(is_or))
|
||||
expl = self.pop_format_context(expl_template)
|
||||
return ast.Name(res_var, ast.Load()), self.explanation_param(expl)
|
||||
|
||||
@@ -998,10 +1048,19 @@ class AssertionRewriter(ast.NodeVisitor):
|
||||
new_args = []
|
||||
new_kwargs = []
|
||||
for arg in call.args:
|
||||
if isinstance(arg, ast.Name) and arg.id in self.variables_overwrite:
|
||||
arg = self.variables_overwrite[arg.id] # type:ignore[assignment]
|
||||
res, expl = self.visit(arg)
|
||||
arg_expls.append(expl)
|
||||
new_args.append(res)
|
||||
for keyword in call.keywords:
|
||||
if (
|
||||
isinstance(keyword.value, ast.Name)
|
||||
and keyword.value.id in self.variables_overwrite
|
||||
):
|
||||
keyword.value = self.variables_overwrite[
|
||||
keyword.value.id
|
||||
] # type:ignore[assignment]
|
||||
res, expl = self.visit(keyword.value)
|
||||
new_kwargs.append(ast.keyword(keyword.arg, res))
|
||||
if keyword.arg:
|
||||
@@ -1034,6 +1093,15 @@ class AssertionRewriter(ast.NodeVisitor):
|
||||
|
||||
def visit_Compare(self, comp: ast.Compare) -> Tuple[ast.expr, str]:
|
||||
self.push_format_context()
|
||||
# We first check if we have overwritten a variable in the previous assert
|
||||
if isinstance(comp.left, ast.Name) and comp.left.id in self.variables_overwrite:
|
||||
comp.left = self.variables_overwrite[
|
||||
comp.left.id
|
||||
] # type:ignore[assignment]
|
||||
if isinstance(comp.left, namedExpr):
|
||||
self.variables_overwrite[
|
||||
comp.left.target.id
|
||||
] = comp.left # type:ignore[assignment]
|
||||
left_res, left_expl = self.visit(comp.left)
|
||||
if isinstance(comp.left, (ast.Compare, ast.BoolOp)):
|
||||
left_expl = f"({left_expl})"
|
||||
@@ -1045,14 +1113,23 @@ class AssertionRewriter(ast.NodeVisitor):
|
||||
syms = []
|
||||
results = [left_res]
|
||||
for i, op, next_operand in it:
|
||||
if (
|
||||
isinstance(next_operand, namedExpr)
|
||||
and isinstance(left_res, ast.Name)
|
||||
and next_operand.target.id == left_res.id
|
||||
):
|
||||
next_operand.target.id = self.variable()
|
||||
self.variables_overwrite[
|
||||
left_res.id
|
||||
] = next_operand # type:ignore[assignment]
|
||||
next_res, next_expl = self.visit(next_operand)
|
||||
if isinstance(next_operand, (ast.Compare, ast.BoolOp)):
|
||||
next_expl = f"({next_expl})"
|
||||
results.append(next_res)
|
||||
sym = BINOP_MAP[op.__class__]
|
||||
syms.append(ast.Str(sym))
|
||||
syms.append(astStr(sym))
|
||||
expl = f"{left_expl} {sym} {next_expl}"
|
||||
expls.append(ast.Str(expl))
|
||||
expls.append(astStr(expl))
|
||||
res_expr = ast.Compare(left_res, [op], [next_res])
|
||||
self.statements.append(ast.Assign([store_names[i]], res_expr))
|
||||
left_res, left_expl = next_res, next_expl
|
||||
@@ -1068,6 +1145,7 @@ class AssertionRewriter(ast.NodeVisitor):
|
||||
res: ast.expr = ast.BoolOp(ast.And(), load_names)
|
||||
else:
|
||||
res = load_names[0]
|
||||
|
||||
return res, self.explanation_param(self.pop_format_context(expl_call))
|
||||
|
||||
|
||||
|
||||
@@ -38,9 +38,9 @@ def _truncate_explanation(
|
||||
"""Truncate given list of strings that makes up the assertion explanation.
|
||||
|
||||
Truncates to either 8 lines, or 640 characters - whichever the input reaches
|
||||
first. The remaining lines will be replaced by a usage message.
|
||||
first, taking the truncation explanation into account. The remaining lines
|
||||
will be replaced by a usage message.
|
||||
"""
|
||||
|
||||
if max_lines is None:
|
||||
max_lines = DEFAULT_MAX_LINES
|
||||
if max_chars is None:
|
||||
@@ -48,35 +48,56 @@ def _truncate_explanation(
|
||||
|
||||
# Check if truncation required
|
||||
input_char_count = len("".join(input_lines))
|
||||
if len(input_lines) <= max_lines and input_char_count <= max_chars:
|
||||
# The length of the truncation explanation depends on the number of lines
|
||||
# removed but is at least 68 characters:
|
||||
# The real value is
|
||||
# 64 (for the base message:
|
||||
# '...\n...Full output truncated (1 line hidden), use '-vv' to show")'
|
||||
# )
|
||||
# + 1 (for plural)
|
||||
# + int(math.log10(len(input_lines) - max_lines)) (number of hidden line, at least 1)
|
||||
# + 3 for the '...' added to the truncated line
|
||||
# But if there's more than 100 lines it's very likely that we're going to
|
||||
# truncate, so we don't need the exact value using log10.
|
||||
tolerable_max_chars = (
|
||||
max_chars + 70 # 64 + 1 (for plural) + 2 (for '99') + 3 for '...'
|
||||
)
|
||||
# The truncation explanation add two lines to the output
|
||||
tolerable_max_lines = max_lines + 2
|
||||
if (
|
||||
len(input_lines) <= tolerable_max_lines
|
||||
and input_char_count <= tolerable_max_chars
|
||||
):
|
||||
return input_lines
|
||||
|
||||
# Truncate first to max_lines, and then truncate to max_chars if max_chars
|
||||
# is exceeded.
|
||||
# Truncate first to max_lines, and then truncate to max_chars if necessary
|
||||
truncated_explanation = input_lines[:max_lines]
|
||||
truncated_explanation = _truncate_by_char_count(truncated_explanation, max_chars)
|
||||
|
||||
# Add ellipsis to final line
|
||||
truncated_explanation[-1] = truncated_explanation[-1] + "..."
|
||||
|
||||
# Append useful message to explanation
|
||||
truncated_line_count = len(input_lines) - len(truncated_explanation)
|
||||
truncated_line_count += 1 # Account for the part-truncated final line
|
||||
msg = "...Full output truncated"
|
||||
if truncated_line_count == 1:
|
||||
msg += f" ({truncated_line_count} line hidden)"
|
||||
truncated_char = True
|
||||
# We reevaluate the need to truncate chars following removal of some lines
|
||||
if len("".join(truncated_explanation)) > tolerable_max_chars:
|
||||
truncated_explanation = _truncate_by_char_count(
|
||||
truncated_explanation, max_chars
|
||||
)
|
||||
else:
|
||||
msg += f" ({truncated_line_count} lines hidden)"
|
||||
msg += f", {USAGE_MSG}"
|
||||
truncated_explanation.extend(["", str(msg)])
|
||||
return truncated_explanation
|
||||
truncated_char = False
|
||||
|
||||
truncated_line_count = len(input_lines) - len(truncated_explanation)
|
||||
if truncated_explanation[-1]:
|
||||
# Add ellipsis and take into account part-truncated final line
|
||||
truncated_explanation[-1] = truncated_explanation[-1] + "..."
|
||||
if truncated_char:
|
||||
# It's possible that we did not remove any char from this line
|
||||
truncated_line_count += 1
|
||||
else:
|
||||
# Add proper ellipsis when we were able to fit a full line exactly
|
||||
truncated_explanation[-1] = "..."
|
||||
return truncated_explanation + [
|
||||
"",
|
||||
f"...Full output truncated ({truncated_line_count} line"
|
||||
f"{'' if truncated_line_count == 1 else 's'} hidden), {USAGE_MSG}",
|
||||
]
|
||||
|
||||
|
||||
def _truncate_by_char_count(input_lines: List[str], max_chars: int) -> List[str]:
|
||||
# Check if truncation required
|
||||
if len("".join(input_lines)) <= max_chars:
|
||||
return input_lines
|
||||
|
||||
# Find point at which input length exceeds total allowed length
|
||||
iterated_char_count = 0
|
||||
for iterated_index, input_line in enumerate(input_lines):
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Implementation of the cache provider."""
|
||||
# This plugin was not named "cache" to avoid conflicts with the external
|
||||
# pytest-cache version.
|
||||
import dataclasses
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
@@ -12,8 +13,6 @@ from typing import Optional
|
||||
from typing import Set
|
||||
from typing import Union
|
||||
|
||||
import attr
|
||||
|
||||
from .pathlib import resolve_from_str
|
||||
from .pathlib import rm_rf
|
||||
from .reports import CollectReport
|
||||
@@ -28,11 +27,10 @@ from _pytest.deprecated import check_ispytest
|
||||
from _pytest.fixtures import fixture
|
||||
from _pytest.fixtures import FixtureRequest
|
||||
from _pytest.main import Session
|
||||
from _pytest.python import Module
|
||||
from _pytest.nodes import File
|
||||
from _pytest.python import Package
|
||||
from _pytest.reports import TestReport
|
||||
|
||||
|
||||
README_CONTENT = """\
|
||||
# pytest cache directory #
|
||||
|
||||
@@ -53,10 +51,12 @@ Signature: 8a477f597d28d172789f06886806bc55
|
||||
|
||||
|
||||
@final
|
||||
@attr.s(init=False, auto_attribs=True)
|
||||
@dataclasses.dataclass
|
||||
class Cache:
|
||||
_cachedir: Path = attr.ib(repr=False)
|
||||
_config: Config = attr.ib(repr=False)
|
||||
"""Instance of the `cache` fixture."""
|
||||
|
||||
_cachedir: Path = dataclasses.field(repr=False)
|
||||
_config: Config = dataclasses.field(repr=False)
|
||||
|
||||
# Sub-directory under cache-dir for directories created by `mkdir()`.
|
||||
_CACHE_PREFIX_DIRS = "d"
|
||||
@@ -179,16 +179,22 @@ class Cache:
|
||||
else:
|
||||
cache_dir_exists_already = self._cachedir.exists()
|
||||
path.parent.mkdir(exist_ok=True, parents=True)
|
||||
except OSError:
|
||||
self.warn("could not create cache path {path}", path=path, _ispytest=True)
|
||||
except OSError as exc:
|
||||
self.warn(
|
||||
f"could not create cache path {path}: {exc}",
|
||||
_ispytest=True,
|
||||
)
|
||||
return
|
||||
if not cache_dir_exists_already:
|
||||
self._ensure_supporting_files()
|
||||
data = json.dumps(value, ensure_ascii=False, indent=2)
|
||||
try:
|
||||
f = path.open("w", encoding="UTF-8")
|
||||
except OSError:
|
||||
self.warn("cache could not write path {path}", path=path, _ispytest=True)
|
||||
except OSError as exc:
|
||||
self.warn(
|
||||
f"cache could not write path {path}: {exc}",
|
||||
_ispytest=True,
|
||||
)
|
||||
else:
|
||||
with f:
|
||||
f.write(data)
|
||||
@@ -213,22 +219,30 @@ class LFPluginCollWrapper:
|
||||
|
||||
@hookimpl(hookwrapper=True)
|
||||
def pytest_make_collect_report(self, collector: nodes.Collector):
|
||||
if isinstance(collector, Session):
|
||||
if isinstance(collector, (Session, Package)):
|
||||
out = yield
|
||||
res: CollectReport = out.get_result()
|
||||
|
||||
# Sort any lf-paths to the beginning.
|
||||
lf_paths = self.lfplugin._last_failed_paths
|
||||
|
||||
# Use stable sort to priorize last failed.
|
||||
def sort_key(node: Union[nodes.Item, nodes.Collector]) -> bool:
|
||||
# Package.path is the __init__.py file, we need the directory.
|
||||
if isinstance(node, Package):
|
||||
path = node.path.parent
|
||||
else:
|
||||
path = node.path
|
||||
return path in lf_paths
|
||||
|
||||
res.result = sorted(
|
||||
res.result,
|
||||
# use stable sort to priorize last failed
|
||||
key=lambda x: x.path in lf_paths,
|
||||
key=sort_key,
|
||||
reverse=True,
|
||||
)
|
||||
return
|
||||
|
||||
elif isinstance(collector, Module):
|
||||
elif isinstance(collector, File):
|
||||
if collector.path in self.lfplugin._last_failed_paths:
|
||||
out = yield
|
||||
res = out.get_result()
|
||||
@@ -266,10 +280,9 @@ class LFPluginCollSkipfiles:
|
||||
def pytest_make_collect_report(
|
||||
self, collector: nodes.Collector
|
||||
) -> Optional[CollectReport]:
|
||||
# Packages are Modules, but _last_failed_paths only contains
|
||||
# test-bearing paths and doesn't try to include the paths of their
|
||||
# packages, so don't filter them.
|
||||
if isinstance(collector, Module) and not isinstance(collector, Package):
|
||||
# Packages are Files, but we only want to skip test-bearing Files,
|
||||
# so don't filter Packages.
|
||||
if isinstance(collector, File) and not isinstance(collector, Package):
|
||||
if collector.path not in self.lfplugin._last_failed_paths:
|
||||
self.lfplugin._skipped_files += 1
|
||||
|
||||
@@ -299,9 +312,14 @@ class LFPlugin:
|
||||
)
|
||||
|
||||
def get_last_failed_paths(self) -> Set[Path]:
|
||||
"""Return a set with all Paths()s of the previously failed nodeids."""
|
||||
"""Return a set with all Paths of the previously failed nodeids and
|
||||
their parents."""
|
||||
rootpath = self.config.rootpath
|
||||
result = {rootpath / nodeid.split("::")[0] for nodeid in self.lastfailed}
|
||||
result = set()
|
||||
for nodeid in self.lastfailed:
|
||||
path = rootpath / nodeid.split("::")[0]
|
||||
result.add(path)
|
||||
result.update(path.parents)
|
||||
return {x for x in result if x.exists()}
|
||||
|
||||
def pytest_report_collectionfinish(self) -> Optional[str]:
|
||||
@@ -492,7 +510,7 @@ def pytest_addoption(parser: Parser) -> None:
|
||||
|
||||
|
||||
def pytest_cmdline_main(config: Config) -> Optional[Union[int, ExitCode]]:
|
||||
if config.option.cacheshow:
|
||||
if config.option.cacheshow and not config.option.help:
|
||||
from _pytest.main import wrap_session
|
||||
|
||||
return wrap_session(config, cacheshow)
|
||||
|
||||
+196
-128
@@ -1,19 +1,26 @@
|
||||
"""Per-test stdout/stderr capturing mechanism."""
|
||||
import abc
|
||||
import collections
|
||||
import contextlib
|
||||
import functools
|
||||
import io
|
||||
import os
|
||||
import sys
|
||||
from io import UnsupportedOperation
|
||||
from tempfile import TemporaryFile
|
||||
from types import TracebackType
|
||||
from typing import Any
|
||||
from typing import AnyStr
|
||||
from typing import BinaryIO
|
||||
from typing import Generator
|
||||
from typing import Generic
|
||||
from typing import Iterable
|
||||
from typing import Iterator
|
||||
from typing import List
|
||||
from typing import NamedTuple
|
||||
from typing import Optional
|
||||
from typing import TextIO
|
||||
from typing import Tuple
|
||||
from typing import Type
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import Union
|
||||
|
||||
@@ -29,6 +36,7 @@ from _pytest.nodes import File
|
||||
from _pytest.nodes import Item
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing_extensions import Final
|
||||
from typing_extensions import Literal
|
||||
|
||||
_CaptureMethod = Literal["fd", "sys", "no", "tee-sys"]
|
||||
@@ -185,19 +193,27 @@ class TeeCaptureIO(CaptureIO):
|
||||
return self._other.write(s)
|
||||
|
||||
|
||||
class DontReadFromInput:
|
||||
encoding = None
|
||||
class DontReadFromInput(TextIO):
|
||||
@property
|
||||
def encoding(self) -> str:
|
||||
return sys.__stdin__.encoding
|
||||
|
||||
def read(self, *args):
|
||||
def read(self, size: int = -1) -> str:
|
||||
raise OSError(
|
||||
"pytest: reading from stdin while output is captured! Consider using `-s`."
|
||||
)
|
||||
|
||||
readline = read
|
||||
readlines = read
|
||||
__next__ = read
|
||||
|
||||
def __iter__(self):
|
||||
def __next__(self) -> str:
|
||||
return self.readline()
|
||||
|
||||
def readlines(self, hint: Optional[int] = -1) -> List[str]:
|
||||
raise OSError(
|
||||
"pytest: reading from stdin while output is captured! Consider using `-s`."
|
||||
)
|
||||
|
||||
def __iter__(self) -> Iterator[str]:
|
||||
return self
|
||||
|
||||
def fileno(self) -> int:
|
||||
@@ -215,7 +231,7 @@ class DontReadFromInput:
|
||||
def readable(self) -> bool:
|
||||
return False
|
||||
|
||||
def seek(self, offset: int) -> int:
|
||||
def seek(self, offset: int, whence: int = 0) -> int:
|
||||
raise UnsupportedOperation("redirected stdin is pseudofile, has no seek(int)")
|
||||
|
||||
def seekable(self) -> bool:
|
||||
@@ -224,41 +240,104 @@ class DontReadFromInput:
|
||||
def tell(self) -> int:
|
||||
raise UnsupportedOperation("redirected stdin is pseudofile, has no tell()")
|
||||
|
||||
def truncate(self, size: int) -> None:
|
||||
raise UnsupportedOperation("cannont truncate stdin")
|
||||
def truncate(self, size: Optional[int] = None) -> int:
|
||||
raise UnsupportedOperation("cannot truncate stdin")
|
||||
|
||||
def write(self, *args) -> None:
|
||||
def write(self, data: str) -> int:
|
||||
raise UnsupportedOperation("cannot write to stdin")
|
||||
|
||||
def writelines(self, *args) -> None:
|
||||
def writelines(self, lines: Iterable[str]) -> None:
|
||||
raise UnsupportedOperation("Cannot write to stdin")
|
||||
|
||||
def writable(self) -> bool:
|
||||
return False
|
||||
|
||||
@property
|
||||
def buffer(self):
|
||||
def __enter__(self) -> "DontReadFromInput":
|
||||
return self
|
||||
|
||||
def __exit__(
|
||||
self,
|
||||
type: Optional[Type[BaseException]],
|
||||
value: Optional[BaseException],
|
||||
traceback: Optional[TracebackType],
|
||||
) -> None:
|
||||
pass
|
||||
|
||||
@property
|
||||
def buffer(self) -> BinaryIO:
|
||||
# The str/bytes doesn't actually matter in this type, so OK to fake.
|
||||
return self # type: ignore[return-value]
|
||||
|
||||
|
||||
# Capture classes.
|
||||
|
||||
|
||||
class CaptureBase(abc.ABC, Generic[AnyStr]):
|
||||
EMPTY_BUFFER: AnyStr
|
||||
|
||||
@abc.abstractmethod
|
||||
def __init__(self, fd: int) -> None:
|
||||
raise NotImplementedError()
|
||||
|
||||
@abc.abstractmethod
|
||||
def start(self) -> None:
|
||||
raise NotImplementedError()
|
||||
|
||||
@abc.abstractmethod
|
||||
def done(self) -> None:
|
||||
raise NotImplementedError()
|
||||
|
||||
@abc.abstractmethod
|
||||
def suspend(self) -> None:
|
||||
raise NotImplementedError()
|
||||
|
||||
@abc.abstractmethod
|
||||
def resume(self) -> None:
|
||||
raise NotImplementedError()
|
||||
|
||||
@abc.abstractmethod
|
||||
def writeorg(self, data: AnyStr) -> None:
|
||||
raise NotImplementedError()
|
||||
|
||||
@abc.abstractmethod
|
||||
def snap(self) -> AnyStr:
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
patchsysdict = {0: "stdin", 1: "stdout", 2: "stderr"}
|
||||
|
||||
|
||||
class NoCapture:
|
||||
EMPTY_BUFFER = None
|
||||
__init__ = start = done = suspend = resume = lambda *args: None
|
||||
class NoCapture(CaptureBase[str]):
|
||||
EMPTY_BUFFER = ""
|
||||
|
||||
def __init__(self, fd: int) -> None:
|
||||
pass
|
||||
|
||||
def start(self) -> None:
|
||||
pass
|
||||
|
||||
def done(self) -> None:
|
||||
pass
|
||||
|
||||
def suspend(self) -> None:
|
||||
pass
|
||||
|
||||
def resume(self) -> None:
|
||||
pass
|
||||
|
||||
def snap(self) -> str:
|
||||
return ""
|
||||
|
||||
def writeorg(self, data: str) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class SysCaptureBinary:
|
||||
|
||||
EMPTY_BUFFER = b""
|
||||
|
||||
def __init__(self, fd: int, tmpfile=None, *, tee: bool = False) -> None:
|
||||
class SysCaptureBase(CaptureBase[AnyStr]):
|
||||
def __init__(
|
||||
self, fd: int, tmpfile: Optional[TextIO] = None, *, tee: bool = False
|
||||
) -> None:
|
||||
name = patchsysdict[fd]
|
||||
self._old = getattr(sys, name)
|
||||
self._old: TextIO = getattr(sys, name)
|
||||
self.name = name
|
||||
if tmpfile is None:
|
||||
if name == "stdin":
|
||||
@@ -298,14 +377,6 @@ class SysCaptureBinary:
|
||||
setattr(sys, self.name, self.tmpfile)
|
||||
self._state = "started"
|
||||
|
||||
def snap(self):
|
||||
self._assert_state("snap", ("started", "suspended"))
|
||||
self.tmpfile.seek(0)
|
||||
res = self.tmpfile.buffer.read()
|
||||
self.tmpfile.seek(0)
|
||||
self.tmpfile.truncate()
|
||||
return res
|
||||
|
||||
def done(self) -> None:
|
||||
self._assert_state("done", ("initialized", "started", "suspended", "done"))
|
||||
if self._state == "done":
|
||||
@@ -327,36 +398,43 @@ class SysCaptureBinary:
|
||||
setattr(sys, self.name, self.tmpfile)
|
||||
self._state = "started"
|
||||
|
||||
def writeorg(self, data) -> None:
|
||||
|
||||
class SysCaptureBinary(SysCaptureBase[bytes]):
|
||||
EMPTY_BUFFER = b""
|
||||
|
||||
def snap(self) -> bytes:
|
||||
self._assert_state("snap", ("started", "suspended"))
|
||||
self.tmpfile.seek(0)
|
||||
res = self.tmpfile.buffer.read()
|
||||
self.tmpfile.seek(0)
|
||||
self.tmpfile.truncate()
|
||||
return res
|
||||
|
||||
def writeorg(self, data: bytes) -> None:
|
||||
self._assert_state("writeorg", ("started", "suspended"))
|
||||
self._old.flush()
|
||||
self._old.buffer.write(data)
|
||||
self._old.buffer.flush()
|
||||
|
||||
|
||||
class SysCapture(SysCaptureBinary):
|
||||
EMPTY_BUFFER = "" # type: ignore[assignment]
|
||||
class SysCapture(SysCaptureBase[str]):
|
||||
EMPTY_BUFFER = ""
|
||||
|
||||
def snap(self):
|
||||
def snap(self) -> str:
|
||||
self._assert_state("snap", ("started", "suspended"))
|
||||
assert isinstance(self.tmpfile, CaptureIO)
|
||||
res = self.tmpfile.getvalue()
|
||||
self.tmpfile.seek(0)
|
||||
self.tmpfile.truncate()
|
||||
return res
|
||||
|
||||
def writeorg(self, data):
|
||||
def writeorg(self, data: str) -> None:
|
||||
self._assert_state("writeorg", ("started", "suspended"))
|
||||
self._old.write(data)
|
||||
self._old.flush()
|
||||
|
||||
|
||||
class FDCaptureBinary:
|
||||
"""Capture IO to/from a given OS-level file descriptor.
|
||||
|
||||
snap() produces `bytes`.
|
||||
"""
|
||||
|
||||
EMPTY_BUFFER = b""
|
||||
|
||||
class FDCaptureBase(CaptureBase[AnyStr]):
|
||||
def __init__(self, targetfd: int) -> None:
|
||||
self.targetfd = targetfd
|
||||
|
||||
@@ -382,7 +460,7 @@ class FDCaptureBinary:
|
||||
|
||||
if targetfd == 0:
|
||||
self.tmpfile = open(os.devnull, encoding="utf-8")
|
||||
self.syscapture = SysCapture(targetfd)
|
||||
self.syscapture: CaptureBase[str] = SysCapture(targetfd)
|
||||
else:
|
||||
self.tmpfile = EncodedFile(
|
||||
TemporaryFile(buffering=0),
|
||||
@@ -394,7 +472,7 @@ class FDCaptureBinary:
|
||||
if targetfd in patchsysdict:
|
||||
self.syscapture = SysCapture(targetfd, self.tmpfile)
|
||||
else:
|
||||
self.syscapture = NoCapture()
|
||||
self.syscapture = NoCapture(targetfd)
|
||||
|
||||
self._state = "initialized"
|
||||
|
||||
@@ -421,14 +499,6 @@ class FDCaptureBinary:
|
||||
self.syscapture.start()
|
||||
self._state = "started"
|
||||
|
||||
def snap(self):
|
||||
self._assert_state("snap", ("started", "suspended"))
|
||||
self.tmpfile.seek(0)
|
||||
res = self.tmpfile.buffer.read()
|
||||
self.tmpfile.seek(0)
|
||||
self.tmpfile.truncate()
|
||||
return res
|
||||
|
||||
def done(self) -> None:
|
||||
"""Stop capturing, restore streams, return original capture file,
|
||||
seeked to position zero."""
|
||||
@@ -461,22 +531,38 @@ class FDCaptureBinary:
|
||||
os.dup2(self.tmpfile.fileno(), self.targetfd)
|
||||
self._state = "started"
|
||||
|
||||
def writeorg(self, data):
|
||||
|
||||
class FDCaptureBinary(FDCaptureBase[bytes]):
|
||||
"""Capture IO to/from a given OS-level file descriptor.
|
||||
|
||||
snap() produces `bytes`.
|
||||
"""
|
||||
|
||||
EMPTY_BUFFER = b""
|
||||
|
||||
def snap(self) -> bytes:
|
||||
self._assert_state("snap", ("started", "suspended"))
|
||||
self.tmpfile.seek(0)
|
||||
res = self.tmpfile.buffer.read()
|
||||
self.tmpfile.seek(0)
|
||||
self.tmpfile.truncate()
|
||||
return res
|
||||
|
||||
def writeorg(self, data: bytes) -> None:
|
||||
"""Write to original file descriptor."""
|
||||
self._assert_state("writeorg", ("started", "suspended"))
|
||||
os.write(self.targetfd_save, data)
|
||||
|
||||
|
||||
class FDCapture(FDCaptureBinary):
|
||||
class FDCapture(FDCaptureBase[str]):
|
||||
"""Capture IO to/from a given OS-level file descriptor.
|
||||
|
||||
snap() produces text.
|
||||
"""
|
||||
|
||||
# Ignore type because it doesn't match the type in the superclass (bytes).
|
||||
EMPTY_BUFFER = "" # type: ignore
|
||||
EMPTY_BUFFER = ""
|
||||
|
||||
def snap(self):
|
||||
def snap(self) -> str:
|
||||
self._assert_state("snap", ("started", "suspended"))
|
||||
self.tmpfile.seek(0)
|
||||
res = self.tmpfile.read()
|
||||
@@ -484,77 +570,49 @@ class FDCapture(FDCaptureBinary):
|
||||
self.tmpfile.truncate()
|
||||
return res
|
||||
|
||||
def writeorg(self, data):
|
||||
def writeorg(self, data: str) -> None:
|
||||
"""Write to original file descriptor."""
|
||||
super().writeorg(data.encode("utf-8")) # XXX use encoding of original stream
|
||||
self._assert_state("writeorg", ("started", "suspended"))
|
||||
# XXX use encoding of original stream
|
||||
os.write(self.targetfd_save, data.encode("utf-8"))
|
||||
|
||||
|
||||
# MultiCapture
|
||||
|
||||
|
||||
# This class was a namedtuple, but due to mypy limitation[0] it could not be
|
||||
# made generic, so was replaced by a regular class which tries to emulate the
|
||||
# pertinent parts of a namedtuple. If the mypy limitation is ever lifted, can
|
||||
# make it a namedtuple again.
|
||||
# [0]: https://github.com/python/mypy/issues/685
|
||||
@final
|
||||
@functools.total_ordering
|
||||
class CaptureResult(Generic[AnyStr]):
|
||||
"""The result of :method:`CaptureFixture.readouterr`."""
|
||||
# Generic NamedTuple only supported since Python 3.11.
|
||||
if sys.version_info >= (3, 11) or TYPE_CHECKING:
|
||||
|
||||
__slots__ = ("out", "err")
|
||||
@final
|
||||
class CaptureResult(NamedTuple, Generic[AnyStr]):
|
||||
"""The result of :method:`CaptureFixture.readouterr`."""
|
||||
|
||||
def __init__(self, out: AnyStr, err: AnyStr) -> None:
|
||||
self.out: AnyStr = out
|
||||
self.err: AnyStr = err
|
||||
out: AnyStr
|
||||
err: AnyStr
|
||||
|
||||
def __len__(self) -> int:
|
||||
return 2
|
||||
else:
|
||||
|
||||
def __iter__(self) -> Iterator[AnyStr]:
|
||||
return iter((self.out, self.err))
|
||||
class CaptureResult(
|
||||
collections.namedtuple("CaptureResult", ["out", "err"]), Generic[AnyStr]
|
||||
):
|
||||
"""The result of :method:`CaptureFixture.readouterr`."""
|
||||
|
||||
def __getitem__(self, item: int) -> AnyStr:
|
||||
return tuple(self)[item]
|
||||
|
||||
def _replace(
|
||||
self, *, out: Optional[AnyStr] = None, err: Optional[AnyStr] = None
|
||||
) -> "CaptureResult[AnyStr]":
|
||||
return CaptureResult(
|
||||
out=self.out if out is None else out, err=self.err if err is None else err
|
||||
)
|
||||
|
||||
def count(self, value: AnyStr) -> int:
|
||||
return tuple(self).count(value)
|
||||
|
||||
def index(self, value) -> int:
|
||||
return tuple(self).index(value)
|
||||
|
||||
def __eq__(self, other: object) -> bool:
|
||||
if not isinstance(other, (CaptureResult, tuple)):
|
||||
return NotImplemented
|
||||
return tuple(self) == tuple(other)
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return hash(tuple(self))
|
||||
|
||||
def __lt__(self, other: object) -> bool:
|
||||
if not isinstance(other, (CaptureResult, tuple)):
|
||||
return NotImplemented
|
||||
return tuple(self) < tuple(other)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"CaptureResult(out={self.out!r}, err={self.err!r})"
|
||||
__slots__ = ()
|
||||
|
||||
|
||||
class MultiCapture(Generic[AnyStr]):
|
||||
_state = None
|
||||
_in_suspended = False
|
||||
|
||||
def __init__(self, in_, out, err) -> None:
|
||||
self.in_ = in_
|
||||
self.out = out
|
||||
self.err = err
|
||||
def __init__(
|
||||
self,
|
||||
in_: Optional[CaptureBase[AnyStr]],
|
||||
out: Optional[CaptureBase[AnyStr]],
|
||||
err: Optional[CaptureBase[AnyStr]],
|
||||
) -> None:
|
||||
self.in_: Optional[CaptureBase[AnyStr]] = in_
|
||||
self.out: Optional[CaptureBase[AnyStr]] = out
|
||||
self.err: Optional[CaptureBase[AnyStr]] = err
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return "<MultiCapture out={!r} err={!r} in_={!r} _state={!r} _in_suspended={!r}>".format(
|
||||
@@ -578,8 +636,10 @@ class MultiCapture(Generic[AnyStr]):
|
||||
"""Pop current snapshot out/err capture and flush to orig streams."""
|
||||
out, err = self.readouterr()
|
||||
if out:
|
||||
assert self.out is not None
|
||||
self.out.writeorg(out)
|
||||
if err:
|
||||
assert self.err is not None
|
||||
self.err.writeorg(err)
|
||||
return out, err
|
||||
|
||||
@@ -600,6 +660,7 @@ class MultiCapture(Generic[AnyStr]):
|
||||
if self.err:
|
||||
self.err.resume()
|
||||
if self._in_suspended:
|
||||
assert self.in_ is not None
|
||||
self.in_.resume()
|
||||
self._in_suspended = False
|
||||
|
||||
@@ -622,7 +683,8 @@ class MultiCapture(Generic[AnyStr]):
|
||||
def readouterr(self) -> CaptureResult[AnyStr]:
|
||||
out = self.out.snap() if self.out else ""
|
||||
err = self.err.snap() if self.err else ""
|
||||
return CaptureResult(out, err)
|
||||
# TODO: This type error is real, need to fix.
|
||||
return CaptureResult(out, err) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def _get_multicapture(method: "_CaptureMethod") -> MultiCapture[str]:
|
||||
@@ -662,7 +724,7 @@ class CaptureManager:
|
||||
"""
|
||||
|
||||
def __init__(self, method: "_CaptureMethod") -> None:
|
||||
self._method = method
|
||||
self._method: Final = method
|
||||
self._global_capturing: Optional[MultiCapture[str]] = None
|
||||
self._capture_fixture: Optional[CaptureFixture[Any]] = None
|
||||
|
||||
@@ -831,14 +893,18 @@ class CaptureFixture(Generic[AnyStr]):
|
||||
:fixture:`capfd` and :fixture:`capfdbinary` fixtures."""
|
||||
|
||||
def __init__(
|
||||
self, captureclass, request: SubRequest, *, _ispytest: bool = False
|
||||
self,
|
||||
captureclass: Type[CaptureBase[AnyStr]],
|
||||
request: SubRequest,
|
||||
*,
|
||||
_ispytest: bool = False,
|
||||
) -> None:
|
||||
check_ispytest(_ispytest)
|
||||
self.captureclass = captureclass
|
||||
self.captureclass: Type[CaptureBase[AnyStr]] = captureclass
|
||||
self.request = request
|
||||
self._capture: Optional[MultiCapture[AnyStr]] = None
|
||||
self._captured_out = self.captureclass.EMPTY_BUFFER
|
||||
self._captured_err = self.captureclass.EMPTY_BUFFER
|
||||
self._captured_out: AnyStr = self.captureclass.EMPTY_BUFFER
|
||||
self._captured_err: AnyStr = self.captureclass.EMPTY_BUFFER
|
||||
|
||||
def _start(self) -> None:
|
||||
if self._capture is None:
|
||||
@@ -893,7 +959,9 @@ class CaptureFixture(Generic[AnyStr]):
|
||||
@contextlib.contextmanager
|
||||
def disabled(self) -> Generator[None, None, None]:
|
||||
"""Temporarily disable capturing while inside the ``with`` block."""
|
||||
capmanager = self.request.config.pluginmanager.getplugin("capturemanager")
|
||||
capmanager: CaptureManager = self.request.config.pluginmanager.getplugin(
|
||||
"capturemanager"
|
||||
)
|
||||
with capmanager.global_and_fixture_disabled():
|
||||
yield
|
||||
|
||||
@@ -920,8 +988,8 @@ def capsys(request: SubRequest) -> Generator[CaptureFixture[str], None, None]:
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out == "hello\n"
|
||||
"""
|
||||
capman = request.config.pluginmanager.getplugin("capturemanager")
|
||||
capture_fixture = CaptureFixture[str](SysCapture, request, _ispytest=True)
|
||||
capman: CaptureManager = request.config.pluginmanager.getplugin("capturemanager")
|
||||
capture_fixture = CaptureFixture(SysCapture, request, _ispytest=True)
|
||||
capman.set_fixture(capture_fixture)
|
||||
capture_fixture._start()
|
||||
yield capture_fixture
|
||||
@@ -948,8 +1016,8 @@ def capsysbinary(request: SubRequest) -> Generator[CaptureFixture[bytes], None,
|
||||
captured = capsysbinary.readouterr()
|
||||
assert captured.out == b"hello\n"
|
||||
"""
|
||||
capman = request.config.pluginmanager.getplugin("capturemanager")
|
||||
capture_fixture = CaptureFixture[bytes](SysCaptureBinary, request, _ispytest=True)
|
||||
capman: CaptureManager = request.config.pluginmanager.getplugin("capturemanager")
|
||||
capture_fixture = CaptureFixture(SysCaptureBinary, request, _ispytest=True)
|
||||
capman.set_fixture(capture_fixture)
|
||||
capture_fixture._start()
|
||||
yield capture_fixture
|
||||
@@ -976,8 +1044,8 @@ def capfd(request: SubRequest) -> Generator[CaptureFixture[str], None, None]:
|
||||
captured = capfd.readouterr()
|
||||
assert captured.out == "hello\n"
|
||||
"""
|
||||
capman = request.config.pluginmanager.getplugin("capturemanager")
|
||||
capture_fixture = CaptureFixture[str](FDCapture, request, _ispytest=True)
|
||||
capman: CaptureManager = request.config.pluginmanager.getplugin("capturemanager")
|
||||
capture_fixture = CaptureFixture(FDCapture, request, _ispytest=True)
|
||||
capman.set_fixture(capture_fixture)
|
||||
capture_fixture._start()
|
||||
yield capture_fixture
|
||||
@@ -1005,8 +1073,8 @@ def capfdbinary(request: SubRequest) -> Generator[CaptureFixture[bytes], None, N
|
||||
assert captured.out == b"hello\n"
|
||||
|
||||
"""
|
||||
capman = request.config.pluginmanager.getplugin("capturemanager")
|
||||
capture_fixture = CaptureFixture[bytes](FDCaptureBinary, request, _ispytest=True)
|
||||
capman: CaptureManager = request.config.pluginmanager.getplugin("capturemanager")
|
||||
capture_fixture = CaptureFixture(FDCaptureBinary, request, _ispytest=True)
|
||||
capman.set_fixture(capture_fixture)
|
||||
capture_fixture._start()
|
||||
yield capture_fixture
|
||||
|
||||
+27
-18
@@ -1,4 +1,7 @@
|
||||
"""Python version compatibility code."""
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import enum
|
||||
import functools
|
||||
import inspect
|
||||
@@ -11,13 +14,8 @@ from typing import Any
|
||||
from typing import Callable
|
||||
from typing import Generic
|
||||
from typing import NoReturn
|
||||
from typing import Optional
|
||||
from typing import Tuple
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TypeVar
|
||||
from typing import Union
|
||||
|
||||
import attr
|
||||
|
||||
import py
|
||||
|
||||
@@ -47,7 +45,7 @@ LEGACY_PATH = py.path. local
|
||||
# fmt: on
|
||||
|
||||
|
||||
def legacy_path(path: Union[str, "os.PathLike[str]"]) -> LEGACY_PATH:
|
||||
def legacy_path(path: str | os.PathLike[str]) -> LEGACY_PATH:
|
||||
"""Internal wrapper to prepare lazy proxies for legacy_path instances"""
|
||||
return LEGACY_PATH(path)
|
||||
|
||||
@@ -57,7 +55,7 @@ def legacy_path(path: Union[str, "os.PathLike[str]"]) -> LEGACY_PATH:
|
||||
# https://www.python.org/dev/peps/pep-0484/#support-for-singleton-types-in-unions
|
||||
class NotSetType(enum.Enum):
|
||||
token = 0
|
||||
NOTSET: "Final" = NotSetType.token # noqa: E305
|
||||
NOTSET: Final = NotSetType.token # noqa: E305
|
||||
# fmt: on
|
||||
|
||||
if sys.version_info >= (3, 8):
|
||||
@@ -95,7 +93,7 @@ def is_async_function(func: object) -> bool:
|
||||
return iscoroutinefunction(func) or inspect.isasyncgenfunction(func)
|
||||
|
||||
|
||||
def getlocation(function, curdir: Optional[str] = None) -> str:
|
||||
def getlocation(function, curdir: str | None = None) -> str:
|
||||
function = get_real_func(function)
|
||||
fn = Path(inspect.getfile(function))
|
||||
lineno = function.__code__.co_firstlineno
|
||||
@@ -133,8 +131,8 @@ def getfuncargnames(
|
||||
*,
|
||||
name: str = "",
|
||||
is_method: bool = False,
|
||||
cls: Optional[type] = None,
|
||||
) -> Tuple[str, ...]:
|
||||
cls: type | None = None,
|
||||
) -> tuple[str, ...]:
|
||||
"""Return the names of a function's mandatory arguments.
|
||||
|
||||
Should return the names of all function arguments that:
|
||||
@@ -198,7 +196,7 @@ def getfuncargnames(
|
||||
return arg_names
|
||||
|
||||
|
||||
def get_default_arg_names(function: Callable[..., Any]) -> Tuple[str, ...]:
|
||||
def get_default_arg_names(function: Callable[..., Any]) -> tuple[str, ...]:
|
||||
# Note: this code intentionally mirrors the code at the beginning of
|
||||
# getfuncargnames, to get the arguments which were excluded from its result
|
||||
# because they had default values.
|
||||
@@ -229,7 +227,7 @@ def _bytes_to_ascii(val: bytes) -> str:
|
||||
return val.decode("ascii", "backslashreplace")
|
||||
|
||||
|
||||
def ascii_escaped(val: Union[bytes, str]) -> str:
|
||||
def ascii_escaped(val: bytes | str) -> str:
|
||||
r"""If val is pure ASCII, return it as an str, otherwise, escape
|
||||
bytes objects into a sequence of escaped bytes:
|
||||
|
||||
@@ -253,7 +251,7 @@ def ascii_escaped(val: Union[bytes, str]) -> str:
|
||||
return _translate_non_printable(ret)
|
||||
|
||||
|
||||
@attr.s
|
||||
@dataclasses.dataclass
|
||||
class _PytestWrapper:
|
||||
"""Dummy wrapper around a function object for internal use only.
|
||||
|
||||
@@ -262,7 +260,7 @@ class _PytestWrapper:
|
||||
decorator to issue warnings when the fixture function is called directly.
|
||||
"""
|
||||
|
||||
obj = attr.ib()
|
||||
obj: Any
|
||||
|
||||
|
||||
def get_real_func(obj):
|
||||
@@ -356,7 +354,6 @@ else:
|
||||
if sys.version_info >= (3, 8):
|
||||
from functools import cached_property as cached_property
|
||||
else:
|
||||
from typing import Type
|
||||
|
||||
class cached_property(Generic[_S, _T]):
|
||||
__slots__ = ("func", "__doc__")
|
||||
@@ -367,12 +364,12 @@ else:
|
||||
|
||||
@overload
|
||||
def __get__(
|
||||
self, instance: None, owner: Optional[Type[_S]] = ...
|
||||
) -> "cached_property[_S, _T]":
|
||||
self, instance: None, owner: type[_S] | None = ...
|
||||
) -> cached_property[_S, _T]:
|
||||
...
|
||||
|
||||
@overload
|
||||
def __get__(self, instance: _S, owner: Optional[Type[_S]] = ...) -> _T:
|
||||
def __get__(self, instance: _S, owner: type[_S] | None = ...) -> _T:
|
||||
...
|
||||
|
||||
def __get__(self, instance, owner=None):
|
||||
@@ -382,6 +379,18 @@ else:
|
||||
return value
|
||||
|
||||
|
||||
def get_user_id() -> int | None:
|
||||
"""Return the current user id, or None if we cannot get it reliably on the current platform."""
|
||||
# win32 does not have a getuid() function.
|
||||
# On Emscripten, getuid() is a stub that always returns 0.
|
||||
if sys.platform in ("win32", "emscripten"):
|
||||
return None
|
||||
# getuid shouldn't fail, but cpython defines such a case.
|
||||
# Let's hope for the best.
|
||||
uid = os.getuid()
|
||||
return uid if uid != -1 else None
|
||||
|
||||
|
||||
# Perform exhaustiveness checking.
|
||||
#
|
||||
# Consider this example:
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import argparse
|
||||
import collections.abc
|
||||
import copy
|
||||
import dataclasses
|
||||
import enum
|
||||
import glob
|
||||
import inspect
|
||||
@@ -34,7 +35,6 @@ from typing import Type
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import Union
|
||||
|
||||
import attr
|
||||
from pluggy import HookimplMarker
|
||||
from pluggy import HookspecMarker
|
||||
from pluggy import PluginManager
|
||||
@@ -49,7 +49,7 @@ from _pytest._code import ExceptionInfo
|
||||
from _pytest._code import filter_traceback
|
||||
from _pytest._io import TerminalWriter
|
||||
from _pytest.compat import final
|
||||
from _pytest.compat import importlib_metadata
|
||||
from _pytest.compat import importlib_metadata # type: ignore[attr-defined]
|
||||
from _pytest.outcomes import fail
|
||||
from _pytest.outcomes import Skipped
|
||||
from _pytest.pathlib import absolutepath
|
||||
@@ -62,7 +62,6 @@ from _pytest.warning_types import PytestConfigWarning
|
||||
from _pytest.warning_types import warn_explicit_for
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
||||
from _pytest._code.code import _TracebackStyle
|
||||
from _pytest.terminal import TerminalReporter
|
||||
from .argparsing import Argument
|
||||
@@ -527,7 +526,10 @@ class PytestPluginManager(PluginManager):
|
||||
# Internal API for local conftest plugin handling.
|
||||
#
|
||||
def _set_initial_conftests(
|
||||
self, namespace: argparse.Namespace, rootpath: Path
|
||||
self,
|
||||
namespace: argparse.Namespace,
|
||||
rootpath: Path,
|
||||
testpaths_ini: Sequence[str],
|
||||
) -> None:
|
||||
"""Load initial conftest files given a preparsed "namespace".
|
||||
|
||||
@@ -544,7 +546,7 @@ class PytestPluginManager(PluginManager):
|
||||
)
|
||||
self._noconftest = namespace.noconftest
|
||||
self._using_pyargs = namespace.pyargs
|
||||
testpaths = namespace.file_or_dir
|
||||
testpaths = namespace.file_or_dir + testpaths_ini
|
||||
foundanchor = False
|
||||
for testpath in testpaths:
|
||||
path = str(testpath)
|
||||
@@ -553,7 +555,14 @@ class PytestPluginManager(PluginManager):
|
||||
if i != -1:
|
||||
path = path[:i]
|
||||
anchor = absolutepath(current / path)
|
||||
if anchor.exists(): # we found some file object
|
||||
|
||||
# Ensure we do not break if what appears to be an anchor
|
||||
# is in fact a very long option (#10169).
|
||||
try:
|
||||
anchor_exists = anchor.exists()
|
||||
except OSError: # pragma: no cover
|
||||
anchor_exists = False
|
||||
if anchor_exists:
|
||||
self._try_load_conftest(anchor, namespace.importmode, rootpath)
|
||||
foundanchor = True
|
||||
if not foundanchor:
|
||||
@@ -697,6 +706,7 @@ class PytestPluginManager(PluginManager):
|
||||
parg = opt[2:]
|
||||
else:
|
||||
continue
|
||||
parg = parg.strip()
|
||||
if exclude_only and not parg.startswith("no:"):
|
||||
continue
|
||||
self.consider_pluginarg(parg)
|
||||
@@ -886,10 +896,6 @@ def _iter_rewritable_modules(package_files: Iterable[str]) -> Iterator[str]:
|
||||
yield from _iter_rewritable_modules(new_package_files)
|
||||
|
||||
|
||||
def _args_converter(args: Iterable[str]) -> Tuple[str, ...]:
|
||||
return tuple(args)
|
||||
|
||||
|
||||
@final
|
||||
class Config:
|
||||
"""Access to configuration values, pluginmanager and plugin hooks.
|
||||
@@ -903,7 +909,7 @@ class Config:
|
||||
"""
|
||||
|
||||
@final
|
||||
@attr.s(frozen=True, auto_attribs=True)
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class InvocationParams:
|
||||
"""Holds parameters passed during :func:`pytest.main`.
|
||||
|
||||
@@ -919,13 +925,24 @@ class Config:
|
||||
Plugins accessing ``InvocationParams`` must be aware of that.
|
||||
"""
|
||||
|
||||
args: Tuple[str, ...] = attr.ib(converter=_args_converter)
|
||||
args: Tuple[str, ...]
|
||||
"""The command-line arguments as passed to :func:`pytest.main`."""
|
||||
plugins: Optional[Sequence[Union[str, _PluggyPlugin]]]
|
||||
"""Extra plugins, might be `None`."""
|
||||
dir: Path
|
||||
"""The directory from which :func:`pytest.main` was invoked."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
args: Iterable[str],
|
||||
plugins: Optional[Sequence[Union[str, _PluggyPlugin]]],
|
||||
dir: Path,
|
||||
) -> None:
|
||||
object.__setattr__(self, "args", tuple(args))
|
||||
object.__setattr__(self, "plugins", plugins)
|
||||
object.__setattr__(self, "dir", dir)
|
||||
|
||||
class ArgsSource(enum.Enum):
|
||||
"""Indicates the source of the test arguments.
|
||||
|
||||
@@ -998,6 +1015,8 @@ class Config:
|
||||
self.hook.pytest_addoption.call_historic(
|
||||
kwargs=dict(parser=self._parser, pluginmanager=self.pluginmanager)
|
||||
)
|
||||
self.args_source = Config.ArgsSource.ARGS
|
||||
self.args: List[str] = []
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from _pytest.cacheprovider import Cache
|
||||
@@ -1057,7 +1076,6 @@ class Config:
|
||||
try:
|
||||
self.parse(args)
|
||||
except UsageError:
|
||||
|
||||
# Handle --version and --help here in a minimal fashion.
|
||||
# This gets done via helpconfig normally, but its
|
||||
# pytest_cmdline_main is not called in case of errors.
|
||||
@@ -1123,7 +1141,9 @@ class Config:
|
||||
@hookimpl(trylast=True)
|
||||
def pytest_load_initial_conftests(self, early_config: "Config") -> None:
|
||||
self.pluginmanager._set_initial_conftests(
|
||||
early_config.known_args_namespace, rootpath=early_config.rootpath
|
||||
early_config.known_args_namespace,
|
||||
rootpath=early_config.rootpath,
|
||||
testpaths_ini=self.getini("testpaths"),
|
||||
)
|
||||
|
||||
def _initini(self, args: Sequence[str]) -> None:
|
||||
@@ -1241,8 +1261,11 @@ class Config:
|
||||
_pytest.deprecated.STRICT_OPTION, stacklevel=2
|
||||
)
|
||||
|
||||
if self.known_args_namespace.confcutdir is None and self.inipath is not None:
|
||||
confcutdir = str(self.inipath.parent)
|
||||
if self.known_args_namespace.confcutdir is None:
|
||||
if self.inipath is not None:
|
||||
confcutdir = str(self.inipath.parent)
|
||||
else:
|
||||
confcutdir = str(self.rootpath)
|
||||
self.known_args_namespace.confcutdir = confcutdir
|
||||
try:
|
||||
self.hook.pytest_load_initial_conftests(
|
||||
@@ -1337,8 +1360,8 @@ class Config:
|
||||
|
||||
def parse(self, args: List[str], addopts: bool = True) -> None:
|
||||
# Parse given cmdline arguments into this config object.
|
||||
assert not hasattr(
|
||||
self, "args"
|
||||
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)
|
||||
@@ -1362,6 +1385,15 @@ class Config:
|
||||
args = []
|
||||
for path in testpaths:
|
||||
args.extend(sorted(glob.iglob(path, recursive=True)))
|
||||
if testpaths and not args:
|
||||
warning_text = (
|
||||
"No files were found in testpaths; "
|
||||
"consider removing or adjusting your testpaths configuration. "
|
||||
"Searching recursively from the current directory instead."
|
||||
)
|
||||
self.issue_config_time_warning(
|
||||
PytestConfigWarning(warning_text), stacklevel=3
|
||||
)
|
||||
if not args:
|
||||
source = Config.ArgsSource.INCOVATION_DIR
|
||||
args = [str(self.invocation_params.dir)]
|
||||
|
||||
@@ -43,7 +43,6 @@ class PathAwareHookProxy:
|
||||
|
||||
@_wraps(hook)
|
||||
def fixed_hook(**kw):
|
||||
|
||||
path_value: Optional[Path] = kw.pop(path_var, None)
|
||||
fspath_value: Optional[LEGACY_PATH] = kw.pop(fspath_var, None)
|
||||
if fspath_value is not None:
|
||||
|
||||
@@ -203,8 +203,7 @@ def determine_setup(
|
||||
else:
|
||||
cwd = Path.cwd()
|
||||
rootdir = get_common_ancestor([cwd, ancestor])
|
||||
is_fs_root = os.path.splitdrive(str(rootdir))[1] == "/"
|
||||
if is_fs_root:
|
||||
if is_fs_root(rootdir):
|
||||
rootdir = ancestor
|
||||
if rootdir_cmd_arg:
|
||||
rootdir = absolutepath(os.path.expandvars(rootdir_cmd_arg))
|
||||
@@ -216,3 +215,11 @@ def determine_setup(
|
||||
)
|
||||
assert rootdir is not None
|
||||
return rootdir, inipath, inicfg or {}
|
||||
|
||||
|
||||
def is_fs_root(p: Path) -> bool:
|
||||
r"""
|
||||
Return True if the given path is pointing to the root of the
|
||||
file system ("/" on Unix and "C:\\" on Windows for example).
|
||||
"""
|
||||
return os.path.splitdrive(str(p))[1] == os.sep
|
||||
|
||||
@@ -531,7 +531,6 @@ class DoctestModule(Module):
|
||||
if _is_mocked(obj):
|
||||
return
|
||||
with _patch_unwrap_mock_aware():
|
||||
|
||||
# Type ignored because this is a private function.
|
||||
super()._find( # type:ignore[misc]
|
||||
tests, obj, name, module, source_lines, globs, seen
|
||||
|
||||
@@ -2,7 +2,6 @@ import io
|
||||
import os
|
||||
import sys
|
||||
from typing import Generator
|
||||
from typing import TextIO
|
||||
|
||||
import pytest
|
||||
from _pytest.config import Config
|
||||
@@ -11,7 +10,7 @@ from _pytest.nodes import Item
|
||||
from _pytest.stash import StashKey
|
||||
|
||||
|
||||
fault_handler_stderr_key = StashKey[TextIO]()
|
||||
fault_handler_stderr_fd_key = StashKey[int]()
|
||||
fault_handler_originally_enabled_key = StashKey[bool]()
|
||||
|
||||
|
||||
@@ -26,10 +25,9 @@ def pytest_addoption(parser: Parser) -> None:
|
||||
def pytest_configure(config: Config) -> None:
|
||||
import faulthandler
|
||||
|
||||
stderr_fd_copy = os.dup(get_stderr_fileno())
|
||||
config.stash[fault_handler_stderr_key] = open(stderr_fd_copy, "w")
|
||||
config.stash[fault_handler_stderr_fd_key] = os.dup(get_stderr_fileno())
|
||||
config.stash[fault_handler_originally_enabled_key] = faulthandler.is_enabled()
|
||||
faulthandler.enable(file=config.stash[fault_handler_stderr_key])
|
||||
faulthandler.enable(file=config.stash[fault_handler_stderr_fd_key])
|
||||
|
||||
|
||||
def pytest_unconfigure(config: Config) -> None:
|
||||
@@ -37,9 +35,9 @@ def pytest_unconfigure(config: Config) -> None:
|
||||
|
||||
faulthandler.disable()
|
||||
# Close the dup file installed during pytest_configure.
|
||||
if fault_handler_stderr_key in config.stash:
|
||||
config.stash[fault_handler_stderr_key].close()
|
||||
del config.stash[fault_handler_stderr_key]
|
||||
if fault_handler_stderr_fd_key in config.stash:
|
||||
os.close(config.stash[fault_handler_stderr_fd_key])
|
||||
del config.stash[fault_handler_stderr_fd_key]
|
||||
if config.stash.get(fault_handler_originally_enabled_key, False):
|
||||
# Re-enable the faulthandler if it was originally enabled.
|
||||
faulthandler.enable(file=get_stderr_fileno())
|
||||
@@ -67,10 +65,10 @@ def get_timeout_config_value(config: Config) -> float:
|
||||
@pytest.hookimpl(hookwrapper=True, trylast=True)
|
||||
def pytest_runtest_protocol(item: Item) -> Generator[None, None, None]:
|
||||
timeout = get_timeout_config_value(item.config)
|
||||
stderr = item.config.stash[fault_handler_stderr_key]
|
||||
if timeout > 0 and stderr is not None:
|
||||
if timeout > 0:
|
||||
import faulthandler
|
||||
|
||||
stderr = item.config.stash[fault_handler_stderr_fd_key]
|
||||
faulthandler.dump_traceback_later(timeout, file=stderr)
|
||||
try:
|
||||
yield
|
||||
|
||||
+89
-48
@@ -1,3 +1,4 @@
|
||||
import dataclasses
|
||||
import functools
|
||||
import inspect
|
||||
import os
|
||||
@@ -28,8 +29,6 @@ from typing import TYPE_CHECKING
|
||||
from typing import TypeVar
|
||||
from typing import Union
|
||||
|
||||
import attr
|
||||
|
||||
import _pytest
|
||||
from _pytest import nodes
|
||||
from _pytest._code import getfslineno
|
||||
@@ -47,6 +46,7 @@ from _pytest.compat import getimfunc
|
||||
from _pytest.compat import getlocation
|
||||
from _pytest.compat import is_generator
|
||||
from _pytest.compat import NOTSET
|
||||
from _pytest.compat import NotSetType
|
||||
from _pytest.compat import overload
|
||||
from _pytest.compat import safe_getattr
|
||||
from _pytest.config import _PluggyPlugin
|
||||
@@ -58,6 +58,7 @@ from _pytest.mark import Mark
|
||||
from _pytest.mark import ParameterSet
|
||||
from _pytest.mark.structures import MarkDecorator
|
||||
from _pytest.outcomes import fail
|
||||
from _pytest.outcomes import skip
|
||||
from _pytest.outcomes import TEST_OUTCOME
|
||||
from _pytest.pathlib import absolutepath
|
||||
from _pytest.pathlib import bestrelpath
|
||||
@@ -102,7 +103,7 @@ _FixtureCachedResult = Union[
|
||||
]
|
||||
|
||||
|
||||
@attr.s(frozen=True, auto_attribs=True)
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class PseudoFixtureDef(Generic[FixtureValue]):
|
||||
cached_result: "_FixtureCachedResult[FixtureValue]"
|
||||
_scope: Scope
|
||||
@@ -112,16 +113,18 @@ def pytest_sessionstart(session: "Session") -> None:
|
||||
session._fixturemanager = FixtureManager(session)
|
||||
|
||||
|
||||
def get_scope_package(node, fixturedef: "FixtureDef[object]"):
|
||||
import pytest
|
||||
def get_scope_package(
|
||||
node: nodes.Item,
|
||||
fixturedef: "FixtureDef[object]",
|
||||
) -> Optional[Union[nodes.Item, nodes.Collector]]:
|
||||
from _pytest.python import Package
|
||||
|
||||
cls = pytest.Package
|
||||
current = node
|
||||
current: Optional[Union[nodes.Item, nodes.Collector]] = node
|
||||
fixture_package_name = "{}/{}".format(fixturedef.baseid, "__init__.py")
|
||||
while current and (
|
||||
type(current) is not cls or fixture_package_name != current.nodeid
|
||||
not isinstance(current, Package) or fixture_package_name != current.nodeid
|
||||
):
|
||||
current = current.parent
|
||||
current = current.parent # type: ignore[assignment]
|
||||
if current is None:
|
||||
return node.session
|
||||
return current
|
||||
@@ -349,8 +352,10 @@ def get_direct_param_fixture_func(request: "FixtureRequest") -> Any:
|
||||
return request.param
|
||||
|
||||
|
||||
@attr.s(slots=True, auto_attribs=True)
|
||||
@dataclasses.dataclass
|
||||
class FuncFixtureInfo:
|
||||
__slots__ = ("argnames", "initialnames", "names_closure", "name2fixturedefs")
|
||||
|
||||
# Original function argument names.
|
||||
argnames: Tuple[str, ...]
|
||||
# Argnames that function immediately requires. These include argnames +
|
||||
@@ -432,7 +437,23 @@ class FixtureRequest:
|
||||
@property
|
||||
def node(self):
|
||||
"""Underlying collection node (depends on current request scope)."""
|
||||
return self._getscopeitem(self._scope)
|
||||
scope = self._scope
|
||||
if scope is Scope.Function:
|
||||
# This might also be a non-function Item despite its attribute name.
|
||||
node: Optional[Union[nodes.Item, nodes.Collector]] = self._pyfuncitem
|
||||
elif scope is Scope.Package:
|
||||
# FIXME: _fixturedef is not defined on FixtureRequest (this class),
|
||||
# but on FixtureRequest (a subclass).
|
||||
node = get_scope_package(self._pyfuncitem, self._fixturedef) # type: ignore[attr-defined]
|
||||
else:
|
||||
node = get_scope_node(self._pyfuncitem, scope)
|
||||
if node is None and scope is Scope.Class:
|
||||
# Fallback to function item itself.
|
||||
node = self._pyfuncitem
|
||||
assert node, 'Could not obtain a node for scope "{}" for function {!r}'.format(
|
||||
scope, self._pyfuncitem
|
||||
)
|
||||
return node
|
||||
|
||||
def _getnextfixturedef(self, argname: str) -> "FixtureDef[Any]":
|
||||
fixturedefs = self._arg2fixturedefs.get(argname, None)
|
||||
@@ -516,11 +537,7 @@ class FixtureRequest:
|
||||
"""Add finalizer/teardown function to be called without arguments after
|
||||
the last test within the requesting test context finished execution."""
|
||||
# XXX usually this method is shadowed by fixturedef specific ones.
|
||||
self._addfinalizer(finalizer, scope=self.scope)
|
||||
|
||||
def _addfinalizer(self, finalizer: Callable[[], object], scope) -> None:
|
||||
node = self._getscopeitem(scope)
|
||||
node.addfinalizer(finalizer)
|
||||
self.node.addfinalizer(finalizer)
|
||||
|
||||
def applymarker(self, marker: Union[str, MarkDecorator]) -> None:
|
||||
"""Apply a marker to a single test function invocation.
|
||||
@@ -715,28 +732,6 @@ class FixtureRequest:
|
||||
lines.append("%s:%d: def %s%s" % (p, lineno + 1, factory.__name__, args))
|
||||
return lines
|
||||
|
||||
def _getscopeitem(
|
||||
self, scope: Union[Scope, "_ScopeName"]
|
||||
) -> Union[nodes.Item, nodes.Collector]:
|
||||
if isinstance(scope, str):
|
||||
scope = Scope(scope)
|
||||
if scope is Scope.Function:
|
||||
# This might also be a non-function Item despite its attribute name.
|
||||
node: Optional[Union[nodes.Item, nodes.Collector]] = self._pyfuncitem
|
||||
elif scope is Scope.Package:
|
||||
# FIXME: _fixturedef is not defined on FixtureRequest (this class),
|
||||
# but on FixtureRequest (a subclass).
|
||||
node = get_scope_package(self._pyfuncitem, self._fixturedef) # type: ignore[attr-defined]
|
||||
else:
|
||||
node = get_scope_node(self._pyfuncitem, scope)
|
||||
if node is None and scope is Scope.Class:
|
||||
# Fallback to function item itself.
|
||||
node = self._pyfuncitem
|
||||
assert node, 'Could not obtain a node for scope "{}" for function {!r}'.format(
|
||||
scope, self._pyfuncitem
|
||||
)
|
||||
return node
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return "<FixtureRequest for %r>" % (self.node)
|
||||
|
||||
@@ -1129,6 +1124,10 @@ def pytest_fixture_setup(
|
||||
except TEST_OUTCOME:
|
||||
exc_info = sys.exc_info()
|
||||
assert exc_info[0] is not None
|
||||
if isinstance(
|
||||
exc_info[1], skip.Exception
|
||||
) and not fixturefunc.__name__.startswith("xunit_setup"):
|
||||
exc_info[1]._use_item_location = True # type: ignore[attr-defined]
|
||||
fixturedef.cached_result = (None, my_cache_key, exc_info)
|
||||
raise
|
||||
fixturedef.cached_result = (result, my_cache_key, None)
|
||||
@@ -1176,19 +1175,21 @@ def wrap_function_to_error_out_if_called_directly(
|
||||
|
||||
|
||||
@final
|
||||
@attr.s(frozen=True, auto_attribs=True)
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class FixtureFunctionMarker:
|
||||
scope: "Union[_ScopeName, Callable[[str, Config], _ScopeName]]"
|
||||
params: Optional[Tuple[object, ...]] = attr.ib(converter=_params_converter)
|
||||
params: Optional[Tuple[object, ...]]
|
||||
autouse: bool = False
|
||||
ids: Optional[
|
||||
Union[Tuple[Optional[object], ...], Callable[[Any], Optional[object]]]
|
||||
] = attr.ib(
|
||||
default=None,
|
||||
converter=_ensure_immutable_ids,
|
||||
)
|
||||
] = None
|
||||
name: Optional[str] = None
|
||||
|
||||
_ispytest: dataclasses.InitVar[bool] = False
|
||||
|
||||
def __post_init__(self, _ispytest: bool) -> None:
|
||||
check_ispytest(_ispytest)
|
||||
|
||||
def __call__(self, function: FixtureFunction) -> FixtureFunction:
|
||||
if inspect.isclass(function):
|
||||
raise ValueError("class fixtures not supported (maybe in the future)")
|
||||
@@ -1308,10 +1309,11 @@ def fixture( # noqa: F811
|
||||
"""
|
||||
fixture_marker = FixtureFunctionMarker(
|
||||
scope=scope,
|
||||
params=params,
|
||||
params=tuple(params) if params is not None else None,
|
||||
autouse=autouse,
|
||||
ids=ids,
|
||||
ids=None if ids is None else ids if callable(ids) else tuple(ids),
|
||||
name=name,
|
||||
_ispytest=True,
|
||||
)
|
||||
|
||||
# Direct decoration.
|
||||
@@ -1584,13 +1586,52 @@ class FixtureManager:
|
||||
# Separate parametrized setups.
|
||||
items[:] = reorder_items(items)
|
||||
|
||||
@overload
|
||||
def parsefactories(
|
||||
self, node_or_obj, nodeid=NOTSET, unittest: bool = False
|
||||
self,
|
||||
node_or_obj: nodes.Node,
|
||||
*,
|
||||
unittest: bool = ...,
|
||||
) -> None:
|
||||
raise NotImplementedError()
|
||||
|
||||
@overload
|
||||
def parsefactories( # noqa: F811
|
||||
self,
|
||||
node_or_obj: object,
|
||||
nodeid: Optional[str],
|
||||
*,
|
||||
unittest: bool = ...,
|
||||
) -> None:
|
||||
raise NotImplementedError()
|
||||
|
||||
def parsefactories( # noqa: F811
|
||||
self,
|
||||
node_or_obj: Union[nodes.Node, object],
|
||||
nodeid: Union[str, NotSetType, None] = NOTSET,
|
||||
*,
|
||||
unittest: bool = False,
|
||||
) -> None:
|
||||
"""Collect fixtures from a collection node or object.
|
||||
|
||||
Found fixtures are parsed into `FixtureDef`s and saved.
|
||||
|
||||
If `node_or_object` is a collection node (with an underlying Python
|
||||
object), the node's object is traversed and the node's nodeid is used to
|
||||
determine the fixtures' visibilty. `nodeid` must not be specified in
|
||||
this case.
|
||||
|
||||
If `node_or_object` is an object (e.g. a plugin), the object is
|
||||
traversed and the given `nodeid` is used to determine the fixtures'
|
||||
visibility. `nodeid` must be specified in this case; None and "" mean
|
||||
total visibility.
|
||||
"""
|
||||
if nodeid is not NOTSET:
|
||||
holderobj = node_or_obj
|
||||
else:
|
||||
holderobj = node_or_obj.obj
|
||||
assert isinstance(node_or_obj, nodes.Node)
|
||||
holderobj = cast(object, node_or_obj.obj) # type: ignore[attr-defined]
|
||||
assert isinstance(node_or_obj.nodeid, str)
|
||||
nodeid = node_or_obj.nodeid
|
||||
if holderobj in self._holderobjseen:
|
||||
return
|
||||
|
||||
@@ -164,7 +164,8 @@ def showhelp(config: Config) -> None:
|
||||
tw.write(config._parser.optparser.format_help())
|
||||
tw.line()
|
||||
tw.line(
|
||||
"[pytest] ini-options in the first pytest.ini|tox.ini|setup.cfg file found:"
|
||||
"[pytest] ini-options in the first "
|
||||
"pytest.ini|tox.ini|setup.cfg|pyproject.toml file found:"
|
||||
)
|
||||
tw.line()
|
||||
|
||||
|
||||
+15
-8
@@ -21,7 +21,7 @@ if TYPE_CHECKING:
|
||||
from typing_extensions import Literal
|
||||
|
||||
from _pytest._code.code import ExceptionRepr
|
||||
from _pytest.code import ExceptionInfo
|
||||
from _pytest._code.code import ExceptionInfo
|
||||
from _pytest.config import Config
|
||||
from _pytest.config import ExitCode
|
||||
from _pytest.config import PytestPluginManager
|
||||
@@ -41,6 +41,7 @@ if TYPE_CHECKING:
|
||||
from _pytest.reports import TestReport
|
||||
from _pytest.runner import CallInfo
|
||||
from _pytest.terminal import TerminalReporter
|
||||
from _pytest.terminal import TestShortLogReport
|
||||
from _pytest.compat import LEGACY_PATH
|
||||
|
||||
|
||||
@@ -505,7 +506,9 @@ def pytest_runtest_logstart(
|
||||
See :hook:`pytest_runtest_protocol` for a description of the runtest protocol.
|
||||
|
||||
:param nodeid: Full node ID of the item.
|
||||
:param location: A tuple of ``(filename, lineno, testname)``.
|
||||
:param location: A tuple of ``(filename, lineno, testname)``
|
||||
where ``filename`` is a file path relative to ``config.rootpath``
|
||||
and ``lineno`` is 0-based.
|
||||
"""
|
||||
|
||||
|
||||
@@ -517,7 +520,9 @@ def pytest_runtest_logfinish(
|
||||
See :hook:`pytest_runtest_protocol` for a description of the runtest protocol.
|
||||
|
||||
:param nodeid: Full node ID of the item.
|
||||
:param location: A tuple of ``(filename, lineno, testname)``.
|
||||
:param location: A tuple of ``(filename, lineno, testname)``
|
||||
where ``filename`` is a file path relative to ``config.rootpath``
|
||||
and ``lineno`` is 0-based.
|
||||
"""
|
||||
|
||||
|
||||
@@ -738,7 +743,7 @@ def pytest_assertion_pass(item: "Item", lineno: int, orig: str, expl: str) -> No
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
|
||||
def pytest_report_header(
|
||||
def pytest_report_header( # type:ignore[empty-body]
|
||||
config: "Config", start_path: Path, startdir: "LEGACY_PATH"
|
||||
) -> Union[str, List[str]]:
|
||||
"""Return a string or list of strings to be displayed as header info for terminal reporting.
|
||||
@@ -767,7 +772,7 @@ def pytest_report_header(
|
||||
"""
|
||||
|
||||
|
||||
def pytest_report_collectionfinish(
|
||||
def pytest_report_collectionfinish( # type:ignore[empty-body]
|
||||
config: "Config",
|
||||
start_path: Path,
|
||||
startdir: "LEGACY_PATH",
|
||||
@@ -800,9 +805,9 @@ def pytest_report_collectionfinish(
|
||||
|
||||
|
||||
@hookspec(firstresult=True)
|
||||
def pytest_report_teststatus(
|
||||
def pytest_report_teststatus( # type:ignore[empty-body]
|
||||
report: Union["CollectReport", "TestReport"], config: "Config"
|
||||
) -> Tuple[str, str, Union[str, Mapping[str, bool]]]:
|
||||
) -> "TestShortLogReport | Tuple[str, str, Union[str, Tuple[str, Mapping[str, bool]]]]":
|
||||
"""Return result-category, shortletter and verbose word for status
|
||||
reporting.
|
||||
|
||||
@@ -880,7 +885,9 @@ def pytest_warning_recorded(
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
|
||||
def pytest_markeval_namespace(config: "Config") -> Dict[str, Any]:
|
||||
def pytest_markeval_namespace( # type:ignore[empty-body]
|
||||
config: "Config",
|
||||
) -> Dict[str, Any]:
|
||||
"""Called when constructing the globals dictionary used for
|
||||
evaluating string conditions in xfail/skipif markers.
|
||||
|
||||
|
||||
@@ -645,8 +645,8 @@ class LogXML:
|
||||
|
||||
def pytest_sessionfinish(self) -> None:
|
||||
dirname = os.path.dirname(os.path.abspath(self.logfile))
|
||||
if not os.path.isdir(dirname):
|
||||
os.makedirs(dirname)
|
||||
# exist_ok avoids filesystem race conditions between checking path existence and requesting creation
|
||||
os.makedirs(dirname, exist_ok=True)
|
||||
|
||||
with open(self.logfile, "w", encoding="utf-8") as logfile:
|
||||
suite_stop_time = timing.time()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Add backward compatibility support for the legacy py path type."""
|
||||
import dataclasses
|
||||
import shlex
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
@@ -7,7 +8,6 @@ from typing import Optional
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import Union
|
||||
|
||||
import attr
|
||||
from iniconfig import SectionWrapper
|
||||
|
||||
from _pytest.cacheprovider import Cache
|
||||
@@ -268,7 +268,7 @@ class LegacyTestdirPlugin:
|
||||
|
||||
|
||||
@final
|
||||
@attr.s(init=False, auto_attribs=True)
|
||||
@dataclasses.dataclass
|
||||
class TempdirFactory:
|
||||
"""Backward compatibility wrapper that implements :class:`py.path.local`
|
||||
for :class:`TempPathFactory`.
|
||||
|
||||
+90
-4
@@ -5,7 +5,11 @@ import os
|
||||
import re
|
||||
from contextlib import contextmanager
|
||||
from contextlib import nullcontext
|
||||
from datetime import datetime
|
||||
from datetime import timedelta
|
||||
from datetime import timezone
|
||||
from io import StringIO
|
||||
from logging import LogRecord
|
||||
from pathlib import Path
|
||||
from typing import AbstractSet
|
||||
from typing import Dict
|
||||
@@ -53,7 +57,25 @@ def _remove_ansi_escape_sequences(text: str) -> str:
|
||||
return _ANSI_ESCAPE_SEQ.sub("", text)
|
||||
|
||||
|
||||
class ColoredLevelFormatter(logging.Formatter):
|
||||
class DatetimeFormatter(logging.Formatter):
|
||||
"""A logging formatter which formats record with
|
||||
:func:`datetime.datetime.strftime` formatter instead of
|
||||
:func:`time.strftime` in case of microseconds in format string.
|
||||
"""
|
||||
|
||||
def formatTime(self, record: LogRecord, datefmt=None) -> str:
|
||||
if datefmt and "%f" in datefmt:
|
||||
ct = self.converter(record.created)
|
||||
tz = timezone(timedelta(seconds=ct.tm_gmtoff), ct.tm_zone)
|
||||
# Construct `datetime.datetime` object from `struct_time`
|
||||
# and msecs information from `record`
|
||||
dt = datetime(*ct[0:6], microsecond=round(record.msecs * 1000), tzinfo=tz)
|
||||
return dt.strftime(datefmt)
|
||||
# Use `logging.Formatter` for non-microsecond formats
|
||||
return super().formatTime(record, datefmt)
|
||||
|
||||
|
||||
class ColoredLevelFormatter(DatetimeFormatter):
|
||||
"""A logging formatter which colorizes the %(levelname)..s part of the
|
||||
log format passed to __init__."""
|
||||
|
||||
@@ -297,6 +319,13 @@ def pytest_addoption(parser: Parser) -> None:
|
||||
default=None,
|
||||
help="Auto-indent multiline messages passed to the logging module. Accepts true|on, false|off or an integer.",
|
||||
)
|
||||
group.addoption(
|
||||
"--log-disable",
|
||||
action="append",
|
||||
default=[],
|
||||
dest="logger_disable",
|
||||
help="Disable a logger by name. Can be passed multiple times.",
|
||||
)
|
||||
|
||||
|
||||
_HandlerType = TypeVar("_HandlerType", bound=logging.Handler)
|
||||
@@ -369,11 +398,12 @@ class LogCaptureFixture:
|
||||
self._initial_handler_level: Optional[int] = None
|
||||
# Dict of log name -> log level.
|
||||
self._initial_logger_levels: Dict[Optional[str], int] = {}
|
||||
self._initial_disabled_logging_level: Optional[int] = None
|
||||
|
||||
def _finalize(self) -> None:
|
||||
"""Finalize the fixture.
|
||||
|
||||
This restores the log levels changed by :meth:`set_level`.
|
||||
This restores the log levels and the disabled logging levels changed by :meth:`set_level`.
|
||||
"""
|
||||
# Restore log levels.
|
||||
if self._initial_handler_level is not None:
|
||||
@@ -381,6 +411,10 @@ class LogCaptureFixture:
|
||||
for logger_name, level in self._initial_logger_levels.items():
|
||||
logger = logging.getLogger(logger_name)
|
||||
logger.setLevel(level)
|
||||
# Disable logging at the original disabled logging level.
|
||||
if self._initial_disabled_logging_level is not None:
|
||||
logging.disable(self._initial_disabled_logging_level)
|
||||
self._initial_disabled_logging_level = None
|
||||
|
||||
@property
|
||||
def handler(self) -> LogCaptureHandler:
|
||||
@@ -446,6 +480,40 @@ class LogCaptureFixture:
|
||||
"""Reset the list of log records and the captured log text."""
|
||||
self.handler.clear()
|
||||
|
||||
def _force_enable_logging(
|
||||
self, level: Union[int, str], logger_obj: logging.Logger
|
||||
) -> int:
|
||||
"""Enable the desired logging level if the global level was disabled via ``logging.disabled``.
|
||||
|
||||
Only enables logging levels greater than or equal to the requested ``level``.
|
||||
|
||||
Does nothing if the desired ``level`` wasn't disabled.
|
||||
|
||||
:param level:
|
||||
The logger level caplog should capture.
|
||||
All logging is enabled if a non-standard logging level string is supplied.
|
||||
Valid level strings are in :data:`logging._nameToLevel`.
|
||||
:param logger_obj: The logger object to check.
|
||||
|
||||
:return: The original disabled logging level.
|
||||
"""
|
||||
original_disable_level: int = logger_obj.manager.disable # type: ignore[attr-defined]
|
||||
|
||||
if isinstance(level, str):
|
||||
# Try to translate the level string to an int for `logging.disable()`
|
||||
level = logging.getLevelName(level)
|
||||
|
||||
if not isinstance(level, int):
|
||||
# The level provided was not valid, so just un-disable all logging.
|
||||
logging.disable(logging.NOTSET)
|
||||
elif not logger_obj.isEnabledFor(level):
|
||||
# Each level is `10` away from other levels.
|
||||
# https://docs.python.org/3/library/logging.html#logging-levels
|
||||
disable_level = max(level - 10, logging.NOTSET)
|
||||
logging.disable(disable_level)
|
||||
|
||||
return original_disable_level
|
||||
|
||||
def set_level(self, level: Union[int, str], logger: Optional[str] = None) -> None:
|
||||
"""Set the level of a logger for the duration of a test.
|
||||
|
||||
@@ -453,6 +521,8 @@ class LogCaptureFixture:
|
||||
The levels of the loggers changed by this function will be
|
||||
restored to their initial values at the end of the test.
|
||||
|
||||
Will enable the requested logging level if it was disabled via :meth:`logging.disable`.
|
||||
|
||||
:param level: The level.
|
||||
:param logger: The logger to update. If not given, the root logger.
|
||||
"""
|
||||
@@ -463,6 +533,9 @@ class LogCaptureFixture:
|
||||
if self._initial_handler_level is None:
|
||||
self._initial_handler_level = self.handler.level
|
||||
self.handler.setLevel(level)
|
||||
initial_disabled_logging_level = self._force_enable_logging(level, logger_obj)
|
||||
if self._initial_disabled_logging_level is None:
|
||||
self._initial_disabled_logging_level = initial_disabled_logging_level
|
||||
|
||||
@contextmanager
|
||||
def at_level(
|
||||
@@ -472,6 +545,8 @@ class LogCaptureFixture:
|
||||
the end of the 'with' statement the level is restored to its original
|
||||
value.
|
||||
|
||||
Will enable the requested logging level if it was disabled via :meth:`logging.disable`.
|
||||
|
||||
:param level: The level.
|
||||
:param logger: The logger to update. If not given, the root logger.
|
||||
"""
|
||||
@@ -480,11 +555,13 @@ class LogCaptureFixture:
|
||||
logger_obj.setLevel(level)
|
||||
handler_orig_level = self.handler.level
|
||||
self.handler.setLevel(level)
|
||||
original_disable_level = self._force_enable_logging(level, logger_obj)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
logger_obj.setLevel(orig_level)
|
||||
self.handler.setLevel(handler_orig_level)
|
||||
logging.disable(original_disable_level)
|
||||
|
||||
|
||||
@fixture
|
||||
@@ -570,7 +647,7 @@ class LoggingPlugin:
|
||||
config, "log_file_date_format", "log_date_format"
|
||||
)
|
||||
|
||||
log_file_formatter = logging.Formatter(
|
||||
log_file_formatter = DatetimeFormatter(
|
||||
log_file_format, datefmt=log_file_date_format
|
||||
)
|
||||
self.log_file_handler.setFormatter(log_file_formatter)
|
||||
@@ -594,6 +671,15 @@ class LoggingPlugin:
|
||||
get_option_ini(config, "log_auto_indent"),
|
||||
)
|
||||
self.log_cli_handler.setFormatter(log_cli_formatter)
|
||||
self._disable_loggers(loggers_to_disable=config.option.logger_disable)
|
||||
|
||||
def _disable_loggers(self, loggers_to_disable: List[str]) -> None:
|
||||
if not loggers_to_disable:
|
||||
return
|
||||
|
||||
for name in loggers_to_disable:
|
||||
logger = logging.getLogger(name)
|
||||
logger.disabled = True
|
||||
|
||||
def _create_formatter(self, log_format, log_date_format, auto_indent):
|
||||
# Color option doesn't exist if terminal plugin is disabled.
|
||||
@@ -605,7 +691,7 @@ class LoggingPlugin:
|
||||
create_terminal_writer(self._config), log_format, log_date_format
|
||||
)
|
||||
else:
|
||||
formatter = logging.Formatter(log_format, log_date_format)
|
||||
formatter = DatetimeFormatter(log_format, log_date_format)
|
||||
|
||||
formatter._style = PercentStyleMultiline(
|
||||
formatter._style._fmt, auto_indent=auto_indent
|
||||
|
||||
+21
-16
@@ -1,5 +1,6 @@
|
||||
"""Core implementation of the testing process: init, session, runtest loop."""
|
||||
import argparse
|
||||
import dataclasses
|
||||
import fnmatch
|
||||
import functools
|
||||
import importlib
|
||||
@@ -19,8 +20,6 @@ from typing import Type
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import Union
|
||||
|
||||
import attr
|
||||
|
||||
import _pytest._code
|
||||
from _pytest import nodes
|
||||
from _pytest.compat import final
|
||||
@@ -123,11 +122,12 @@ def pytest_addoption(parser: Parser) -> None:
|
||||
)
|
||||
group._addoption(
|
||||
"-c",
|
||||
metavar="file",
|
||||
"--config-file",
|
||||
metavar="FILE",
|
||||
type=str,
|
||||
dest="inifilename",
|
||||
help="Load configuration from `file` instead of trying to locate one of the "
|
||||
"implicit configuration files",
|
||||
help="Load configuration from `FILE` instead of trying to locate one of the "
|
||||
"implicit configuration files.",
|
||||
)
|
||||
group._addoption(
|
||||
"--continue-on-collection-errors",
|
||||
@@ -400,6 +400,12 @@ def pytest_ignore_collect(collection_path: Path, config: Config) -> Optional[boo
|
||||
allow_in_venv = config.getoption("collect_in_virtualenv")
|
||||
if not allow_in_venv and _in_venv(collection_path):
|
||||
return True
|
||||
|
||||
if collection_path.is_dir():
|
||||
norecursepatterns = config.getini("norecursedirs")
|
||||
if any(fnmatch_ex(pat, collection_path) for pat in norecursepatterns):
|
||||
return True
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@@ -442,8 +448,10 @@ class Failed(Exception):
|
||||
"""Signals a stop as failed test run."""
|
||||
|
||||
|
||||
@attr.s(slots=True, auto_attribs=True)
|
||||
@dataclasses.dataclass
|
||||
class _bestrelpath_cache(Dict[Path, str]):
|
||||
__slots__ = ("path",)
|
||||
|
||||
path: Path
|
||||
|
||||
def __missing__(self, path: Path) -> str:
|
||||
@@ -561,9 +569,6 @@ class Session(nodes.FSCollector):
|
||||
ihook = self.gethookproxy(fspath.parent)
|
||||
if ihook.pytest_ignore_collect(collection_path=fspath, config=self.config):
|
||||
return False
|
||||
norecursepatterns = self.config.getini("norecursedirs")
|
||||
if any(fnmatch_ex(pat, fspath) for pat in norecursepatterns):
|
||||
return False
|
||||
return True
|
||||
|
||||
def _collectfile(
|
||||
@@ -684,8 +689,8 @@ class Session(nodes.FSCollector):
|
||||
# are not collected more than once.
|
||||
matchnodes_cache: Dict[Tuple[Type[nodes.Collector], str], CollectReport] = {}
|
||||
|
||||
# Dirnames of pkgs with dunder-init files.
|
||||
pkg_roots: Dict[str, Package] = {}
|
||||
# Directories of pkgs with dunder-init files.
|
||||
pkg_roots: Dict[Path, Package] = {}
|
||||
|
||||
for argpath, names in self._initial_parts:
|
||||
self.trace("processing argument", (argpath, names))
|
||||
@@ -706,7 +711,7 @@ class Session(nodes.FSCollector):
|
||||
col = self._collectfile(pkginit, handle_dupes=False)
|
||||
if col:
|
||||
if isinstance(col[0], Package):
|
||||
pkg_roots[str(parent)] = col[0]
|
||||
pkg_roots[parent] = col[0]
|
||||
node_cache1[col[0].path] = [col[0]]
|
||||
|
||||
# If it's a directory argument, recurse and look for any Subpackages.
|
||||
@@ -715,7 +720,7 @@ class Session(nodes.FSCollector):
|
||||
assert not names, f"invalid arg {(argpath, names)!r}"
|
||||
|
||||
seen_dirs: Set[Path] = set()
|
||||
for direntry in visit(str(argpath), self._recurse):
|
||||
for direntry in visit(argpath, self._recurse):
|
||||
if not direntry.is_file():
|
||||
continue
|
||||
|
||||
@@ -730,8 +735,8 @@ class Session(nodes.FSCollector):
|
||||
for x in self._collectfile(pkginit):
|
||||
yield x
|
||||
if isinstance(x, Package):
|
||||
pkg_roots[str(dirpath)] = x
|
||||
if str(dirpath) in pkg_roots:
|
||||
pkg_roots[dirpath] = x
|
||||
if dirpath in pkg_roots:
|
||||
# Do not collect packages here.
|
||||
continue
|
||||
|
||||
@@ -748,7 +753,7 @@ class Session(nodes.FSCollector):
|
||||
if argpath in node_cache1:
|
||||
col = node_cache1[argpath]
|
||||
else:
|
||||
collect_root = pkg_roots.get(str(argpath.parent), self)
|
||||
collect_root = pkg_roots.get(argpath.parent, self)
|
||||
col = collect_root._collectfile(argpath, handle_dupes=False)
|
||||
if col:
|
||||
node_cache1[argpath] = col
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Generic mechanism for marking and selecting python functions."""
|
||||
import dataclasses
|
||||
from typing import AbstractSet
|
||||
from typing import Collection
|
||||
from typing import List
|
||||
@@ -6,8 +7,6 @@ from typing import Optional
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import Union
|
||||
|
||||
import attr
|
||||
|
||||
from .expression import Expression
|
||||
from .expression import ParseError
|
||||
from .structures import EMPTY_PARAMETERSET_OPTION
|
||||
@@ -130,7 +129,7 @@ def pytest_cmdline_main(config: Config) -> Optional[Union[int, ExitCode]]:
|
||||
return None
|
||||
|
||||
|
||||
@attr.s(slots=True, auto_attribs=True)
|
||||
@dataclasses.dataclass
|
||||
class KeywordMatcher:
|
||||
"""A matcher for keywords.
|
||||
|
||||
@@ -145,6 +144,8 @@ class KeywordMatcher:
|
||||
any item, as well as names directly assigned to test functions.
|
||||
"""
|
||||
|
||||
__slots__ = ("_names",)
|
||||
|
||||
_names: AbstractSet[str]
|
||||
|
||||
@classmethod
|
||||
@@ -201,13 +202,15 @@ def deselect_by_keyword(items: "List[Item]", config: Config) -> None:
|
||||
items[:] = remaining
|
||||
|
||||
|
||||
@attr.s(slots=True, auto_attribs=True)
|
||||
@dataclasses.dataclass
|
||||
class MarkMatcher:
|
||||
"""A matcher for markers which are present.
|
||||
|
||||
Tries to match on any marker names, attached to the given colitem.
|
||||
"""
|
||||
|
||||
__slots__ = ("own_mark_names",)
|
||||
|
||||
own_mark_names: AbstractSet[str]
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -15,8 +15,10 @@ The semantics are:
|
||||
- or/and/not evaluate according to the usual boolean semantics.
|
||||
"""
|
||||
import ast
|
||||
import dataclasses
|
||||
import enum
|
||||
import re
|
||||
import sys
|
||||
import types
|
||||
from typing import Callable
|
||||
from typing import Iterator
|
||||
@@ -25,7 +27,10 @@ from typing import NoReturn
|
||||
from typing import Optional
|
||||
from typing import Sequence
|
||||
|
||||
import attr
|
||||
if sys.version_info >= (3, 8):
|
||||
astNameConstant = ast.Constant
|
||||
else:
|
||||
astNameConstant = ast.NameConstant
|
||||
|
||||
|
||||
__all__ = [
|
||||
@@ -44,8 +49,9 @@ class TokenType(enum.Enum):
|
||||
EOF = "end of input"
|
||||
|
||||
|
||||
@attr.s(frozen=True, slots=True, auto_attribs=True)
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Token:
|
||||
__slots__ = ("type", "value", "pos")
|
||||
type: TokenType
|
||||
value: str
|
||||
pos: int
|
||||
@@ -132,7 +138,7 @@ IDENT_PREFIX = "$"
|
||||
|
||||
def expression(s: Scanner) -> ast.Expression:
|
||||
if s.accept(TokenType.EOF):
|
||||
ret: ast.expr = ast.NameConstant(False)
|
||||
ret: ast.expr = astNameConstant(False)
|
||||
else:
|
||||
ret = expr(s)
|
||||
s.accept(TokenType.EOF, reject=True)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import collections.abc
|
||||
import dataclasses
|
||||
import inspect
|
||||
import warnings
|
||||
from typing import Any
|
||||
@@ -20,8 +21,6 @@ from typing import TYPE_CHECKING
|
||||
from typing import TypeVar
|
||||
from typing import Union
|
||||
|
||||
import attr
|
||||
|
||||
from .._code import getfslineno
|
||||
from ..compat import ascii_escaped
|
||||
from ..compat import final
|
||||
@@ -191,8 +190,10 @@ class ParameterSet(NamedTuple):
|
||||
|
||||
|
||||
@final
|
||||
@attr.s(frozen=True, init=False, auto_attribs=True)
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Mark:
|
||||
"""A pytest mark."""
|
||||
|
||||
#: Name of the mark.
|
||||
name: str
|
||||
#: Positional arguments of the mark decorator.
|
||||
@@ -201,9 +202,11 @@ class Mark:
|
||||
kwargs: Mapping[str, Any]
|
||||
|
||||
#: Source Mark for ids with parametrize Marks.
|
||||
_param_ids_from: Optional["Mark"] = attr.ib(default=None, repr=False)
|
||||
_param_ids_from: Optional["Mark"] = dataclasses.field(default=None, repr=False)
|
||||
#: Resolved/generated ids with parametrize Marks.
|
||||
_param_ids_generated: Optional[Sequence[str]] = attr.ib(default=None, repr=False)
|
||||
_param_ids_generated: Optional[Sequence[str]] = dataclasses.field(
|
||||
default=None, repr=False
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -261,7 +264,7 @@ class Mark:
|
||||
Markable = TypeVar("Markable", bound=Union[Callable[..., object], type])
|
||||
|
||||
|
||||
@attr.s(init=False, auto_attribs=True)
|
||||
@dataclasses.dataclass
|
||||
class MarkDecorator:
|
||||
"""A decorator for applying a mark on test functions and classes.
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ from contextlib import contextmanager
|
||||
from typing import Any
|
||||
from typing import Generator
|
||||
from typing import List
|
||||
from typing import Mapping
|
||||
from typing import MutableMapping
|
||||
from typing import Optional
|
||||
from typing import overload
|
||||
@@ -129,7 +130,7 @@ class MonkeyPatch:
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._setattr: List[Tuple[object, str, object]] = []
|
||||
self._setitem: List[Tuple[MutableMapping[Any, Any], object, object]] = []
|
||||
self._setitem: List[Tuple[Mapping[Any, Any], object, object]] = []
|
||||
self._cwd: Optional[str] = None
|
||||
self._savesyspath: Optional[List[str]] = None
|
||||
|
||||
@@ -290,12 +291,13 @@ class MonkeyPatch:
|
||||
self._setattr.append((target, name, oldval))
|
||||
delattr(target, name)
|
||||
|
||||
def setitem(self, dic: MutableMapping[K, V], name: K, value: V) -> None:
|
||||
def setitem(self, dic: Mapping[K, V], name: K, value: V) -> None:
|
||||
"""Set dictionary entry ``name`` to value."""
|
||||
self._setitem.append((dic, name, dic.get(name, notset)))
|
||||
dic[name] = value
|
||||
# Not all Mapping types support indexing, but MutableMapping doesn't support TypedDict
|
||||
dic[name] = value # type: ignore[index]
|
||||
|
||||
def delitem(self, dic: MutableMapping[K, V], name: K, raising: bool = True) -> None:
|
||||
def delitem(self, dic: Mapping[K, V], name: K, raising: bool = True) -> None:
|
||||
"""Delete ``name`` from dict.
|
||||
|
||||
Raises ``KeyError`` if it doesn't exist, unless ``raising`` is set to
|
||||
@@ -306,7 +308,8 @@ class MonkeyPatch:
|
||||
raise KeyError(name)
|
||||
else:
|
||||
self._setitem.append((dic, name, dic.get(name, notset)))
|
||||
del dic[name]
|
||||
# Not all Mapping types support indexing, but MutableMapping doesn't support TypedDict
|
||||
del dic[name] # type: ignore[attr-defined]
|
||||
|
||||
def setenv(self, name: str, value: str, prepend: Optional[str] = None) -> None:
|
||||
"""Set environment variable ``name`` to ``value``.
|
||||
@@ -401,11 +404,13 @@ class MonkeyPatch:
|
||||
for dictionary, key, value in reversed(self._setitem):
|
||||
if value is notset:
|
||||
try:
|
||||
del dictionary[key]
|
||||
# Not all Mapping types support indexing, but MutableMapping doesn't support TypedDict
|
||||
del dictionary[key] # type: ignore[attr-defined]
|
||||
except KeyError:
|
||||
pass # Was already deleted, so we have the desired state.
|
||||
else:
|
||||
dictionary[key] = value
|
||||
# Not all Mapping types support indexing, but MutableMapping doesn't support TypedDict
|
||||
dictionary[key] = value # type: ignore[index]
|
||||
self._setitem[:] = []
|
||||
if self._savesyspath is not None:
|
||||
sys.path[:] = self._savesyspath
|
||||
|
||||
+18
-11
@@ -22,6 +22,7 @@ import _pytest._code
|
||||
from _pytest._code import getfslineno
|
||||
from _pytest._code.code import ExceptionInfo
|
||||
from _pytest._code.code import TerminalRepr
|
||||
from _pytest._code.code import Traceback
|
||||
from _pytest.compat import cached_property
|
||||
from _pytest.compat import LEGACY_PATH
|
||||
from _pytest.config import Config
|
||||
@@ -432,8 +433,8 @@ class Node(metaclass=NodeMeta):
|
||||
assert current is None or isinstance(current, cls)
|
||||
return current
|
||||
|
||||
def _prunetraceback(self, excinfo: ExceptionInfo[BaseException]) -> None:
|
||||
pass
|
||||
def _traceback_filter(self, excinfo: ExceptionInfo[BaseException]) -> Traceback:
|
||||
return excinfo.traceback
|
||||
|
||||
def _repr_failure_py(
|
||||
self,
|
||||
@@ -449,13 +450,13 @@ class Node(metaclass=NodeMeta):
|
||||
style = "value"
|
||||
if isinstance(excinfo.value, FixtureLookupError):
|
||||
return excinfo.value.formatrepr()
|
||||
|
||||
tbfilter: Union[bool, Callable[[ExceptionInfo[BaseException]], Traceback]]
|
||||
if self.config.getoption("fulltrace", False):
|
||||
style = "long"
|
||||
tbfilter = False
|
||||
else:
|
||||
tb = _pytest._code.Traceback([excinfo.traceback[-1]])
|
||||
self._prunetraceback(excinfo)
|
||||
if len(excinfo.traceback) == 0:
|
||||
excinfo.traceback = tb
|
||||
tbfilter = self._traceback_filter
|
||||
if style == "auto":
|
||||
style = "long"
|
||||
# XXX should excinfo.getrepr record all data and toterminal() process it?
|
||||
@@ -486,7 +487,7 @@ class Node(metaclass=NodeMeta):
|
||||
abspath=abspath,
|
||||
showlocals=self.config.getoption("showlocals", False),
|
||||
style=style,
|
||||
tbfilter=False, # pruned already, or in --fulltrace mode.
|
||||
tbfilter=tbfilter,
|
||||
truncate_locals=truncate_locals,
|
||||
)
|
||||
|
||||
@@ -511,7 +512,7 @@ def get_fslocation_from_item(node: "Node") -> Tuple[Union[str, Path], Optional[i
|
||||
* "obj": a Python object that the node wraps.
|
||||
* "fspath": just a path
|
||||
|
||||
:rtype: A tuple of (str|Path, int) with filename and line number.
|
||||
:rtype: A tuple of (str|Path, int) with filename and 0-based line number.
|
||||
"""
|
||||
# See Item.location.
|
||||
location: Optional[Tuple[str, Optional[int], str]] = getattr(node, "location", None)
|
||||
@@ -557,13 +558,14 @@ class Collector(Node):
|
||||
|
||||
return self._repr_failure_py(excinfo, style=tbstyle)
|
||||
|
||||
def _prunetraceback(self, excinfo: ExceptionInfo[BaseException]) -> None:
|
||||
def _traceback_filter(self, excinfo: ExceptionInfo[BaseException]) -> Traceback:
|
||||
if hasattr(self, "path"):
|
||||
traceback = excinfo.traceback
|
||||
ntraceback = traceback.cut(path=self.path)
|
||||
if ntraceback == traceback:
|
||||
ntraceback = ntraceback.cut(excludepath=tracebackcutdir)
|
||||
excinfo.traceback = ntraceback.filter()
|
||||
return excinfo.traceback.filter(excinfo)
|
||||
return excinfo.traceback
|
||||
|
||||
|
||||
def _check_initialpaths_for_relpath(session: "Session", path: Path) -> Optional[str]:
|
||||
@@ -755,7 +757,7 @@ class Item(Node):
|
||||
Returns a tuple with three elements:
|
||||
|
||||
- The path of the test (default ``self.path``)
|
||||
- The line number of the test (default ``None``)
|
||||
- The 0-based line number of the test (default ``None``)
|
||||
- A name of the test to be shown (default ``""``)
|
||||
|
||||
.. seealso:: :ref:`non-python tests`
|
||||
@@ -764,6 +766,11 @@ class Item(Node):
|
||||
|
||||
@cached_property
|
||||
def location(self) -> Tuple[str, Optional[int], str]:
|
||||
"""
|
||||
Returns a tuple of ``(relfspath, lineno, testname)`` for this item
|
||||
where ``relfspath`` is file path relative to ``config.rootpath``
|
||||
and lineno is a 0-based line number.
|
||||
"""
|
||||
location = self.reportinfo()
|
||||
path = absolutepath(os.fspath(location[0]))
|
||||
relfspath = self.session._node_location_to_relpath(path)
|
||||
|
||||
@@ -157,8 +157,12 @@ def skip(
|
||||
The message to show the user as reason for the skip.
|
||||
|
||||
:param allow_module_level:
|
||||
Allows this function to be called at module level, skipping the rest
|
||||
of the module. Defaults to False.
|
||||
Allows this function to be called at module level.
|
||||
Raising the skip exception at module level will stop
|
||||
the execution of the module and prevent the collection of all tests in the module,
|
||||
even those defined before the `skip` call.
|
||||
|
||||
Defaults to False.
|
||||
|
||||
:param msg:
|
||||
Same as ``reason``, but deprecated. Will be removed in a future version, use ``reason`` instead.
|
||||
@@ -219,7 +223,6 @@ def _resolve_msg_to_reason(
|
||||
"""
|
||||
__tracebackhide__ = True
|
||||
if msg is not None:
|
||||
|
||||
if reason:
|
||||
from pytest import UsageError
|
||||
|
||||
|
||||
+70
-30
@@ -6,6 +6,7 @@ import itertools
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import types
|
||||
import uuid
|
||||
import warnings
|
||||
from enum import Enum
|
||||
@@ -26,8 +27,11 @@ from typing import Callable
|
||||
from typing import Dict
|
||||
from typing import Iterable
|
||||
from typing import Iterator
|
||||
from typing import List
|
||||
from typing import Optional
|
||||
from typing import Set
|
||||
from typing import Tuple
|
||||
from typing import Type
|
||||
from typing import TypeVar
|
||||
from typing import Union
|
||||
|
||||
@@ -63,21 +67,33 @@ def get_lock_path(path: _AnyPurePath) -> _AnyPurePath:
|
||||
return path.joinpath(".lock")
|
||||
|
||||
|
||||
def on_rm_rf_error(func, path: str, exc, *, start_path: Path) -> bool:
|
||||
def on_rm_rf_error(
|
||||
func,
|
||||
path: str,
|
||||
excinfo: Union[
|
||||
BaseException,
|
||||
Tuple[Type[BaseException], BaseException, Optional[types.TracebackType]],
|
||||
],
|
||||
*,
|
||||
start_path: Path,
|
||||
) -> bool:
|
||||
"""Handle known read-only errors during rmtree.
|
||||
|
||||
The returned value is used only by our own tests.
|
||||
"""
|
||||
exctype, excvalue = exc[:2]
|
||||
if isinstance(excinfo, BaseException):
|
||||
exc = excinfo
|
||||
else:
|
||||
exc = excinfo[1]
|
||||
|
||||
# Another process removed the file in the middle of the "rm_rf" (xdist for example).
|
||||
# More context: https://github.com/pytest-dev/pytest/issues/5974#issuecomment-543799018
|
||||
if isinstance(excvalue, FileNotFoundError):
|
||||
if isinstance(exc, FileNotFoundError):
|
||||
return False
|
||||
|
||||
if not isinstance(excvalue, PermissionError):
|
||||
if not isinstance(exc, PermissionError):
|
||||
warnings.warn(
|
||||
PytestWarning(f"(rm_rf) error removing {path}\n{exctype}: {excvalue}")
|
||||
PytestWarning(f"(rm_rf) error removing {path}\n{type(exc)}: {exc}")
|
||||
)
|
||||
return False
|
||||
|
||||
@@ -86,7 +102,7 @@ def on_rm_rf_error(func, path: str, exc, *, start_path: Path) -> bool:
|
||||
warnings.warn(
|
||||
PytestWarning(
|
||||
"(rm_rf) unknown function {} when removing {}:\n{}: {}".format(
|
||||
func, path, exctype, excvalue
|
||||
func, path, type(exc), exc
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -149,7 +165,10 @@ def rm_rf(path: Path) -> None:
|
||||
are read-only."""
|
||||
path = ensure_extended_length_path(path)
|
||||
onerror = partial(on_rm_rf_error, start_path=path)
|
||||
shutil.rmtree(str(path), onerror=onerror)
|
||||
if sys.version_info >= (3, 12):
|
||||
shutil.rmtree(str(path), onexc=onerror)
|
||||
else:
|
||||
shutil.rmtree(str(path), onerror=onerror)
|
||||
|
||||
|
||||
def find_prefixed(root: Path, prefix: str) -> Iterator[Path]:
|
||||
@@ -335,15 +354,26 @@ def cleanup_candidates(root: Path, prefix: str, keep: int) -> Iterator[Path]:
|
||||
yield path
|
||||
|
||||
|
||||
def cleanup_dead_symlinks(root: Path):
|
||||
for left_dir in root.iterdir():
|
||||
if left_dir.is_symlink():
|
||||
if not left_dir.resolve().exists():
|
||||
left_dir.unlink()
|
||||
|
||||
|
||||
def cleanup_numbered_dir(
|
||||
root: Path, prefix: str, keep: int, consider_lock_dead_if_created_before: float
|
||||
) -> None:
|
||||
"""Cleanup for lock driven numbered directories."""
|
||||
if not root.exists():
|
||||
return
|
||||
for path in cleanup_candidates(root, prefix, keep):
|
||||
try_cleanup(path, consider_lock_dead_if_created_before)
|
||||
for path in root.glob("garbage-*"):
|
||||
try_cleanup(path, consider_lock_dead_if_created_before)
|
||||
|
||||
cleanup_dead_symlinks(root)
|
||||
|
||||
|
||||
def make_numbered_dir_with_cleanup(
|
||||
root: Path,
|
||||
@@ -357,8 +387,10 @@ def make_numbered_dir_with_cleanup(
|
||||
for i in range(10):
|
||||
try:
|
||||
p = make_numbered_dir(root, prefix, mode)
|
||||
lock_path = create_cleanup_lock(p)
|
||||
register_cleanup_lock_removal(lock_path)
|
||||
# Only lock the current dir when keep is not 0
|
||||
if keep != 0:
|
||||
lock_path = create_cleanup_lock(p)
|
||||
register_cleanup_lock_removal(lock_path)
|
||||
except Exception as exc:
|
||||
e = exc
|
||||
else:
|
||||
@@ -464,14 +496,14 @@ def import_path(
|
||||
|
||||
* `mode == ImportMode.prepend`: the directory containing the module (or package, taking
|
||||
`__init__.py` files into account) will be put at the *start* of `sys.path` before
|
||||
being imported with `__import__.
|
||||
being imported with `importlib.import_module`.
|
||||
|
||||
* `mode == ImportMode.append`: same as `prepend`, but the directory will be appended
|
||||
to the end of `sys.path`, if not already in `sys.path`.
|
||||
|
||||
* `mode == ImportMode.importlib`: uses more fine control mechanisms provided by `importlib`
|
||||
to import the module, which avoids having to use `__import__` and muck with `sys.path`
|
||||
at all. It effectively allows having same-named test modules in different places.
|
||||
to import the module, which avoids having to muck with `sys.path` at all. It effectively
|
||||
allows having same-named test modules in different places.
|
||||
|
||||
:param root:
|
||||
Used as an anchor when mode == ImportMode.importlib to obtain
|
||||
@@ -544,8 +576,8 @@ def import_path(
|
||||
|
||||
if module_file.endswith((".pyc", ".pyo")):
|
||||
module_file = module_file[:-1]
|
||||
if module_file.endswith(os.path.sep + "__init__.py"):
|
||||
module_file = module_file[: -(len(os.path.sep + "__init__.py"))]
|
||||
if module_file.endswith(os.sep + "__init__.py"):
|
||||
module_file = module_file[: -(len(os.sep + "__init__.py"))]
|
||||
|
||||
try:
|
||||
is_same = _is_same(str(path), module_file)
|
||||
@@ -638,30 +670,38 @@ def resolve_package_path(path: Path) -> Optional[Path]:
|
||||
return result
|
||||
|
||||
|
||||
def scandir(path: Union[str, "os.PathLike[str]"]) -> List["os.DirEntry[str]"]:
|
||||
"""Scan a directory recursively, in breadth-first order.
|
||||
|
||||
The returned entries are sorted.
|
||||
"""
|
||||
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.
|
||||
for entry in s:
|
||||
try:
|
||||
entry.is_file()
|
||||
except OSError as err:
|
||||
if _ignore_error(err):
|
||||
continue
|
||||
raise
|
||||
entries.append(entry)
|
||||
entries.sort(key=lambda entry: entry.name)
|
||||
return entries
|
||||
|
||||
|
||||
def visit(
|
||||
path: Union[str, "os.PathLike[str]"], recurse: Callable[["os.DirEntry[str]"], bool]
|
||||
) -> Iterator["os.DirEntry[str]"]:
|
||||
"""Walk a directory recursively, in breadth-first order.
|
||||
|
||||
The `recurse` predicate determines whether a directory is recursed.
|
||||
|
||||
Entries at each directory level are sorted.
|
||||
"""
|
||||
|
||||
# Skip entries with symlink loops and other brokenness, so the caller doesn't
|
||||
# have to deal with it.
|
||||
entries = []
|
||||
for entry in os.scandir(path):
|
||||
try:
|
||||
entry.is_file()
|
||||
except OSError as err:
|
||||
if _ignore_error(err):
|
||||
continue
|
||||
raise
|
||||
entries.append(entry)
|
||||
|
||||
entries.sort(key=lambda entry: entry.name)
|
||||
|
||||
entries = scandir(path)
|
||||
yield from entries
|
||||
|
||||
for entry in entries:
|
||||
if entry.is_dir() and recurse(entry):
|
||||
yield from visit(entry.path, recurse)
|
||||
|
||||
+47
-41
@@ -1,4 +1,5 @@
|
||||
"""Python test discovery, setup and run of test functions."""
|
||||
import dataclasses
|
||||
import enum
|
||||
import fnmatch
|
||||
import inspect
|
||||
@@ -27,8 +28,6 @@ from typing import Tuple
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import Union
|
||||
|
||||
import attr
|
||||
|
||||
import _pytest
|
||||
from _pytest import fixtures
|
||||
from _pytest import nodes
|
||||
@@ -36,6 +35,7 @@ from _pytest._code import filter_traceback
|
||||
from _pytest._code import getfslineno
|
||||
from _pytest._code.code import ExceptionInfo
|
||||
from _pytest._code.code import TerminalRepr
|
||||
from _pytest._code.code import Traceback
|
||||
from _pytest._io import TerminalWriter
|
||||
from _pytest._io.saferepr import saferepr
|
||||
from _pytest.compat import ascii_escaped
|
||||
@@ -57,7 +57,6 @@ from _pytest.config import ExitCode
|
||||
from _pytest.config import hookimpl
|
||||
from _pytest.config.argparsing import Parser
|
||||
from _pytest.deprecated import check_ispytest
|
||||
from _pytest.deprecated import FSCOLLECTOR_GETHOOKPROXY_ISINITPATH
|
||||
from _pytest.deprecated import INSTANCE_COLLECTOR
|
||||
from _pytest.deprecated import NOSE_SUPPORT_METHOD
|
||||
from _pytest.fixtures import FuncFixtureInfo
|
||||
@@ -403,8 +402,8 @@ class PyCollector(PyobjMixin, nodes.Collector):
|
||||
|
||||
def istestfunction(self, obj: object, name: str) -> bool:
|
||||
if self.funcnamefilter(name) or self.isnosetest(obj):
|
||||
if isinstance(obj, staticmethod):
|
||||
# staticmethods need to be unwrapped.
|
||||
if isinstance(obj, (staticmethod, classmethod)):
|
||||
# staticmethods and classmethods need to be unwrapped.
|
||||
obj = safe_getattr(obj, "__func__", False)
|
||||
return callable(obj) and fixtures.getfixturemarker(obj) is None
|
||||
else:
|
||||
@@ -668,7 +667,7 @@ class Package(Module):
|
||||
config=None,
|
||||
session=None,
|
||||
nodeid=None,
|
||||
path=Optional[Path],
|
||||
path: Optional[Path] = None,
|
||||
) -> None:
|
||||
# NOTE: Could be just the following, but kept as-is for compat.
|
||||
# nodes.FSCollector.__init__(self, fspath, parent=parent)
|
||||
@@ -700,14 +699,6 @@ class Package(Module):
|
||||
func = partial(_call_with_optional_argument, teardown_module, self.obj)
|
||||
self.addfinalizer(func)
|
||||
|
||||
def gethookproxy(self, fspath: "os.PathLike[str]"):
|
||||
warnings.warn(FSCOLLECTOR_GETHOOKPROXY_ISINITPATH, stacklevel=2)
|
||||
return self.session.gethookproxy(fspath)
|
||||
|
||||
def isinitpath(self, path: Union[str, "os.PathLike[str]"]) -> bool:
|
||||
warnings.warn(FSCOLLECTOR_GETHOOKPROXY_ISINITPATH, stacklevel=2)
|
||||
return self.session.isinitpath(path)
|
||||
|
||||
def _recurse(self, direntry: "os.DirEntry[str]") -> bool:
|
||||
if direntry.name == "__pycache__":
|
||||
return False
|
||||
@@ -715,9 +706,6 @@ class Package(Module):
|
||||
ihook = self.session.gethookproxy(fspath.parent)
|
||||
if ihook.pytest_ignore_collect(collection_path=fspath, config=self.config):
|
||||
return False
|
||||
norecursepatterns = self.config.getini("norecursedirs")
|
||||
if any(fnmatch_ex(pat, fspath) for pat in norecursepatterns):
|
||||
return False
|
||||
return True
|
||||
|
||||
def _collectfile(
|
||||
@@ -746,11 +734,11 @@ class Package(Module):
|
||||
|
||||
def collect(self) -> Iterable[Union[nodes.Item, nodes.Collector]]:
|
||||
this_path = self.path.parent
|
||||
init_module = this_path / "__init__.py"
|
||||
if init_module.is_file() and path_matches_patterns(
|
||||
init_module, self.config.getini("python_files")
|
||||
):
|
||||
yield Module.from_parent(self, path=init_module)
|
||||
|
||||
# Always collect the __init__ first.
|
||||
if path_matches_patterns(self.path, self.config.getini("python_files")):
|
||||
yield Module.from_parent(self, path=self.path)
|
||||
|
||||
pkg_prefixes: Set[Path] = set()
|
||||
for direntry in visit(str(this_path), recurse=self._recurse):
|
||||
path = Path(direntry.path)
|
||||
@@ -790,7 +778,8 @@ def _call_with_optional_argument(func, arg) -> None:
|
||||
|
||||
def _get_first_non_fixture_func(obj: object, names: Iterable[str]) -> Optional[object]:
|
||||
"""Return the attribute from the given object to be used as a setup/teardown
|
||||
xunit-style function, but only if not marked as a fixture to avoid calling it twice."""
|
||||
xunit-style function, but only if not marked as a fixture to avoid calling it twice.
|
||||
"""
|
||||
for name in names:
|
||||
meth: Optional[object] = getattr(obj, name, None)
|
||||
if meth is not None and fixtures.getfixturemarker(meth) is None:
|
||||
@@ -848,7 +837,7 @@ class Class(PyCollector):
|
||||
other fixtures (#517).
|
||||
"""
|
||||
setup_class = _get_first_non_fixture_func(self.obj, ("setup_class",))
|
||||
teardown_class = getattr(self.obj, "teardown_class", None)
|
||||
teardown_class = _get_first_non_fixture_func(self.obj, ("teardown_class",))
|
||||
if setup_class is None and teardown_class is None:
|
||||
return
|
||||
|
||||
@@ -885,12 +874,12 @@ class Class(PyCollector):
|
||||
emit_nose_setup_warning = True
|
||||
setup_method = _get_first_non_fixture_func(self.obj, (setup_name,))
|
||||
teardown_name = "teardown_method"
|
||||
teardown_method = getattr(self.obj, teardown_name, None)
|
||||
teardown_method = _get_first_non_fixture_func(self.obj, (teardown_name,))
|
||||
emit_nose_teardown_warning = False
|
||||
if teardown_method is None and has_nose:
|
||||
teardown_name = "teardown"
|
||||
emit_nose_teardown_warning = True
|
||||
teardown_method = getattr(self.obj, teardown_name, None)
|
||||
teardown_method = _get_first_non_fixture_func(self.obj, (teardown_name,))
|
||||
if setup_method is None and teardown_method is None:
|
||||
return
|
||||
|
||||
@@ -956,10 +945,20 @@ def hasnew(obj: object) -> bool:
|
||||
|
||||
|
||||
@final
|
||||
@attr.s(frozen=True, auto_attribs=True, slots=True)
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class IdMaker:
|
||||
"""Make IDs for a parametrization."""
|
||||
|
||||
__slots__ = (
|
||||
"argnames",
|
||||
"parametersets",
|
||||
"idfn",
|
||||
"ids",
|
||||
"config",
|
||||
"nodeid",
|
||||
"func_name",
|
||||
)
|
||||
|
||||
# The argnames of the parametrization.
|
||||
argnames: Sequence[str]
|
||||
# The ParameterSets of the parametrization.
|
||||
@@ -1109,7 +1108,7 @@ class IdMaker:
|
||||
|
||||
|
||||
@final
|
||||
@attr.s(frozen=True, slots=True, auto_attribs=True)
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class CallSpec2:
|
||||
"""A planned parameterized invocation of a test function.
|
||||
|
||||
@@ -1120,18 +1119,18 @@ class CallSpec2:
|
||||
|
||||
# arg name -> arg value which will be passed to the parametrized test
|
||||
# function (direct parameterization).
|
||||
funcargs: Dict[str, object] = attr.Factory(dict)
|
||||
funcargs: Dict[str, object] = dataclasses.field(default_factory=dict)
|
||||
# arg name -> arg value which will be passed to a fixture of the same name
|
||||
# (indirect parametrization).
|
||||
params: Dict[str, object] = attr.Factory(dict)
|
||||
params: Dict[str, object] = dataclasses.field(default_factory=dict)
|
||||
# arg name -> arg index.
|
||||
indices: Dict[str, int] = attr.Factory(dict)
|
||||
indices: Dict[str, int] = dataclasses.field(default_factory=dict)
|
||||
# Used for sorting parametrized resources.
|
||||
_arg2scope: Dict[str, Scope] = attr.Factory(dict)
|
||||
_arg2scope: Dict[str, Scope] = dataclasses.field(default_factory=dict)
|
||||
# Parts which will be added to the item's name in `[..]` separated by "-".
|
||||
_idlist: List[str] = attr.Factory(list)
|
||||
_idlist: List[str] = dataclasses.field(default_factory=list)
|
||||
# Marks which will be applied to the item.
|
||||
marks: List[Mark] = attr.Factory(list)
|
||||
marks: List[Mark] = dataclasses.field(default_factory=list)
|
||||
|
||||
def setmulti(
|
||||
self,
|
||||
@@ -1163,9 +1162,9 @@ class CallSpec2:
|
||||
return CallSpec2(
|
||||
funcargs=funcargs,
|
||||
params=params,
|
||||
arg2scope=arg2scope,
|
||||
indices=indices,
|
||||
idlist=[*self._idlist, id],
|
||||
_arg2scope=arg2scope,
|
||||
_idlist=[*self._idlist, id],
|
||||
marks=[*self.marks, *normalize_mark_list(marks)],
|
||||
)
|
||||
|
||||
@@ -1791,7 +1790,7 @@ class Function(PyobjMixin, nodes.Item):
|
||||
def setup(self) -> None:
|
||||
self._request._fillfixtures()
|
||||
|
||||
def _prunetraceback(self, excinfo: ExceptionInfo[BaseException]) -> None:
|
||||
def _traceback_filter(self, excinfo: ExceptionInfo[BaseException]) -> Traceback:
|
||||
if hasattr(self, "_obj") and not self.config.getoption("fulltrace", False):
|
||||
code = _pytest._code.Code.from_function(get_real_func(self.obj))
|
||||
path, firstlineno = code.path, code.firstlineno
|
||||
@@ -1803,14 +1802,21 @@ class Function(PyobjMixin, nodes.Item):
|
||||
ntraceback = ntraceback.filter(filter_traceback)
|
||||
if not ntraceback:
|
||||
ntraceback = traceback
|
||||
ntraceback = ntraceback.filter(excinfo)
|
||||
|
||||
excinfo.traceback = ntraceback.filter()
|
||||
# issue364: mark all but first and last frames to
|
||||
# only show a single-line message for each frame.
|
||||
if self.config.getoption("tbstyle", "auto") == "auto":
|
||||
if len(excinfo.traceback) > 2:
|
||||
for entry in excinfo.traceback[1:-1]:
|
||||
entry.set_repr_style("short")
|
||||
if len(ntraceback) > 2:
|
||||
ntraceback = Traceback(
|
||||
entry
|
||||
if i == 0 or i == len(ntraceback) - 1
|
||||
else entry.with_repr_style("short")
|
||||
for i, entry in enumerate(ntraceback)
|
||||
)
|
||||
|
||||
return ntraceback
|
||||
return excinfo.traceback
|
||||
|
||||
# TODO: Type ignored -- breaks Liskov Substitution.
|
||||
def repr_failure( # type: ignore[override]
|
||||
|
||||
+18
-16
@@ -8,7 +8,7 @@ from types import TracebackType
|
||||
from typing import Any
|
||||
from typing import Callable
|
||||
from typing import cast
|
||||
from typing import Generic
|
||||
from typing import ContextManager
|
||||
from typing import List
|
||||
from typing import Mapping
|
||||
from typing import Optional
|
||||
@@ -269,10 +269,16 @@ class ApproxMapping(ApproxBase):
|
||||
max_abs_diff = max(
|
||||
max_abs_diff, abs(approx_value.expected - other_value)
|
||||
)
|
||||
max_rel_diff = max(
|
||||
max_rel_diff,
|
||||
abs((approx_value.expected - other_value) / approx_value.expected),
|
||||
)
|
||||
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
|
||||
),
|
||||
)
|
||||
different_ids.append(approx_key)
|
||||
|
||||
message_data = [
|
||||
@@ -801,8 +807,8 @@ def raises( # noqa: F811
|
||||
r"""Assert that a code block/function call raises an exception.
|
||||
|
||||
:param typing.Type[E] | typing.Tuple[typing.Type[E], ...] expected_exception:
|
||||
The excpected exception type, or a tuple if one of multiple possible
|
||||
exception types are excepted.
|
||||
The expected exception type, or a tuple if one of multiple possible
|
||||
exception types are expected.
|
||||
:kwparam str | typing.Pattern[str] | None match:
|
||||
If specified, a string containing a regular expression,
|
||||
or a regular expression object, that is tested against the string
|
||||
@@ -918,10 +924,10 @@ def raises( # noqa: F811
|
||||
f"any special code to say 'this should never raise an exception'."
|
||||
)
|
||||
if isinstance(expected_exception, type):
|
||||
excepted_exceptions: Tuple[Type[E], ...] = (expected_exception,)
|
||||
expected_exceptions: Tuple[Type[E], ...] = (expected_exception,)
|
||||
else:
|
||||
excepted_exceptions = expected_exception
|
||||
for exc in excepted_exceptions:
|
||||
expected_exceptions = expected_exception
|
||||
for exc in expected_exceptions:
|
||||
if not isinstance(exc, type) or not issubclass(exc, BaseException):
|
||||
msg = "expected exception must be a BaseException type, not {}" # type: ignore[unreachable]
|
||||
not_a = exc.__name__ if isinstance(exc, type) else type(exc).__name__
|
||||
@@ -944,11 +950,7 @@ def raises( # noqa: F811
|
||||
try:
|
||||
func(*args[1:], **kwargs)
|
||||
except expected_exception as e:
|
||||
# We just caught the exception - there is a traceback.
|
||||
assert e.__traceback__ is not None
|
||||
return _pytest._code.ExceptionInfo.from_exc_info(
|
||||
(type(e), e, e.__traceback__)
|
||||
)
|
||||
return _pytest._code.ExceptionInfo.from_exception(e)
|
||||
fail(message)
|
||||
|
||||
|
||||
@@ -957,7 +959,7 @@ raises.Exception = fail.Exception # type: ignore
|
||||
|
||||
|
||||
@final
|
||||
class RaisesContext(Generic[E]):
|
||||
class RaisesContext(ContextManager[_pytest._code.ExceptionInfo[E]]):
|
||||
def __init__(
|
||||
self,
|
||||
expected_exception: Union[Type[E], Tuple[Type[E], ...]],
|
||||
|
||||
+28
-9
@@ -1,3 +1,4 @@
|
||||
import dataclasses
|
||||
import os
|
||||
from io import StringIO
|
||||
from pprint import pprint
|
||||
@@ -16,8 +17,6 @@ from typing import TYPE_CHECKING
|
||||
from typing import TypeVar
|
||||
from typing import Union
|
||||
|
||||
import attr
|
||||
|
||||
from _pytest._code.code import ExceptionChainRepr
|
||||
from _pytest._code.code import ExceptionInfo
|
||||
from _pytest._code.code import ExceptionRepr
|
||||
@@ -263,6 +262,8 @@ class TestReport(BaseReport):
|
||||
when: "Literal['setup', 'call', 'teardown']",
|
||||
sections: Iterable[Tuple[str, str]] = (),
|
||||
duration: float = 0,
|
||||
start: float = 0,
|
||||
stop: float = 0,
|
||||
user_properties: Optional[Iterable[Tuple[str, object]]] = None,
|
||||
**extra,
|
||||
) -> None:
|
||||
@@ -272,6 +273,8 @@ class TestReport(BaseReport):
|
||||
#: A (filesystempath, lineno, domaininfo) tuple indicating the
|
||||
#: actual location of a test item - it might be different from the
|
||||
#: collected one e.g. if a method is inherited from a different module.
|
||||
#: The filesystempath may be relative to ``config.rootdir``.
|
||||
#: The line number is 0-based.
|
||||
self.location: Tuple[str, Optional[int], str] = location
|
||||
|
||||
#: A name -> value dictionary containing all keywords and
|
||||
@@ -300,6 +303,11 @@ class TestReport(BaseReport):
|
||||
#: Time it took to run just the test.
|
||||
self.duration: float = duration
|
||||
|
||||
#: The system time when the call started, in seconds since the epoch.
|
||||
self.start: float = start
|
||||
#: The system time when the call ended, in seconds since the epoch.
|
||||
self.stop: float = stop
|
||||
|
||||
self.__dict__.update(extra)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
@@ -318,6 +326,8 @@ class TestReport(BaseReport):
|
||||
# Remove "collect" from the Literal type -- only for collection calls.
|
||||
assert when != "collect"
|
||||
duration = call.duration
|
||||
start = call.start
|
||||
stop = call.stop
|
||||
keywords = {x: 1 for x in item.keywords}
|
||||
excinfo = call.excinfo
|
||||
sections = []
|
||||
@@ -337,6 +347,9 @@ class TestReport(BaseReport):
|
||||
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."
|
||||
if excinfo.value._use_item_location:
|
||||
path, line = item.reportinfo()[:2]
|
||||
assert line is not None
|
||||
@@ -362,6 +375,8 @@ class TestReport(BaseReport):
|
||||
when,
|
||||
sections,
|
||||
duration,
|
||||
start,
|
||||
stop,
|
||||
user_properties=item.user_properties,
|
||||
)
|
||||
|
||||
@@ -407,7 +422,9 @@ class CollectReport(BaseReport):
|
||||
self.__dict__.update(extra)
|
||||
|
||||
@property
|
||||
def location(self):
|
||||
def location( # type:ignore[override]
|
||||
self,
|
||||
) -> Optional[Tuple[str, Optional[int], str]]:
|
||||
return (self.fspath, None, self.fspath)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
@@ -459,15 +476,15 @@ def _report_to_json(report: BaseReport) -> Dict[str, Any]:
|
||||
def serialize_repr_entry(
|
||||
entry: Union[ReprEntry, ReprEntryNative]
|
||||
) -> Dict[str, Any]:
|
||||
data = attr.asdict(entry)
|
||||
data = dataclasses.asdict(entry)
|
||||
for key, value in data.items():
|
||||
if hasattr(value, "__dict__"):
|
||||
data[key] = attr.asdict(value)
|
||||
data[key] = dataclasses.asdict(value)
|
||||
entry_data = {"type": type(entry).__name__, "data": data}
|
||||
return entry_data
|
||||
|
||||
def serialize_repr_traceback(reprtraceback: ReprTraceback) -> Dict[str, Any]:
|
||||
result = attr.asdict(reprtraceback)
|
||||
result = dataclasses.asdict(reprtraceback)
|
||||
result["reprentries"] = [
|
||||
serialize_repr_entry(x) for x in reprtraceback.reprentries
|
||||
]
|
||||
@@ -477,7 +494,7 @@ def _report_to_json(report: BaseReport) -> Dict[str, Any]:
|
||||
reprcrash: Optional[ReprFileLocation],
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
if reprcrash is not None:
|
||||
return attr.asdict(reprcrash)
|
||||
return dataclasses.asdict(reprcrash)
|
||||
else:
|
||||
return None
|
||||
|
||||
@@ -573,7 +590,6 @@ def _report_kwargs_from_json(reportdict: Dict[str, Any]) -> Dict[str, Any]:
|
||||
and "reprcrash" in reportdict["longrepr"]
|
||||
and "reprtraceback" in reportdict["longrepr"]
|
||||
):
|
||||
|
||||
reprtraceback = deserialize_repr_traceback(
|
||||
reportdict["longrepr"]["reprtraceback"]
|
||||
)
|
||||
@@ -594,7 +610,10 @@ def _report_kwargs_from_json(reportdict: Dict[str, Any]) -> Dict[str, Any]:
|
||||
ExceptionChainRepr, ReprExceptionInfo
|
||||
] = ExceptionChainRepr(chain)
|
||||
else:
|
||||
exception_info = ReprExceptionInfo(reprtraceback, reprcrash)
|
||||
exception_info = ReprExceptionInfo(
|
||||
reprtraceback=reprtraceback,
|
||||
reprcrash=reprcrash,
|
||||
)
|
||||
|
||||
for section in reportdict["longrepr"]["sections"]:
|
||||
exception_info.addsection(*section)
|
||||
|
||||
+19
-10
@@ -1,5 +1,6 @@
|
||||
"""Basic collect and runtest protocol implementations."""
|
||||
import bdb
|
||||
import dataclasses
|
||||
import os
|
||||
import sys
|
||||
from typing import Callable
|
||||
@@ -14,8 +15,6 @@ from typing import TYPE_CHECKING
|
||||
from typing import TypeVar
|
||||
from typing import Union
|
||||
|
||||
import attr
|
||||
|
||||
from .reports import BaseReport
|
||||
from .reports import CollectErrorRepr
|
||||
from .reports import CollectReport
|
||||
@@ -35,6 +34,9 @@ from _pytest.outcomes import OutcomeException
|
||||
from _pytest.outcomes import Skipped
|
||||
from _pytest.outcomes import TEST_OUTCOME
|
||||
|
||||
if sys.version_info[:2] < (3, 11):
|
||||
from exceptiongroup import BaseExceptionGroup
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing_extensions import Literal
|
||||
|
||||
@@ -265,7 +267,7 @@ TResult = TypeVar("TResult", covariant=True)
|
||||
|
||||
|
||||
@final
|
||||
@attr.s(repr=False, init=False, auto_attribs=True)
|
||||
@dataclasses.dataclass
|
||||
class CallInfo(Generic[TResult]):
|
||||
"""Result/Exception info of a function invocation."""
|
||||
|
||||
@@ -512,22 +514,29 @@ class SetupState:
|
||||
stack is torn down.
|
||||
"""
|
||||
needed_collectors = nextitem and nextitem.listchain() or []
|
||||
exc = None
|
||||
exceptions: List[BaseException] = []
|
||||
while self.stack:
|
||||
if list(self.stack.keys()) == needed_collectors[: len(self.stack)]:
|
||||
break
|
||||
node, (finalizers, _) = self.stack.popitem()
|
||||
these_exceptions = []
|
||||
while finalizers:
|
||||
fin = finalizers.pop()
|
||||
try:
|
||||
fin()
|
||||
except TEST_OUTCOME as e:
|
||||
# XXX Only first exception will be seen by user,
|
||||
# ideally all should be reported.
|
||||
if exc is None:
|
||||
exc = e
|
||||
if exc:
|
||||
raise exc
|
||||
these_exceptions.append(e)
|
||||
|
||||
if len(these_exceptions) == 1:
|
||||
exceptions.extend(these_exceptions)
|
||||
elif these_exceptions:
|
||||
msg = f"errors while tearing down {node!r}"
|
||||
exceptions.append(BaseExceptionGroup(msg, these_exceptions[::-1]))
|
||||
|
||||
if len(exceptions) == 1:
|
||||
raise exceptions[0]
|
||||
elif exceptions:
|
||||
raise BaseExceptionGroup("errors during test teardown", exceptions[::-1])
|
||||
if nextitem is None:
|
||||
assert not self.stack
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Support for skip/xfail functions and markers."""
|
||||
import dataclasses
|
||||
import os
|
||||
import platform
|
||||
import sys
|
||||
@@ -9,8 +10,6 @@ from typing import Optional
|
||||
from typing import Tuple
|
||||
from typing import Type
|
||||
|
||||
import attr
|
||||
|
||||
from _pytest.config import Config
|
||||
from _pytest.config import hookimpl
|
||||
from _pytest.config.argparsing import Parser
|
||||
@@ -157,7 +156,7 @@ def evaluate_condition(item: Item, mark: Mark, condition: object) -> Tuple[bool,
|
||||
return result, reason
|
||||
|
||||
|
||||
@attr.s(slots=True, frozen=True, auto_attribs=True)
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Skip:
|
||||
"""The result of evaluate_skip_marks()."""
|
||||
|
||||
@@ -192,10 +191,12 @@ def evaluate_skip_marks(item: Item) -> Optional[Skip]:
|
||||
return None
|
||||
|
||||
|
||||
@attr.s(slots=True, frozen=True, auto_attribs=True)
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Xfail:
|
||||
"""The result of evaluate_xfail_marks()."""
|
||||
|
||||
__slots__ = ("reason", "run", "strict", "raises")
|
||||
|
||||
reason: str
|
||||
run: bool
|
||||
strict: bool
|
||||
|
||||
@@ -48,6 +48,10 @@ def pytest_configure(config: Config) -> None:
|
||||
def pytest_sessionfinish(session: Session) -> None:
|
||||
if not session.config.getoption("stepwise"):
|
||||
assert session.config.cache is not None
|
||||
if hasattr(session.config, "workerinput"):
|
||||
# Do not update cache if this process is a xdist worker to prevent
|
||||
# race conditions (#10641).
|
||||
return
|
||||
# Clear the list of failing tests if the plugin is not active.
|
||||
session.config.cache.set(STEPWISE_CACHE_DIR, [])
|
||||
|
||||
@@ -119,4 +123,8 @@ class StepwisePlugin:
|
||||
return None
|
||||
|
||||
def pytest_sessionfinish(self) -> None:
|
||||
if hasattr(self.config, "workerinput"):
|
||||
# Do not update cache if this process is a xdist worker to prevent
|
||||
# race conditions (#10641).
|
||||
return
|
||||
self.cache.set(STEPWISE_CACHE_DIR, self.lastfailed)
|
||||
|
||||
+65
-16
@@ -3,10 +3,12 @@
|
||||
This is a good source for looking at the various reporting hooks.
|
||||
"""
|
||||
import argparse
|
||||
import dataclasses
|
||||
import datetime
|
||||
import inspect
|
||||
import platform
|
||||
import sys
|
||||
import textwrap
|
||||
import warnings
|
||||
from collections import Counter
|
||||
from functools import partial
|
||||
@@ -19,6 +21,7 @@ from typing import Dict
|
||||
from typing import Generator
|
||||
from typing import List
|
||||
from typing import Mapping
|
||||
from typing import NamedTuple
|
||||
from typing import Optional
|
||||
from typing import Sequence
|
||||
from typing import Set
|
||||
@@ -27,7 +30,6 @@ from typing import Tuple
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import Union
|
||||
|
||||
import attr
|
||||
import pluggy
|
||||
|
||||
import _pytest._version
|
||||
@@ -111,6 +113,26 @@ class MoreQuietAction(argparse.Action):
|
||||
namespace.quiet = getattr(namespace, "quiet", 0) + 1
|
||||
|
||||
|
||||
class TestShortLogReport(NamedTuple):
|
||||
"""Used to store the test status result category, shortletter and verbose word.
|
||||
For example ``"rerun", "R", ("RERUN", {"yellow": True})``.
|
||||
|
||||
:ivar category:
|
||||
The class of result, for example ``“passed”``, ``“skipped”``, ``“error”``, or the empty string.
|
||||
|
||||
:ivar letter:
|
||||
The short letter shown as testing progresses, for example ``"."``, ``"s"``, ``"E"``, or the empty string.
|
||||
|
||||
:ivar word:
|
||||
Verbose word is shown as testing progresses in verbose mode, for example ``"PASSED"``, ``"SKIPPED"``,
|
||||
``"ERROR"``, or the empty string.
|
||||
"""
|
||||
|
||||
category: str
|
||||
letter: str
|
||||
word: Union[str, Tuple[str, Mapping[str, bool]]]
|
||||
|
||||
|
||||
def pytest_addoption(parser: Parser) -> None:
|
||||
group = parser.getgroup("terminal reporting", "Reporting", after="general")
|
||||
group._addoption(
|
||||
@@ -229,7 +251,8 @@ def pytest_addoption(parser: Parser) -> None:
|
||||
parser.addini(
|
||||
"console_output_style",
|
||||
help='Console output: "classic", or with additional progress information '
|
||||
'("progress" (percentage) | "count")',
|
||||
'("progress" (percentage) | "count" | "progress-even-when-capture-no" (forces '
|
||||
"progress even when capture=no)",
|
||||
default="progress",
|
||||
)
|
||||
|
||||
@@ -287,7 +310,7 @@ def pytest_report_teststatus(report: BaseReport) -> Tuple[str, str, str]:
|
||||
return outcome, letter, outcome.upper()
|
||||
|
||||
|
||||
@attr.s(auto_attribs=True)
|
||||
@dataclasses.dataclass
|
||||
class WarningReport:
|
||||
"""Simple structure to hold warnings information captured by ``pytest_warning_recorded``.
|
||||
|
||||
@@ -346,14 +369,19 @@ class TerminalReporter:
|
||||
|
||||
def _determine_show_progress_info(self) -> "Literal['progress', 'count', False]":
|
||||
"""Return whether we should display progress information based on the current config."""
|
||||
# do not show progress if we are not capturing output (#3038)
|
||||
if self.config.getoption("capture", "no") == "no":
|
||||
# do not show progress if we are not capturing output (#3038) unless explicitly
|
||||
# overridden by progress-even-when-capture-no
|
||||
if (
|
||||
self.config.getoption("capture", "no") == "no"
|
||||
and self.config.getini("console_output_style")
|
||||
!= "progress-even-when-capture-no"
|
||||
):
|
||||
return False
|
||||
# do not show progress if we are showing fixture setup/teardown
|
||||
if self.config.getoption("setupshow", False):
|
||||
return False
|
||||
cfg: str = self.config.getini("console_output_style")
|
||||
if cfg == "progress":
|
||||
if cfg == "progress" or cfg == "progress-even-when-capture-no":
|
||||
return "progress"
|
||||
elif cfg == "count":
|
||||
return "count"
|
||||
@@ -420,6 +448,28 @@ class TerminalReporter:
|
||||
self._tw.line()
|
||||
self.currentfspath = None
|
||||
|
||||
def wrap_write(
|
||||
self,
|
||||
content: str,
|
||||
*,
|
||||
flush: bool = False,
|
||||
margin: int = 8,
|
||||
line_sep: str = "\n",
|
||||
**markup: bool,
|
||||
) -> None:
|
||||
"""Wrap message with margin for progress info."""
|
||||
width_of_current_line = self._tw.width_of_current_line
|
||||
wrapped = line_sep.join(
|
||||
textwrap.wrap(
|
||||
" " * width_of_current_line + content,
|
||||
width=self._screen_width - margin,
|
||||
drop_whitespace=True,
|
||||
replace_whitespace=False,
|
||||
),
|
||||
)
|
||||
wrapped = wrapped[width_of_current_line:]
|
||||
self._tw.write(wrapped, flush=flush, **markup)
|
||||
|
||||
def write(self, content: str, *, flush: bool = False, **markup: bool) -> None:
|
||||
self._tw.write(content, flush=flush, **markup)
|
||||
|
||||
@@ -519,10 +569,11 @@ class TerminalReporter:
|
||||
def pytest_runtest_logreport(self, report: TestReport) -> None:
|
||||
self._tests_ran = True
|
||||
rep = report
|
||||
res: Tuple[
|
||||
str, str, Union[str, Tuple[str, Mapping[str, bool]]]
|
||||
] = self.config.hook.pytest_report_teststatus(report=rep, config=self.config)
|
||||
category, letter, word = res
|
||||
|
||||
res = TestShortLogReport(
|
||||
*self.config.hook.pytest_report_teststatus(report=rep, config=self.config)
|
||||
)
|
||||
category, letter, word = res.category, res.letter, res.word
|
||||
if not isinstance(word, tuple):
|
||||
markup = None
|
||||
else:
|
||||
@@ -566,7 +617,7 @@ class TerminalReporter:
|
||||
formatted_reason = f" ({reason})"
|
||||
|
||||
if reason and formatted_reason is not None:
|
||||
self._tw.write(formatted_reason)
|
||||
self.wrap_write(formatted_reason)
|
||||
if self._show_progress_info:
|
||||
self._write_progress_information_filling_space()
|
||||
else:
|
||||
@@ -733,16 +784,14 @@ class TerminalReporter:
|
||||
self.write_line(line)
|
||||
|
||||
def pytest_report_header(self, config: Config) -> List[str]:
|
||||
line = "rootdir: %s" % config.rootpath
|
||||
result = [f"rootdir: {config.rootpath}"]
|
||||
|
||||
if config.inipath:
|
||||
line += ", configfile: " + bestrelpath(config.rootpath, config.inipath)
|
||||
result.append("configfile: " + bestrelpath(config.rootpath, config.inipath))
|
||||
|
||||
if config.args_source == Config.ArgsSource.TESTPATHS:
|
||||
testpaths: List[str] = config.getini("testpaths")
|
||||
line += ", testpaths: {}".format(", ".join(testpaths))
|
||||
|
||||
result = [line]
|
||||
result.append("testpaths: {}".format(", ".join(testpaths)))
|
||||
|
||||
plugininfo = config.pluginmanager.list_plugin_distinfo()
|
||||
if plugininfo:
|
||||
|
||||
+132
-24
@@ -1,40 +1,66 @@
|
||||
"""Support for providing temporary directories to test functions."""
|
||||
import dataclasses
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from shutil import rmtree
|
||||
from typing import Any
|
||||
from typing import Dict
|
||||
from typing import Generator
|
||||
from typing import Optional
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import Union
|
||||
|
||||
import attr
|
||||
from _pytest.nodes import Item
|
||||
from _pytest.reports import CollectReport
|
||||
from _pytest.stash import StashKey
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing_extensions import Literal
|
||||
|
||||
RetentionType = Literal["all", "failed", "none"]
|
||||
|
||||
|
||||
from _pytest.config.argparsing import Parser
|
||||
|
||||
from .pathlib import LOCK_TIMEOUT
|
||||
from .pathlib import make_numbered_dir
|
||||
from .pathlib import make_numbered_dir_with_cleanup
|
||||
from .pathlib import rm_rf
|
||||
from _pytest.compat import final
|
||||
from .pathlib import cleanup_dead_symlinks
|
||||
from _pytest.compat import final, get_user_id
|
||||
from _pytest.config import Config
|
||||
from _pytest.config import ExitCode
|
||||
from _pytest.config import hookimpl
|
||||
from _pytest.deprecated import check_ispytest
|
||||
from _pytest.fixtures import fixture
|
||||
from _pytest.fixtures import FixtureRequest
|
||||
from _pytest.monkeypatch import MonkeyPatch
|
||||
|
||||
tmppath_result_key = StashKey[Dict[str, bool]]()
|
||||
|
||||
|
||||
@final
|
||||
@attr.s(init=False)
|
||||
@dataclasses.dataclass
|
||||
class TempPathFactory:
|
||||
"""Factory for temporary directories under the common base temp directory.
|
||||
|
||||
The base directory can be configured using the ``--basetemp`` option.
|
||||
"""
|
||||
|
||||
_given_basetemp = attr.ib(type=Optional[Path])
|
||||
_trace = attr.ib()
|
||||
_basetemp = attr.ib(type=Optional[Path])
|
||||
_given_basetemp: Optional[Path]
|
||||
# pluggy TagTracerSub, not currently exposed, so Any.
|
||||
_trace: Any
|
||||
_basetemp: Optional[Path]
|
||||
_retention_count: int
|
||||
_retention_policy: "RetentionType"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
given_basetemp: Optional[Path],
|
||||
retention_count: int,
|
||||
retention_policy: "RetentionType",
|
||||
trace,
|
||||
basetemp: Optional[Path] = None,
|
||||
*,
|
||||
@@ -49,6 +75,8 @@ class TempPathFactory:
|
||||
# Path.absolute() exists, but it is not public (see https://bugs.python.org/issue25012).
|
||||
self._given_basetemp = Path(os.path.abspath(str(given_basetemp)))
|
||||
self._trace = trace
|
||||
self._retention_count = retention_count
|
||||
self._retention_policy = retention_policy
|
||||
self._basetemp = basetemp
|
||||
|
||||
@classmethod
|
||||
@@ -63,9 +91,23 @@ class TempPathFactory:
|
||||
:meta private:
|
||||
"""
|
||||
check_ispytest(_ispytest)
|
||||
count = int(config.getini("tmp_path_retention_count"))
|
||||
if count < 0:
|
||||
raise ValueError(
|
||||
f"tmp_path_retention_count must be >= 0. Current input: {count}."
|
||||
)
|
||||
|
||||
policy = config.getini("tmp_path_retention_policy")
|
||||
if policy not in ("all", "failed", "none"):
|
||||
raise ValueError(
|
||||
f"tmp_path_retention_policy must be either all, failed, none. Current input: {policy}."
|
||||
)
|
||||
|
||||
return cls(
|
||||
given_basetemp=config.option.basetemp,
|
||||
trace=config.trace.get("tmpdir"),
|
||||
retention_count=count,
|
||||
retention_policy=policy,
|
||||
_ispytest=True,
|
||||
)
|
||||
|
||||
@@ -133,23 +175,23 @@ class TempPathFactory:
|
||||
# Also, to keep things private, fixup any world-readable temp
|
||||
# rootdir's permissions. Historically 0o755 was used, so we can't
|
||||
# just error out on this, at least for a while.
|
||||
if sys.platform != "win32":
|
||||
uid = os.getuid()
|
||||
uid = get_user_id()
|
||||
if uid is not None:
|
||||
rootdir_stat = rootdir.stat()
|
||||
# getuid shouldn't fail, but cpython defines such a case.
|
||||
# Let's hope for the best.
|
||||
if uid != -1:
|
||||
if rootdir_stat.st_uid != uid:
|
||||
raise OSError(
|
||||
f"The temporary directory {rootdir} is not owned by the current user. "
|
||||
"Fix this and try again."
|
||||
)
|
||||
if (rootdir_stat.st_mode & 0o077) != 0:
|
||||
os.chmod(rootdir, rootdir_stat.st_mode & ~0o077)
|
||||
if rootdir_stat.st_uid != uid:
|
||||
raise OSError(
|
||||
f"The temporary directory {rootdir} is not owned by the current user. "
|
||||
"Fix this and try again."
|
||||
)
|
||||
if (rootdir_stat.st_mode & 0o077) != 0:
|
||||
os.chmod(rootdir, rootdir_stat.st_mode & ~0o077)
|
||||
keep = self._retention_count
|
||||
if self._retention_policy == "none":
|
||||
keep = 0
|
||||
basetemp = make_numbered_dir_with_cleanup(
|
||||
prefix="pytest-",
|
||||
root=rootdir,
|
||||
keep=3,
|
||||
keep=keep,
|
||||
lock_timeout=LOCK_TIMEOUT,
|
||||
mode=0o700,
|
||||
)
|
||||
@@ -184,6 +226,21 @@ def pytest_configure(config: Config) -> None:
|
||||
mp.setattr(config, "_tmp_path_factory", _tmp_path_factory, raising=False)
|
||||
|
||||
|
||||
def pytest_addoption(parser: Parser) -> None:
|
||||
parser.addini(
|
||||
"tmp_path_retention_count",
|
||||
help="How many sessions should we keep the `tmp_path` directories, according to `tmp_path_retention_policy`.",
|
||||
default=3,
|
||||
)
|
||||
|
||||
parser.addini(
|
||||
"tmp_path_retention_policy",
|
||||
help="Controls which directories created by the `tmp_path` fixture are kept around, based on test outcome. "
|
||||
"(all/failed/none)",
|
||||
default="all",
|
||||
)
|
||||
|
||||
|
||||
@fixture(scope="session")
|
||||
def tmp_path_factory(request: FixtureRequest) -> TempPathFactory:
|
||||
"""Return a :class:`pytest.TempPathFactory` instance for the test session."""
|
||||
@@ -200,17 +257,68 @@ def _mk_tmp(request: FixtureRequest, factory: TempPathFactory) -> Path:
|
||||
|
||||
|
||||
@fixture
|
||||
def tmp_path(request: FixtureRequest, tmp_path_factory: TempPathFactory) -> Path:
|
||||
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. If
|
||||
``--basetemp`` is used then it is cleared each session. See :ref:`base
|
||||
and old bases are removed after 3 sessions, to aid in debugging.
|
||||
This behavior can be configured with :confval:`tmp_path_retention_count` and
|
||||
:confval:`tmp_path_retention_policy`.
|
||||
If ``--basetemp`` is used then it is cleared each session. See :ref:`base
|
||||
temporary directory`.
|
||||
|
||||
The returned object is a :class:`pathlib.Path` object.
|
||||
"""
|
||||
|
||||
return _mk_tmp(request, tmp_path_factory)
|
||||
path = _mk_tmp(request, tmp_path_factory)
|
||||
yield path
|
||||
|
||||
# Remove the tmpdir if the policy is "failed" and the test passed.
|
||||
tmp_path_factory: TempPathFactory = request.session.config._tmp_path_factory # type: ignore
|
||||
policy = tmp_path_factory._retention_policy
|
||||
result_dict = request.node.stash[tmppath_result_key]
|
||||
|
||||
if policy == "failed" and result_dict.get("call", True):
|
||||
# We do a "best effort" to remove files, but it might not be possible due to some leaked resource,
|
||||
# permissions, etc, in which case we ignore it.
|
||||
rmtree(path, ignore_errors=True)
|
||||
|
||||
del request.node.stash[tmppath_result_key]
|
||||
|
||||
|
||||
def pytest_sessionfinish(session, exitstatus: Union[int, ExitCode]):
|
||||
"""After each session, remove base directory if all the tests passed,
|
||||
the policy is "failed", and the basetemp is not specified by a user.
|
||||
"""
|
||||
tmp_path_factory: TempPathFactory = session.config._tmp_path_factory
|
||||
basetemp = tmp_path_factory._basetemp
|
||||
if basetemp is None:
|
||||
return
|
||||
|
||||
policy = tmp_path_factory._retention_policy
|
||||
if (
|
||||
exitstatus == 0
|
||||
and policy == "failed"
|
||||
and tmp_path_factory._given_basetemp is None
|
||||
):
|
||||
if basetemp.is_dir():
|
||||
# We do a "best effort" to remove files, but it might not be possible due to some leaked resource,
|
||||
# permissions, etc, in which case we ignore it.
|
||||
rmtree(basetemp, ignore_errors=True)
|
||||
|
||||
# Remove dead symlinks.
|
||||
if basetemp.is_dir():
|
||||
cleanup_dead_symlinks(basetemp)
|
||||
|
||||
|
||||
@hookimpl(tryfirst=True, hookwrapper=True)
|
||||
def pytest_runtest_makereport(item: Item, call):
|
||||
outcome = yield
|
||||
result: CollectReport = outcome.get_result()
|
||||
|
||||
empty: Dict[str, bool] = {}
|
||||
item.stash.setdefault(tmppath_result_key, empty)[result.when] = result.passed
|
||||
|
||||
+11
-7
@@ -298,6 +298,9 @@ class TestCaseFunction(Function):
|
||||
def stopTest(self, testcase: "unittest.TestCase") -> None:
|
||||
pass
|
||||
|
||||
def addDuration(self, testcase: "unittest.TestCase", elapsed: float) -> None:
|
||||
pass
|
||||
|
||||
def runtest(self) -> None:
|
||||
from _pytest.debugging import maybe_wrap_pytest_function_for_tracing
|
||||
|
||||
@@ -331,15 +334,16 @@ class TestCaseFunction(Function):
|
||||
finally:
|
||||
delattr(self._testcase, self.name)
|
||||
|
||||
def _prunetraceback(
|
||||
def _traceback_filter(
|
||||
self, excinfo: _pytest._code.ExceptionInfo[BaseException]
|
||||
) -> None:
|
||||
super()._prunetraceback(excinfo)
|
||||
traceback = excinfo.traceback.filter(
|
||||
lambda x: not x.frame.f_globals.get("__unittest")
|
||||
) -> _pytest._code.Traceback:
|
||||
traceback = super()._traceback_filter(excinfo)
|
||||
ntraceback = traceback.filter(
|
||||
lambda x: not x.frame.f_globals.get("__unittest"),
|
||||
)
|
||||
if traceback:
|
||||
excinfo.traceback = traceback
|
||||
if not ntraceback:
|
||||
ntraceback = traceback
|
||||
return ntraceback
|
||||
|
||||
|
||||
@hookimpl(tryfirst=True)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import dataclasses
|
||||
import inspect
|
||||
import warnings
|
||||
from types import FunctionType
|
||||
@@ -6,8 +7,6 @@ from typing import Generic
|
||||
from typing import Type
|
||||
from typing import TypeVar
|
||||
|
||||
import attr
|
||||
|
||||
from _pytest.compat import final
|
||||
|
||||
|
||||
@@ -130,7 +129,7 @@ _W = TypeVar("_W", bound=PytestWarning)
|
||||
|
||||
|
||||
@final
|
||||
@attr.s(auto_attribs=True)
|
||||
@dataclasses.dataclass
|
||||
class UnformattedWarning(Generic[_W]):
|
||||
"""A warning meant to be formatted during runtime.
|
||||
|
||||
@@ -150,7 +149,7 @@ def warn_explicit_for(method: FunctionType, message: PytestWarning) -> None:
|
||||
"""
|
||||
Issue the warning :param:`message` for the definition of the given :param:`method`
|
||||
|
||||
this helps to log warnigns for functions defined prior to finding an issue with them
|
||||
this helps to log warnings for functions defined prior to finding an issue with them
|
||||
(like hook wrappers being marked in a legacy mechanism)
|
||||
"""
|
||||
lineno = method.__code__.co_firstlineno
|
||||
|
||||
@@ -62,6 +62,7 @@ from _pytest.reports import TestReport
|
||||
from _pytest.runner import CallInfo
|
||||
from _pytest.stash import Stash
|
||||
from _pytest.stash import StashKey
|
||||
from _pytest.terminal import TestShortLogReport
|
||||
from _pytest.tmpdir import TempPathFactory
|
||||
from _pytest.warning_types import PytestAssertRewriteWarning
|
||||
from _pytest.warning_types import PytestCacheWarning
|
||||
@@ -152,6 +153,7 @@ __all__ = [
|
||||
"TempPathFactory",
|
||||
"Testdir",
|
||||
"TestReport",
|
||||
"TestShortLogReport",
|
||||
"UsageError",
|
||||
"WarningsRecorder",
|
||||
"warns",
|
||||
|
||||
@@ -803,7 +803,7 @@ class TestLocalPath(CommonFSTests):
|
||||
# depending on how the paths are used), but > 4096 (which is the
|
||||
# Linux' limitation) - the behaviour of paths with names > 4096 chars
|
||||
# is undetermined
|
||||
newfilename = "/test" * 60
|
||||
newfilename = "/test" * 60 # type:ignore[unreachable]
|
||||
l1 = tmpdir.join(newfilename)
|
||||
l1.ensure(file=True)
|
||||
l1.write("foo")
|
||||
@@ -1344,8 +1344,8 @@ class TestPOSIXLocalPath:
|
||||
assert realpath.basename == "file"
|
||||
|
||||
def test_owner(self, path1, tmpdir):
|
||||
from pwd import getpwuid
|
||||
from grp import getgrgid
|
||||
from pwd import getpwuid # type:ignore[attr-defined]
|
||||
from grp import getgrgid # type:ignore[attr-defined]
|
||||
|
||||
stat = path1.stat()
|
||||
assert stat.path == path1
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user