diff --git a/.coveragerc b/.coveragerc index b06629a8a8f..09ab3764337 100644 --- a/.coveragerc +++ b/.coveragerc @@ -27,3 +27,4 @@ exclude_lines = ^\s*assert False(,|$) ^\s*if TYPE_CHECKING: + ^\s*@overload( |$) diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE/1_bug_report.md similarity index 52% rename from .github/ISSUE_TEMPLATE.md rename to .github/ISSUE_TEMPLATE/1_bug_report.md index fb81416dd5e..0fc3e06cd2c 100644 --- a/.github/ISSUE_TEMPLATE.md +++ b/.github/ISSUE_TEMPLATE/1_bug_report.md @@ -1,10 +1,16 @@ +--- +name: 🐛 Bug Report +about: Report errors and problems + +--- + -- [ ] a detailed description of the bug or suggestion +- [ ] a detailed description of the bug or problem you are having - [ ] output of `pip list` from the virtual environment you are using - [ ] pytest and operating system versions - [ ] minimal example if possible diff --git a/.github/ISSUE_TEMPLATE/2_feature_request.md b/.github/ISSUE_TEMPLATE/2_feature_request.md new file mode 100644 index 00000000000..01fe96295ea --- /dev/null +++ b/.github/ISSUE_TEMPLATE/2_feature_request.md @@ -0,0 +1,25 @@ +--- +name: 🚀 Feature Request +about: Ideas for new features and improvements + +--- + + + +#### What's the problem this feature will solve? + + +#### Describe the solution you'd like + + + + +#### Alternative Solutions + + +#### Additional context + diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000000..742d2e4d668 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: ❓ Support Question + url: https://github.com/pytest-dev/pytest/discussions + about: Use GitHub's new Discussions feature for questions diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index d189f7869ce..9408fceafe3 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -7,6 +7,10 @@ Here is a quick checklist that should be present in PRs. - [ ] Include new tests or update existing tests when applicable. - [X] Allow maintainers to push and squash when merging my commits. Please uncheck this if you prefer to squash the commits yourself. +If this change fixes an issue, please: + +- [ ] Add text like ``closes #XYZW`` to the PR description and/or commits (where ``XYZW`` is the issue number). See the [github docs](https://help.github.com/en/github/managing-your-work-on-github/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword) for more information. + Unless your change is trivial or a small documentation fix (e.g., a typo or reword of a small section) please: - [ ] Create a new changelog file in the `changelog` folder, with a name like `..rst`. See [changelog/README.rst](https://github.com/pytest-dev/pytest/blob/master/changelog/README.rst) for details. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000000..507789bf5a4 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +version: 2 +updates: +- package-ecosystem: pip + directory: "/testing/plugins_integration" + schedule: + interval: weekly + time: "03:00" + open-pull-requests-limit: 10 + allow: + - dependency-type: direct + - dependency-type: indirect diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index a8a9c527b38..2b779279fdc 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,9 +1,3 @@ -# evaluating GitHub actions for CI, disregard failures when evaluating PRs -# -# this is still missing: -# - deploy -# - upload github notes -# name: main on: @@ -12,7 +6,8 @@ on: - master - "[0-9]+.[0-9]+.x" tags: - - "*" + - "[0-9]+.[0-9]+.[0-9]+" + - "[0-9]+.[0-9]+.[0-9]+rc[0-9]+" pull_request: branches: @@ -22,39 +17,34 @@ on: jobs: build: runs-on: ${{ matrix.os }} + timeout-minutes: 30 strategy: fail-fast: false matrix: name: [ - "windows-py35", "windows-py36", "windows-py37", "windows-py37-pluggy", "windows-py38", - "ubuntu-py35", "ubuntu-py36", "ubuntu-py37", "ubuntu-py37-pluggy", "ubuntu-py37-freeze", "ubuntu-py38", + "ubuntu-py39", "ubuntu-pypy3", "macos-py37", "macos-py38", - "linting", "docs", "doctesting", + "plugins", ] include: - - name: "windows-py35" - python: "3.5" - os: windows-latest - tox_env: "py35-xdist" - use_coverage: true - name: "windows-py36" python: "3.6" os: windows-latest @@ -62,7 +52,7 @@ jobs: - name: "windows-py37" python: "3.7" os: windows-latest - tox_env: "py37-twisted-numpy" + tox_env: "py37-numpy" - name: "windows-py37-pluggy" python: "3.7" os: windows-latest @@ -70,13 +60,9 @@ jobs: - name: "windows-py38" python: "3.8" os: windows-latest - tox_env: "py38" + tox_env: "py38-unittestextras" use_coverage: true - - name: "ubuntu-py35" - python: "3.5" - os: ubuntu-latest - tox_env: "py35-xdist" - name: "ubuntu-py36" python: "3.6" os: ubuntu-latest @@ -84,7 +70,7 @@ jobs: - name: "ubuntu-py37" python: "3.7" os: ubuntu-latest - tox_env: "py37-lsof-numpy-oldattrs-pexpect-twisted" + tox_env: "py37-lsof-numpy-pexpect" use_coverage: true - name: "ubuntu-py37-pluggy" python: "3.7" @@ -98,6 +84,10 @@ jobs: python: "3.8" os: ubuntu-latest tox_env: "py38-xdist" + - name: "ubuntu-py39" + python: "3.9" + os: ubuntu-latest + tox_env: "py39-xdist" - name: "ubuntu-pypy3" python: "pypy3" os: ubuntu-latest @@ -113,10 +103,11 @@ jobs: tox_env: "py38-xdist" use_coverage: true - - name: "linting" + - name: "plugins" python: "3.7" os: ubuntu-latest - tox_env: "linting" + tox_env: "plugins" + - name: "docs" python: "3.7" os: ubuntu-latest @@ -128,9 +119,11 @@ jobs: use_coverage: true steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v2 + with: + fetch-depth: 0 - name: Set up Python ${{ matrix.python }} - uses: actions/setup-python@v1 + uses: actions/setup-python@v2 with: python-version: ${{ matrix.python }} - name: Install dependencies @@ -161,17 +154,37 @@ jobs: CODECOV_NAME: ${{ matrix.name }} run: bash scripts/report-coverage.sh -F GHA,${{ runner.os }} + linting: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-python@v2 + - name: set PY + run: echo "name=PY::$(python -c 'import hashlib, sys;print(hashlib.sha256(sys.version.encode()+sys.executable.encode()).hexdigest())')" >> $GITHUB_ENV + - uses: actions/cache@v2 + with: + path: ~/.cache/pre-commit + key: pre-commit|${{ env.PY }}|${{ hashFiles('.pre-commit-config.yaml') }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install tox + - run: tox -e linting + deploy: if: github.event_name == 'push' && startsWith(github.event.ref, 'refs/tags') && github.repository == 'pytest-dev/pytest' runs-on: ubuntu-latest + timeout-minutes: 30 needs: [build] steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v2 + with: + fetch-depth: 0 - name: Set up Python - uses: actions/setup-python@v1 + uses: actions/setup-python@v2 with: python-version: "3.7" - name: Install dependencies diff --git a/.github/workflows/release-on-comment.yml b/.github/workflows/release-on-comment.yml index fe62eb1cb4b..94863d896b9 100644 --- a/.github/workflows/release-on-comment.yml +++ b/.github/workflows/release-on-comment.yml @@ -14,9 +14,12 @@ jobs: if: (github.event.comment && startsWith(github.event.comment.body, '@pytestbot please')) || (github.event.issue && !github.event.comment && startsWith(github.event.issue.body, '@pytestbot please')) steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v2 + with: + fetch-depth: 0 + - name: Set up Python - uses: actions/setup-python@v1 + uses: actions/setup-python@v2 with: python-version: "3.8" - name: Install dependencies diff --git a/.gitignore b/.gitignore index 83b6dbe7351..23e7c996688 100644 --- a/.gitignore +++ b/.gitignore @@ -29,10 +29,12 @@ doc/*/_changelog_towncrier_draft.rst build/ dist/ *.egg-info +htmlcov/ issue/ env/ .env/ .venv/ +/pythonenv*/ 3rdparty/ .tox .cache diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1fd3e382754..68cc3273bba 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -5,12 +5,12 @@ repos: - id: black args: [--safe, --quiet] - repo: https://github.com/asottile/blacken-docs - rev: v1.0.0 + rev: v1.8.0 hooks: - id: blacken-docs additional_dependencies: [black==19.10b0] - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v2.2.3 + rev: v3.2.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer @@ -18,30 +18,47 @@ repos: args: [--remove] - id: check-yaml - id: debug-statements - exclude: _pytest/debugging.py + exclude: _pytest/(debugging|hookspec).py language_version: python3 - repo: https://gitlab.com/pycqa/flake8 - rev: 3.7.7 + rev: 3.8.3 hooks: - id: flake8 language_version: python3 - additional_dependencies: [flake8-typing-imports==1.3.0] + additional_dependencies: + - flake8-typing-imports==1.9.0 + - flake8-docstrings==1.5.0 - repo: https://github.com/asottile/reorder_python_imports - rev: v1.4.0 + rev: v2.3.5 hooks: - id: reorder-python-imports - args: ['--application-directories=.:src', --py3-plus] + args: ['--application-directories=.:src', --py36-plus] - repo: https://github.com/asottile/pyupgrade - rev: v1.18.0 + rev: v2.7.2 hooks: - id: pyupgrade - args: [--py3-plus] + args: [--py36-plus] +- repo: https://github.com/asottile/setup-cfg-fmt + rev: v1.11.0 + hooks: + - id: setup-cfg-fmt + # TODO: when upgrading setup-cfg-fmt this can be removed + args: [--max-py-version=3.9] +- repo: https://github.com/pre-commit/pygrep-hooks + rev: v1.6.0 + hooks: + - id: python-use-type-annotations - repo: https://github.com/pre-commit/mirrors-mypy - rev: v0.761 # NOTE: keep this in sync with setup.py. + rev: v0.790 hooks: - id: mypy files: ^(src/|testing/) args: [] + additional_dependencies: + - iniconfig>=1.1.0 + - py>=1.8.2 + - attrs>=19.2.0 + - packaging - repo: local hooks: - id: rst @@ -64,9 +81,11 @@ repos: _code\.| builtin\.| code\.| - io\.(BytesIO|saferepr|TerminalWriter)| + io\.| path\.local\.sysfind| process\.| - std\. + std\.| + error\.| + xml\. ) types: [python] diff --git a/.readthedocs.yml b/.readthedocs.yml new file mode 100644 index 00000000000..0176c264001 --- /dev/null +++ b/.readthedocs.yml @@ -0,0 +1,12 @@ +version: 2 + +python: + version: 3.7 + install: + - requirements: doc/en/requirements.txt + - method: pip + path: . + +formats: + - epub + - pdf diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 5c85dfe1f59..00000000000 --- a/.travis.yml +++ /dev/null @@ -1,60 +0,0 @@ -language: python -dist: trusty -python: '3.5.1' -cache: false - -env: - global: - - PYTEST_ADDOPTS=-vv - -# setuptools-scm needs all tags in order to obtain a proper version -git: - depth: false - -install: - - python -m pip install --upgrade --pre tox - -jobs: - include: - # Coverage for Python 3.5.{0,1} specific code, mostly typing related. - - env: TOXENV=py35 PYTEST_COVERAGE=1 PYTEST_ADDOPTS="-k test_raises_cyclic_reference" - before_install: - # Work around https://github.com/jaraco/zipp/issues/40. - - python -m pip install -U 'setuptools>=34.4.0' virtualenv==16.7.9 - -before_script: - - | - # Do not (re-)upload coverage with cron runs. - if [[ "$TRAVIS_EVENT_TYPE" = cron ]]; then - PYTEST_COVERAGE=0 - fi - - | - if [[ "$PYTEST_COVERAGE" = 1 ]]; then - export COVERAGE_FILE="$PWD/.coverage" - export COVERAGE_PROCESS_START="$PWD/.coveragerc" - export _PYTEST_TOX_COVERAGE_RUN="coverage run -m" - export _PYTEST_TOX_EXTRA_DEP=coverage-enable-subprocess - fi - -script: tox - -after_success: - - | - if [[ "$PYTEST_COVERAGE" = 1 ]]; then - env CODECOV_NAME="$TOXENV-$TRAVIS_OS_NAME" scripts/report-coverage.sh -F Travis - fi - -notifications: - irc: - channels: - - "chat.freenode.net#pytest" - on_success: change - on_failure: change - skip_join: true - email: - - pytest-commit@python.org - -branches: - only: - - master - - /^\d+\.\d+\.x$/ diff --git a/AUTHORS b/AUTHORS index af0dc62c4d8..72391122eb5 100644 --- a/AUTHORS +++ b/AUTHORS @@ -32,6 +32,7 @@ Anthony Sottile Anton Lodder Antony Lee Arel Cordero +Ariel Pillemer Armin Rigo Aron Coyle Aron Curzon @@ -60,9 +61,11 @@ Christian Fetzer Christian Neumüller Christian Theunert Christian Tismer +Christine Mecklenborg Christoph Buelter Christopher Dignam Christopher Gilling +Claire Cecil Claudio Madotto CrazyMerlyn Cyrus Maden @@ -80,11 +83,13 @@ David Paul Röthlisberger David Szotten David Vierra Daw-Ran Liou +Debi Mishra Denis Kirisov Dhiren Serai Diego Russo Dmitry Dygalo Dmitry Pribysh +Dominic Mortlock Duncan Betts Edison Gustavo Muenz Edoardo Batini @@ -94,17 +99,22 @@ Elizaveta Shashkova Endre Galaczi Eric Hunsberger Eric Siegerman +Erik Aronesty Erik M. Bray Evan Kepner Fabien Zarifian Fabio Zadrozny +Felix Nieuwenhuizen Feng Ma Florian Bruhin +Florian Dahlitz Floris Bruynooghe Gabriel Reis +Garvit Shubham Gene Wood George Kussumoto Georgy Dyuldin +Gleb Nikonorov Graham Horler Greg Price Gregory Lee @@ -123,6 +133,7 @@ Ilya Konstantinov Ionuț Turturică Iwan Briquemont Jaap Broekhuizen +Jakob van Santen Jakub Mitoraj Jan Balster Janne Vanhala @@ -145,9 +156,13 @@ Joshua Bronson Jurko Gospodnetić Justyna Janczyszyn Kale Kundert +Kamran Ahmad Karl O. Pinc +Karthikeyan Singaravelan Katarzyna Jachim +Katarzyna Król Katerina Koukiou +Keri Volans Kevin Cox Kevin J. Foley Kodi B. Arfer @@ -157,6 +172,7 @@ Kyle Altendorf Lawrence Mitchell Lee Kamentsky Lev Maximov +Lewis Cowles Llandy Riveron Del Risco Loic Esteve Lukas Bednar @@ -183,7 +199,9 @@ Matt Duck Matt Williams Matthias Hafner Maxim Filipenko +Maximilian Cosmo Sitter mbyt +Mickey Pashov Michael Aquilina Michael Birtwell Michael Droettboom @@ -213,15 +231,22 @@ Ondřej Súkup Oscar Benjamin Patrick Hayes Pauli Virtanen +Pavel Karateev Paweł Adamczak Pedro Algarvio +Petter Strandmark Philipp Loose Pieter Mulder Piotr Banaszkiewicz +Piotr Helm +Prakhar Gurunani +Prashant Anand +Prashant Sharma Pulkit Goyal Punyashloka Biswal Quentin Pradet Ralf Schmitt +Ram Rachum Ralph Giles Ran Benita Raphael Castaneda @@ -235,16 +260,20 @@ Romain Dorgueil Roman Bolshakov Ronny Pfannschmidt Ross Lawley +Ruaridh Williamson Russel Winder Ryan Wooden Samuel Dion-Girardeau Samuel Searles-Bryant Samuele Pedroni +Sanket Duthade Sankt Petersbug Segev Finer Serhii Mozghovyi Seth Junot +Shubham Adep Simon Gomizelj +Simon Kerr Skylar Downes Srinivas Reddy Thatiparthy Stefan Farmbauer @@ -254,8 +283,10 @@ Stefano Taschini Steffen Allner Stephan Obermann Sven-Hendrik Haase +Sylvain Marié Tadek Teleżyński Takafumi Arakaki +Tanvi Mehta Tarcisio Fischer Tareq Alayan Ted Xiao @@ -267,6 +298,7 @@ Tom Dalton Tom Viner Tomáš Gavenčiak Tomer Keren +Tor Colvin Trevor Bekolay Tyler Goodlet Tzu-ping Chung @@ -277,6 +309,7 @@ Vidar T. Fauske Virgil Dupras Vitaly Lashmanov Vlad Dragos +Vlad Radziuk Vladyslav Rachek Volodymyr Piskun Wei Lin @@ -290,3 +323,4 @@ Xuecong Liao Yoav Caspi Zac Hatfield-Dodds Zoltán Máté +Zsolt Cserna diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 49649f7894f..3865f250c26 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -2,6 +2,6 @@ Changelog ========= -The pytest CHANGELOG is located `here `__. +The pytest CHANGELOG is located `here `__. The source document can be found at: https://github.com/pytest-dev/pytest/blob/master/doc/en/changelog.rst diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 050c7082c57..2669cb19509 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -2,7 +2,7 @@ Contribution getting started ============================ -Contributions are highly welcomed and appreciated. Every little help counts, +Contributions are highly welcomed and appreciated. Every little bit of help counts, so do not hesitate! .. contents:: @@ -86,9 +86,40 @@ without using a local copy. This can be convenient for small fixes. $ tox -e docs - The built documentation should be available in ``doc/en/_build/html``. + The built documentation should be available in ``doc/en/_build/html``, + where 'en' refers to the documentation language. + +Pytest has an API reference which in large part is +`generated automatically `_ +from the docstrings of the documented items. Pytest uses the +`Sphinx docstring format `_. +For example: + +.. code-block:: python + + def my_function(arg: ArgType) -> Foo: + """Do important stuff. + + More detailed info here, in separate paragraphs from the subject line. + Use proper sentences -- start sentences with capital letters and end + with periods. + + Can include annotated documentation: + + :param short_arg: An argument which determines stuff. + :param long_arg: + A long explanation which spans multiple lines, overflows + like this. + :returns: The result. + :raises ValueError: + Detailed information when this can happen. + + .. versionadded:: 6.0 + + Including types into the annotations above is not necessary when + type-hinting is being used (as in this example). + """ - Where 'en' refers to the documentation language. .. _submitplugin: @@ -100,8 +131,6 @@ in repositories living under the ``pytest-dev`` organisations: - `pytest-dev on GitHub `_ -- `pytest-dev on Bitbucket `_ - All pytest-dev Contributors team members have write access to all contained repositories. Pytest core and plugins are generally developed using `pull requests`_ to respective repositories. @@ -117,20 +146,21 @@ You can submit your plugin by subscribing to the `pytest-dev mail list mail pointing to your existing pytest plugin repository which must have the following: -- PyPI presence with a ``setup.py`` that contains a license, ``pytest-`` +- PyPI presence with packaging metadata that contains a ``pytest-`` prefixed name, version number, authors, short and long description. -- a ``tox.ini`` for running tests using `tox `_. +- a `tox configuration `_ + for running tests using `tox `_. -- a ``README.txt`` describing how to use the plugin and on which +- a ``README`` describing how to use the plugin and on which platforms it runs. -- a ``LICENSE.txt`` file or equivalent containing the licensing - information, with matching info in ``setup.py``. +- a ``LICENSE`` file containing the licensing information, with + matching info in its packaging metadata. - an issue tracker for bug reports and enhancement requests. -- a `changelog `_ +- a `changelog `_. If no contributor strongly objects and two agree, the repository can then be transferred to the ``pytest-dev`` organisation. @@ -174,8 +204,10 @@ Short version The test environments above are usually enough to cover most cases locally. #. Write a ``changelog`` entry: ``changelog/2574.bugfix.rst``, use issue id number - and one of ``bugfix``, ``removal``, ``feature``, ``vendor``, ``doc`` or - ``trivial`` for the issue type. + and one of ``feature``, ``improvement``, ``bugfix``, ``doc``, ``deprecation``, + ``breaking``, ``vendor`` or ``trivial`` for the issue type. + + #. Unless your change is a trivial or a documentation fix (e.g., a typo or reword of a small section) please add yourself to the ``AUTHORS`` file, in alphabetical order. @@ -213,9 +245,7 @@ Here is a simple overview, with pytest-specific bits: If you need some help with Git, follow this quick start guide: https://git.wiki.kernel.org/index.php/QuickStart -#. Install `pre-commit `_ and its hook on the pytest repo: - - **Note: pre-commit must be installed as admin, as it will not function otherwise**:: +#. Install `pre-commit `_ and its hook on the pytest repo:: $ pip install --user pre-commit $ pre-commit install @@ -269,19 +299,19 @@ Here is a simple overview, with pytest-specific bits: $ pytest testing/test_config.py +#. Create a new changelog entry in ``changelog``. The file should be named ``..rst``, + where *issueid* is the number of the issue related to the change and *type* is one of + ``feature``, ``improvement``, ``bugfix``, ``doc``, ``deprecation``, ``breaking``, ``vendor`` + or ``trivial``. You may skip creating the changelog entry if the change doesn't affect the + documented behaviour of pytest. + +#. Add yourself to ``AUTHORS`` file if not there yet, in alphabetical order. #. Commit and push once your tests pass and you are happy with your change(s):: $ git commit -a -m "" $ git push -u -#. Create a new changelog entry in ``changelog``. The file should be named ``..rst``, - where *issueid* is the number of the issue related to the change and *type* is one of - ``bugfix``, ``removal``, ``feature``, ``vendor``, ``doc`` or ``trivial``. You may not create a - changelog entry if the change doesn't affect the documented behaviour of Pytest. - -#. Add yourself to ``AUTHORS`` file if not there yet, in alphabetical order. - #. Finally, submit a pull request through the GitHub website using this data:: head-fork: YOUR_GITHUB_USERNAME/pytest @@ -292,9 +322,9 @@ Here is a simple overview, with pytest-specific bits: Writing Tests ----------------------------- +~~~~~~~~~~~~~ -Writing tests for plugins or for pytest itself is often done using the `testdir fixture `_, as a "black-box" test. +Writing tests for plugins or for pytest itself is often done using the `testdir fixture `_, as a "black-box" test. For example, to ensure a simple test passes you can write: @@ -331,16 +361,121 @@ one file which looks like a good fit. For example, a regression test about a bug should go into ``test_cacheprovider.py``, given that this option is implemented in ``cacheprovider.py``. If in doubt, go ahead and open a PR with your best guess and we can discuss this over the code. - Joining the Development Team ---------------------------- Anyone who has successfully seen through a pull request which did not require any extra work from the development team to merge will themselves gain commit access if they so wish (if we forget to ask please send a friendly -reminder). This does not mean your workflow to contribute changes, +reminder). This does not mean there is any change in your contribution workflow: everyone goes through the same pull-request-and-review process and no-one merges their own pull requests unless already approved. It does however mean you can participate in the development process more fully since you can merge pull requests from other contributors yourself after having reviewed them. + + +Backporting bug fixes for the next patch release +------------------------------------------------ + +Pytest makes feature release every few weeks or months. In between, patch releases +are made to the previous feature release, containing bug fixes only. The bug fixes +usually fix regressions, but may be any change that should reach users before the +next feature release. + +Suppose for example that the latest release was 1.2.3, and you want to include +a bug fix in 1.2.4 (check https://github.com/pytest-dev/pytest/releases for the +actual latest release). The procedure for this is: + +#. First, make sure the bug is fixed the ``master`` branch, with a regular pull + request, as described above. An exception to this is if the bug fix is not + applicable to ``master`` anymore. + +#. ``git checkout origin/1.2.x -b backport-XXXX`` # use the master PR number here + +#. Locate the merge commit on the PR, in the *merged* message, for example: + + nicoddemus merged commit 0f8b462 into pytest-dev:master + +#. ``git cherry-pick -x -m1 REVISION`` # use the revision you found above (``0f8b462``). + +#. Open a PR targeting ``1.2.x``: + + * Prefix the message with ``[1.2.x]``. + * Delete the PR body, it usually contains a duplicate commit message. + + +Who does the backporting +~~~~~~~~~~~~~~~~~~~~~~~~ + +As mentioned above, bugs should first be fixed on ``master`` (except in rare occasions +that a bug only happens in a previous release). So who should do the backport procedure described +above? + +1. If the bug was fixed by a core developer, it is the main responsibility of that core developer + to do the backport. +2. However, often the merge is done by another maintainer, in which case it is nice of them to + do the backport procedure if they have the time. +3. For bugs submitted by non-maintainers, it is expected that a core developer will to do + the backport, normally the one that merged the PR on ``master``. +4. If a non-maintainers notices a bug which is fixed on ``master`` but has not been backported + (due to maintainers forgetting to apply the *needs backport* label, or just plain missing it), + they are also welcome to open a PR with the backport. The procedure is simple and really + helps with the maintenance of the project. + +All the above are not rules, but merely some guidelines/suggestions on what we should expect +about backports. + +Handling stale issues/PRs +------------------------- + +Stale issues/PRs are those where pytest contributors have asked for questions/changes +and the authors didn't get around to answer/implement them yet after a somewhat long time, or +the discussion simply died because people seemed to lose interest. + +There are many reasons why people don't answer questions or implement requested changes: +they might get busy, lose interest, or just forget about it, +but the fact is that this is very common in open source software. + +The pytest team really appreciates every issue and pull request, but being a high-volume project +with many issues and pull requests being submitted daily, we try to reduce the number of stale +issues and PRs by regularly closing them. When an issue/pull request is closed in this manner, +it is by no means a dismissal of the topic being tackled by the issue/pull request, but it +is just a way for us to clear up the queue and make the maintainers' work more manageable. Submitters +can always reopen the issue/pull request in their own time later if it makes sense. + +When to close +~~~~~~~~~~~~~ + +Here are a few general rules the maintainers use to decide when to close issues/PRs because +of lack of inactivity: + +* Issues labeled ``question`` or ``needs information``: closed after 14 days inactive. +* Issues labeled ``proposal``: closed after six months inactive. +* Pull requests: after one month, consider pinging the author, update linked issue, or consider closing. For pull requests which are nearly finished, the team should consider finishing it up and merging it. + +The above are **not hard rules**, but merely **guidelines**, and can be (and often are!) reviewed on a case-by-case basis. + +Closing pull requests +~~~~~~~~~~~~~~~~~~~~~ + +When closing a Pull Request, it needs to be acknowledge the time, effort, and interest demonstrated by the person which submitted it. As mentioned previously, it is not the intent of the team to dismiss stalled pull request entirely but to merely to clear up our queue, so a message like the one below is warranted when closing a pull request that went stale: + + Hi , + + First of all we would like to thank you for your time and effort on working on this, the pytest team deeply appreciates it. + + We noticed it has been awhile since you have updated this PR, however. pytest is a high activity project, with many issues/PRs being opened daily, so it is hard for us maintainers to track which PRs are ready for merging, for review, or need more attention. + + So for those reasons we think it is best to close the PR for now, but with the only intention to cleanup our queue, it is by no means a rejection of your changes. We still encourage you to re-open this PR (it is just a click of a button away) when you are ready to get back to it. + + Again we appreciate your time for working on this, and hope you might get back to this at a later time! + + + +Closing Issues +-------------- + +When a pull request is submitted to fix an issue, add text like ``closes #XYZW`` to the PR description and/or commits (where ``XYZW`` is the issue number). See the `GitHub docs `_ for more information. + +When an issue is due to user error (e.g. misunderstanding of a functionality), please politely explain to the user why the issue raised is really a non-issue and ask them to close the issue if they have no further questions. If the original requestor is unresponsive, the issue will be handled as described in the section `Handling stale issues/PRs`_ above. diff --git a/README.rst b/README.rst index 864467ea210..398d6451c58 100644 --- a/README.rst +++ b/README.rst @@ -1,5 +1,5 @@ -.. image:: https://docs.pytest.org/en/latest/_static/pytest1.png - :target: https://docs.pytest.org/en/latest/ +.. image:: https://docs.pytest.org/en/stable/_static/pytest1.png + :target: https://docs.pytest.org/en/stable/ :align: center :alt: pytest @@ -22,8 +22,8 @@ .. image:: https://travis-ci.org/pytest-dev/pytest.svg?branch=master :target: https://travis-ci.org/pytest-dev/pytest -.. image:: https://dev.azure.com/pytest-dev/pytest/_apis/build/status/pytest-CI?branchName=master - :target: https://dev.azure.com/pytest-dev/pytest +.. image:: https://github.com/pytest-dev/pytest/workflows/main/badge.svg + :target: https://github.com/pytest-dev/pytest/actions?query=workflow%3Amain .. image:: https://img.shields.io/badge/code%20style-black-000000.svg :target: https://github.com/psf/black @@ -71,33 +71,33 @@ To execute it:: ========================== 1 failed in 0.04 seconds =========================== -Due to ``pytest``'s detailed assertion introspection, only plain ``assert`` statements are used. See `getting-started `_ for more examples. +Due to ``pytest``'s detailed assertion introspection, only plain ``assert`` statements are used. See `getting-started `_ for more examples. Features -------- -- Detailed info on failing `assert statements `_ (no need to remember ``self.assert*`` names); +- Detailed info on failing `assert statements `_ (no need to remember ``self.assert*`` names) - `Auto-discovery - `_ - of test modules and functions; + `_ + of test modules and functions -- `Modular fixtures `_ for - managing small or parametrized long-lived test resources; +- `Modular fixtures `_ for + managing small or parametrized long-lived test resources -- Can run `unittest `_ (or trial), - `nose `_ test suites out of the box; +- Can run `unittest `_ (or trial), + `nose `_ test suites out of the box -- Python 3.5+ and PyPy3; +- Python 3.6+ and PyPy3 -- Rich plugin architecture, with over 315+ `external plugins `_ and thriving community; +- Rich plugin architecture, with over 850+ `external plugins `_ and thriving community Documentation ------------- -For full documentation, including installation, tutorials and PDF documents, please see https://docs.pytest.org/en/latest/. +For full documentation, including installation, tutorials and PDF documents, please see https://docs.pytest.org/en/stable/. Bugs/Requests @@ -109,7 +109,7 @@ Please use the `GitHub issue tracker `__ page for fixes and enhancements of each version. +Consult the `Changelog `__ page for fixes and enhancements of each version. Support pytest diff --git a/RELEASING.rst b/RELEASING.rst index 9c254ea0e02..9ec2b069c68 100644 --- a/RELEASING.rst +++ b/RELEASING.rst @@ -5,33 +5,85 @@ Our current policy for releasing is to aim for a bug-fix release every few weeks is to get fixes and new features out instead of trying to cram a ton of features into a release and by consequence taking a lot of time to make a new one. +The git commands assume the following remotes are setup: + +* ``origin``: your own fork of the repository. +* ``upstream``: the ``pytest-dev/pytest`` official repository. + Preparing: Automatic Method ~~~~~~~~~~~~~~~~~~~~~~~~~~~ We have developed an automated workflow for releases, that uses GitHub workflows and is triggered -by opening an issue or issuing a comment one. +by opening an issue. + +Bug-fix releases +^^^^^^^^^^^^^^^^ + +A bug-fix release is always done from a maintenance branch, so for example to release bug-fix +``5.1.2``, open a new issue and add this comment to the body:: + + @pytestbot please prepare release from 5.1.x + +Where ``5.1.x`` is the maintenance branch for the ``5.1`` series. + +The automated workflow will publish a PR for a branch ``release-5.1.2`` +and notify it as a comment in the issue. + +Minor releases +^^^^^^^^^^^^^^ + +1. Create a new maintenance branch from ``master``:: + + git fetch --all + git branch 5.2.x upstream/master + git push upstream 5.2.x + +2. Open a new issue and add this comment to the body:: + + @pytestbot please prepare release from 5.2.x -The comment must be in the form:: +The automated workflow will publish a PR for a branch ``release-5.2.0`` and +notify it as a comment in the issue. - @pytestbot please prepare release from BRANCH +Major and release candidates +^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -Where ``BRANCH`` is ``master`` or one of the maintenance branches. +1. Create a new maintenance branch from ``master``:: -After that, the workflow should publish a PR and notify that it has done so as a comment -in the original issue. + git fetch --all + git branch 6.0.x upstream/master + git push upstream 6.0.x + +2. For a **major release**, open a new issue and add this comment in the body:: + + @pytestbot please prepare major release from 6.0.x + + For a **release candidate**, the comment must be (TODO: `#7551 `__):: + + @pytestbot please prepare release candidate from 6.0.x + +The automated workflow will publish a PR for a branch ``release-6.0.0`` and +notify it as a comment in the issue. + +At this point on, this follows the same workflow as other maintenance branches: bug-fixes are merged +into ``master`` and ported back to the maintenance branch, even for release candidates. + +**A note about release candidates** + +During release candidates we can merge small improvements into +the maintenance branch before releasing the final major version, however we must take care +to avoid introducing big changes at this stage. Preparing: Manual Method ~~~~~~~~~~~~~~~~~~~~~~~~ -.. important:: - - pytest releases must be prepared on **Linux** because the docs and examples expect - to be executed on that platform. +**Important**: pytest releases must be prepared on **Linux** because the docs and examples expect +to be executed on that platform. To release a version ``MAJOR.MINOR.PATCH``, follow these steps: -#. For major and minor releases, create a new branch ``MAJOR.MINOR.x`` from the - latest ``master`` and push it to the ``pytest-dev/pytest`` repo. +#. For major and minor releases, create a new branch ``MAJOR.MINOR.x`` from + ``upstream/master`` and push it to ``upstream``. #. Create a branch ``release-MAJOR.MINOR.PATCH`` from the ``MAJOR.MINOR.x`` branch. @@ -52,9 +104,10 @@ Releasing Both automatic and manual processes described above follow the same steps from this point onward. #. After all tests pass and the PR has been approved, tag the release commit - in the ``MAJOR.MINOR.x`` branch and push it. This will publish to PyPI:: + in the ``release-MAJOR.MINOR.PATCH`` branch and push it. This will publish to PyPI:: - git tag MAJOR.MINOR.PATCH + git fetch --all + git tag MAJOR.MINOR.PATCH upstream/release-MAJOR.MINOR.PATCH git push git@github.com:pytest-dev/pytest.git MAJOR.MINOR.PATCH Wait for the deploy to complete, then make sure it is `available on PyPI `_. @@ -65,9 +118,17 @@ Both automatic and manual processes described above follow the same steps from t git fetch --all --prune git checkout origin/master -b cherry-pick-release - git cherry-pick --no-commit -m1 origin/MAJOR.MINOR.x - git checkout origin/master -- changelog - git commit # no arguments + git cherry-pick -x -m1 upstream/MAJOR.MINOR.x + +#. Open a PR for ``cherry-pick-release`` and merge it once CI passes. No need to wait for approvals if there were no conflicts on the previous step. + +#. For major and minor releases, tag the release cherry-pick merge commit in master with + a dev tag for the next feature release:: + + git checkout master + git pull + git tag MAJOR.{MINOR+1}.0.dev0 + git push git@github.com:pytest-dev/pytest.git MAJOR.{MINOR+1}.0.dev0 #. Send an email announcement with the contents from:: diff --git a/TIDELIFT.rst b/TIDELIFT.rst index 062cf6b2504..b18f4793f81 100644 --- a/TIDELIFT.rst +++ b/TIDELIFT.rst @@ -24,7 +24,6 @@ members of the `contributors team`_ interested in receiving funding. The current list of contributors receiving funding are: * `@asottile`_ -* `@blueyed`_ * `@nicoddemus`_ Contributors interested in receiving a part of the funds just need to submit a PR adding their @@ -56,5 +55,4 @@ funds. Just drop a line to one of the `@pytest-dev/tidelift-admins`_ or use the .. _`agreement`: https://tidelift.com/docs/lifting/agreement .. _`@asottile`: https://github.com/asottile -.. _`@blueyed`: https://github.com/blueyed .. _`@nicoddemus`: https://github.com/nicoddemus diff --git a/bench/unit_test.py b/bench/unit_test.py new file mode 100644 index 00000000000..ad52069dbfd --- /dev/null +++ b/bench/unit_test.py @@ -0,0 +1,13 @@ +from unittest import TestCase # noqa: F401 + +for i in range(15000): + exec( + f""" +class Test{i}(TestCase): + @classmethod + def setUpClass(cls): pass + def test_1(self): pass + def test_2(self): pass + def test_3(self): pass +""" + ) diff --git a/bench/xunit.py b/bench/xunit.py new file mode 100644 index 00000000000..3a77dcdce42 --- /dev/null +++ b/bench/xunit.py @@ -0,0 +1,11 @@ +for i in range(5000): + exec( + f""" +class Test{i}: + @classmethod + def setup_class(cls): pass + def test_1(self): pass + def test_2(self): pass + def test_3(self): pass +""" + ) diff --git a/codecov.yml b/codecov.yml index db2472009c6..f1cc8697338 100644 --- a/codecov.yml +++ b/codecov.yml @@ -1 +1,6 @@ -comment: off +# reference: https://docs.codecov.io/docs/codecovyml-reference +coverage: + status: + patch: true + project: false +comment: false diff --git a/doc/en/Makefile b/doc/en/Makefile index c8d2c856447..1cffbd463d8 100644 --- a/doc/en/Makefile +++ b/doc/en/Makefile @@ -27,7 +27,6 @@ REGENDOC_ARGS := \ --normalize "/in \d.\d\ds/in 0.12s/" \ --normalize "@/tmp/pytest-of-.*/pytest-\d+@PYTEST_TMPDIR@" \ --normalize "@pytest-(\d+)\\.[^ ,]+@pytest-\1.x.y@" \ - --normalize "@(This is pytest version )(\d+)\\.[^ ,]+@\1\2.x.y@" \ --normalize "@py-(\d+)\\.[^ ,]+@py-\1.x.y@" \ --normalize "@pluggy-(\d+)\\.[.\d,]+@pluggy-\1.x.y@" \ --normalize "@hypothesis-(\d+)\\.[.\d,]+@hypothesis-\1.x.y@" \ diff --git a/doc/en/_templates/layout.html b/doc/en/_templates/layout.html new file mode 100644 index 00000000000..f7096eaaa5e --- /dev/null +++ b/doc/en/_templates/layout.html @@ -0,0 +1,52 @@ +{# + + Copied from: + + https://raw.githubusercontent.com/pallets/pallets-sphinx-themes/b0c6c41849b4e15cbf62cc1d95c05ef2b3e155c8/src/pallets_sphinx_themes/themes/pocoo/layout.html + + And removed the warning version (see #7331). + +#} + +{% extends "basic/layout.html" %} + +{% set metatags %} + {{- metatags }} + +{%- endset %} + +{% block extrahead %} + {%- if page_canonical_url %} + + {%- endif %} + + {{ super() }} +{%- endblock %} + +{% block sidebarlogo %} + {% if pagename != "index" or theme_index_sidebar_logo %} + {{ super() }} + {% endif %} +{% endblock %} + +{% block relbar2 %}{% endblock %} + +{% block sidebar2 %} + + {{- super() }} +{%- endblock %} + +{% block footer %} + {{ super() }} + {%- if READTHEDOCS and not readthedocs_docsearch %} + + {%- endif %} + {{ js_tag("_static/version_warning_offset.js") }} +{% endblock %} diff --git a/doc/en/_themes/flask/relations.html b/doc/en/_templates/relations.html similarity index 100% rename from doc/en/_themes/flask/relations.html rename to doc/en/_templates/relations.html diff --git a/doc/en/_themes/flask/slim_searchbox.html b/doc/en/_templates/slim_searchbox.html similarity index 100% rename from doc/en/_themes/flask/slim_searchbox.html rename to doc/en/_templates/slim_searchbox.html diff --git a/doc/en/_themes/.gitignore b/doc/en/_themes/.gitignore deleted file mode 100644 index 66b6e4c2f3b..00000000000 --- a/doc/en/_themes/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -*.pyc -*.pyo -.DS_Store diff --git a/doc/en/_themes/LICENSE b/doc/en/_themes/LICENSE deleted file mode 100644 index 8daab7ee6ef..00000000000 --- a/doc/en/_themes/LICENSE +++ /dev/null @@ -1,37 +0,0 @@ -Copyright (c) 2010 by Armin Ronacher. - -Some rights reserved. - -Redistribution and use in source and binary forms of the theme, with or -without modification, are permitted provided that the following conditions -are met: - -* Redistributions of source code must retain the above copyright - notice, this list of conditions and the following disclaimer. - -* Redistributions in binary form must reproduce the above - copyright notice, this list of conditions and the following - disclaimer in the documentation and/or other materials provided - with the distribution. - -* The names of the contributors may not be used to endorse or - promote products derived from this software without specific - prior written permission. - -We kindly ask you to only use these themes in an unmodified manner just -for Flask and Flask-related products, not for unrelated projects. If you -like the visual style and want to use it for your own projects, please -consider making some larger changes to the themes (such as changing -font faces, sizes, colors or margins). - -THIS THEME IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE -ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE -LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR -CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF -SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS -INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN -CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) -ARISING IN ANY WAY OUT OF THE USE OF THIS THEME, EVEN IF ADVISED OF THE -POSSIBILITY OF SUCH DAMAGE. diff --git a/doc/en/_themes/README b/doc/en/_themes/README deleted file mode 100644 index b3292bdff8e..00000000000 --- a/doc/en/_themes/README +++ /dev/null @@ -1,31 +0,0 @@ -Flask Sphinx Styles -=================== - -This repository contains sphinx styles for Flask and Flask related -projects. To use this style in your Sphinx documentation, follow -this guide: - -1. put this folder as _themes into your docs folder. Alternatively - you can also use git submodules to check out the contents there. -2. add this to your conf.py: - - sys.path.append(os.path.abspath('_themes')) - html_theme_path = ['_themes'] - html_theme = 'flask' - -The following themes exist: - -- 'flask' - the standard flask documentation theme for large - projects -- 'flask_small' - small one-page theme. Intended to be used by - very small addon libraries for flask. - -The following options exist for the flask_small theme: - - [options] - index_logo = '' filename of a picture in _static - to be used as replacement for the - h1 in the index.rst file. - index_logo_height = 120px height of the index logo - github_fork = '' repository name on github for the - "fork me" badge diff --git a/doc/en/_themes/flask/layout.html b/doc/en/_themes/flask/layout.html deleted file mode 100644 index f2fa8e6aa9a..00000000000 --- a/doc/en/_themes/flask/layout.html +++ /dev/null @@ -1,24 +0,0 @@ -{%- extends "basic/layout.html" %} -{%- block extrahead %} - {{ super() }} - {% if theme_touch_icon %} - - {% endif %} - -{% endblock %} -{%- block relbar2 %}{% endblock %} -{% block header %} - {{ super() }} - {% if pagename == 'index' %} -
- {% endif %} -{% endblock %} -{%- block footer %} - - {% if pagename == 'index' %} -
- {% endif %} -{%- endblock %} diff --git a/doc/en/_themes/flask/static/flasky.css_t b/doc/en/_themes/flask/static/flasky.css_t deleted file mode 100644 index 108c8540157..00000000000 --- a/doc/en/_themes/flask/static/flasky.css_t +++ /dev/null @@ -1,623 +0,0 @@ -/* - * flasky.css_t - * ~~~~~~~~~~~~ - * - * :copyright: Copyright 2010 by Armin Ronacher. - * :license: Flask Design License, see LICENSE for details. - */ - -{% set page_width = '1020px' %} -{% set sidebar_width = '220px' %} -/* muted version of green logo color #C9D22A */ -{% set link_color = '#606413' %} -/* blue logo color */ -{% set link_hover_color = '#009de0' %} -{% set base_font = 'sans-serif' %} -{% set header_font = 'sans-serif' %} - -@import url("basic.css"); - -/* -- page layout ----------------------------------------------------------- */ - -body { - font-family: {{ base_font }}; - font-size: 16px; - background-color: white; - color: #000; - margin: 0; - padding: 0; -} - -div.document { - width: {{ page_width }}; - margin: 30px auto 0 auto; -} - -div.documentwrapper { - float: left; - width: 100%; -} - -div.bodywrapper { - margin: 0 0 0 {{ sidebar_width }}; -} - -div.sphinxsidebar { - width: {{ sidebar_width }}; -} - -hr { - border: 0; - border-top: 1px solid #B1B4B6; -} - -div.body { - background-color: #ffffff; - color: #3E4349; - padding: 0 30px 0 30px; -} - -img.floatingflask { - padding: 0 0 10px 10px; - float: right; -} - -div.footer { - width: {{ page_width }}; - margin: 20px auto 30px auto; - font-size: 14px; - color: #888; - text-align: right; -} - -div.footer a { - color: #888; -} - -div.related { - display: none; -} - -div.sphinxsidebar a { - text-decoration: none; - border-bottom: none; -} - -div.sphinxsidebar a:hover { - color: {{ link_hover_color }}; - border-bottom: 1px solid {{ link_hover_color }}; -} - -div.sphinxsidebar { - font-size: 14px; - line-height: 1.5; -} - -div.sphinxsidebarwrapper { - padding: 18px 10px; -} - -div.sphinxsidebarwrapper p.logo { - padding: 0 0 20px 0; - margin: 0; - text-align: center; -} - -div.sphinxsidebar h3, -div.sphinxsidebar h4 { - font-family: {{ header_font }}; - color: #444; - font-size: 21px; - font-weight: normal; - margin: 16px 0 0 0; - padding: 0; -} - -div.sphinxsidebar h4 { - font-size: 18px; -} - -div.sphinxsidebar h3 a { - color: #444; -} - -div.sphinxsidebar p.logo a, -div.sphinxsidebar h3 a, -div.sphinxsidebar p.logo a:hover, -div.sphinxsidebar h3 a:hover { - border: none; -} - -div.sphinxsidebar p { - color: #555; - margin: 10px 0; -} - -div.sphinxsidebar ul { - margin: 10px 0; - padding: 0; - color: #000; -} - -div.sphinxsidebar input { - border: 1px solid #ccc; - font-family: {{ base_font }}; - font-size: 1em; -} - -/* -- body styles ----------------------------------------------------------- */ - -a { - color: {{ link_color }}; - text-decoration: underline; -} - -a:hover { - color: {{ link_hover_color }}; - text-decoration: underline; -} - -a.reference.internal em { - font-style: normal; -} - -div.body h1, -div.body h2, -div.body h3, -div.body h4, -div.body h5, -div.body h6 { - font-family: {{ header_font }}; - font-weight: normal; - margin: 30px 0px 10px 0px; - padding: 0; -} - -{% if theme_index_logo %} -div.indexwrapper h1 { - text-indent: -999999px; - background: url({{ theme_index_logo }}) no-repeat center center; - height: {{ theme_index_logo_height }}; -} -{% else %} -div.indexwrapper div.body h1 { - font-size: 200%; -} -{% endif %} -div.body h1 { margin-top: 0; padding-top: 0; font-size: 240%; } -div.body h2 { font-size: 180%; } -div.body h3 { font-size: 150%; } -div.body h4 { font-size: 130%; } -div.body h5 { font-size: 100%; } -div.body h6 { font-size: 100%; } - -a.headerlink { - color: #ddd; - padding: 0 4px; - text-decoration: none; -} - -a.headerlink:hover { - color: #444; - background: #eaeaea; -} - -div.body p, div.body dd, div.body li { - line-height: 1.4em; -} - -ul.simple li { - margin-bottom: 0.5em; -} - -div.topic ul.simple li { - margin-bottom: 0; -} - -div.topic li > p:first-child { - margin-top: 0; - margin-bottom: 0; -} - -div.admonition { - background: #fafafa; - padding: 10px 20px; - border-top: 1px solid #ccc; - border-bottom: 1px solid #ccc; -} - -div.admonition tt.xref, div.admonition a tt { - border-bottom: 1px solid #fafafa; -} - -div.admonition p.admonition-title { - font-family: {{ header_font }}; - font-weight: normal; - font-size: 24px; - margin: 0 0 10px 0; - padding: 0; - line-height: 1; -} - -div.admonition :last-child { - margin-bottom: 0; -} - -div.highlight { - background-color: white; -} - -dt:target, .highlight { - background: #FAF3E8; -} - -div.note, div.warning { - background-color: #eee; - border: 1px solid #ccc; -} - -div.seealso { - background-color: #ffc; - border: 1px solid #ff6; -} - -div.topic { - background-color: #eee; -} - -div.topic a { - text-decoration: none; - border-bottom: none; -} - -p.admonition-title { - display: inline; -} - -p.admonition-title:after { - content: ":"; -} - -pre, tt, code { - font-family: 'Consolas', 'Menlo', 'Deja Vu Sans Mono', 'Bitstream Vera Sans Mono', monospace; - font-size: 0.9em; - background: #eee; -} - -img.screenshot { -} - -tt.descname, tt.descclassname { - font-size: 0.95em; -} - -tt.descname { - padding-right: 0.08em; -} - -img.screenshot { - -moz-box-shadow: 2px 2px 4px #eee; - -webkit-box-shadow: 2px 2px 4px #eee; - box-shadow: 2px 2px 4px #eee; -} - -table.docutils { - border: 1px solid #888; - -moz-box-shadow: 2px 2px 4px #eee; - -webkit-box-shadow: 2px 2px 4px #eee; - box-shadow: 2px 2px 4px #eee; -} - -table.docutils td, table.docutils th { - border: 1px solid #888; - padding: 0.25em 0.7em; -} - -table.field-list, table.footnote { - border: none; - -moz-box-shadow: none; - -webkit-box-shadow: none; - box-shadow: none; -} - -table.footnote { - margin: 15px 0; - width: 100%; - border: 1px solid #eee; - background: #fdfdfd; - font-size: 0.9em; -} - -table.footnote + table.footnote { - margin-top: -15px; - border-top: none; -} - -table.field-list th { - padding: 0 0.8em 0 0; -} - -table.field-list td { - padding: 0; -} - -table.footnote td.label { - width: 0px; - padding: 0.3em 0 0.3em 0.5em; -} - -table.footnote td { - padding: 0.3em 0.5em; -} - -dl { - margin: 0; - padding: 0; -} - -dl dd { - margin-left: 30px; -} - -blockquote { - margin: 0 0 0 30px; - padding: 0; -} - -ul, ol { - margin: 10px 0 10px 30px; - padding: 0; -} - -pre { - background: #eee; - padding: 7px 12px; - line-height: 1.3em; -} - -tt { - background-color: #ecf0f3; - color: #222; - /* padding: 1px 2px; */ -} - -tt.xref, a tt { - background-color: #FBFBFB; - border-bottom: 1px solid white; -} - -a.reference { - text-decoration: none; - border-bottom: 1px dotted {{ link_color }}; -} - -a.reference:hover { - border-bottom: 1px solid {{ link_hover_color }}; -} - -li.toctree-l1 a.reference, -li.toctree-l2 a.reference, -li.toctree-l3 a.reference, -li.toctree-l4 a.reference { - border-bottom: none; -} - -li.toctree-l1 a.reference:hover, -li.toctree-l2 a.reference:hover, -li.toctree-l3 a.reference:hover, -li.toctree-l4 a.reference:hover { - border-bottom: 1px solid {{ link_hover_color }}; -} - -a.footnote-reference { - text-decoration: none; - font-size: 0.7em; - vertical-align: top; - border-bottom: 1px dotted {{ link_color }}; -} - -a.footnote-reference:hover { - border-bottom: 1px solid {{ link_hover_color }}; -} - -a:hover tt { - background: #EEE; -} - -#reference div.section h2 { - /* separate code elements in the reference section */ - border-top: 2px solid #ccc; - padding-top: 0.5em; -} - -#reference div.section h3 { - /* separate code elements in the reference section */ - border-top: 1px solid #ccc; - padding-top: 0.5em; -} - -dl.class, dl.function { - margin-top: 1em; - margin-bottom: 1em; -} - -dl.class > dd { - border-left: 3px solid #ccc; - margin-left: 0px; - padding-left: 30px; -} - -dl.field-list { - flex-direction: column; -} - -dl.field-list dd { - padding-left: 4em; - border-left: 3px solid #ccc; - margin-bottom: 0.5em; -} - -dl.field-list dd > ul { - list-style: none; - padding-left: 0px; -} - -dl.field-list dd > ul > li li :first-child { - text-indent: 0; -} - -dl.field-list dd > ul > li :first-child { - text-indent: -2em; - padding-left: 0px; -} - -dl.field-list dd > p:first-child { - text-indent: -2em; -} - -@media screen and (max-width: 870px) { - - div.sphinxsidebar { - display: none; - } - - div.document { - width: 100%; - - } - - div.documentwrapper { - margin-left: 0; - margin-top: 0; - margin-right: 0; - margin-bottom: 0; - } - - div.bodywrapper { - margin-top: 0; - margin-right: 0; - margin-bottom: 0; - margin-left: 0; - } - - ul { - margin-left: 0; - } - - .document { - width: auto; - } - - .footer { - width: auto; - } - - .bodywrapper { - margin: 0; - } - - .footer { - width: auto; - } - - .github { - display: none; - } - - - -} - - - -@media screen and (max-width: 875px) { - - body { - margin: 0; - padding: 20px 30px; - } - - div.documentwrapper { - float: none; - background: white; - } - - div.sphinxsidebar { - display: block; - float: none; - width: 102.5%; - margin: 50px -30px -20px -30px; - padding: 10px 20px; - background: #333; - color: white; - } - - div.sphinxsidebar h3, div.sphinxsidebar h4, div.sphinxsidebar p, - div.sphinxsidebar h3 a, div.sphinxsidebar ul { - color: white; - } - - div.sphinxsidebar a { - color: #aaa; - } - - div.sphinxsidebar p.logo { - display: none; - } - - div.document { - width: 100%; - margin: 0; - } - - div.related { - display: block; - margin: 0; - padding: 10px 0 20px 0; - } - - div.related ul, - div.related ul li { - margin: 0; - padding: 0; - } - - div.footer { - display: none; - } - - div.bodywrapper { - margin: 0; - } - - div.body { - min-height: 0; - padding: 0; - } - - .rtd_doc_footer { - display: none; - } - - .document { - width: auto; - } - - .footer { - width: auto; - } - - .footer { - width: auto; - } - - .github { - display: none; - } -} - -/* misc. */ - -.revsys-inline { - display: none!important; -} diff --git a/doc/en/_themes/flask/theme.conf b/doc/en/_themes/flask/theme.conf deleted file mode 100644 index 372b0028393..00000000000 --- a/doc/en/_themes/flask/theme.conf +++ /dev/null @@ -1,9 +0,0 @@ -[theme] -inherit = basic -stylesheet = flasky.css -pygments_style = flask_theme_support.FlaskyStyle - -[options] -index_logo = '' -index_logo_height = 120px -touch_icon = diff --git a/doc/en/_themes/flask_theme_support.py b/doc/en/_themes/flask_theme_support.py deleted file mode 100644 index b107f2c892e..00000000000 --- a/doc/en/_themes/flask_theme_support.py +++ /dev/null @@ -1,87 +0,0 @@ -# flasky extensions. flasky pygments style based on tango style -from pygments.style import Style -from pygments.token import Comment -from pygments.token import Error -from pygments.token import Generic -from pygments.token import Keyword -from pygments.token import Literal -from pygments.token import Name -from pygments.token import Number -from pygments.token import Operator -from pygments.token import Other -from pygments.token import Punctuation -from pygments.token import String -from pygments.token import Whitespace - - -class FlaskyStyle(Style): - background_color = "#f8f8f8" - default_style = "" - - styles = { - # No corresponding class for the following: - # Text: "", # class: '' - Whitespace: "underline #f8f8f8", # class: 'w' - Error: "#a40000 border:#ef2929", # class: 'err' - Other: "#000000", # class 'x' - Comment: "italic #8f5902", # class: 'c' - Comment.Preproc: "noitalic", # class: 'cp' - Keyword: "bold #004461", # class: 'k' - Keyword.Constant: "bold #004461", # class: 'kc' - Keyword.Declaration: "bold #004461", # class: 'kd' - Keyword.Namespace: "bold #004461", # class: 'kn' - Keyword.Pseudo: "bold #004461", # class: 'kp' - Keyword.Reserved: "bold #004461", # class: 'kr' - Keyword.Type: "bold #004461", # class: 'kt' - Operator: "#582800", # class: 'o' - Operator.Word: "bold #004461", # class: 'ow' - like keywords - Punctuation: "bold #000000", # class: 'p' - # because special names such as Name.Class, Name.Function, etc. - # are not recognized as such later in the parsing, we choose them - # to look the same as ordinary variables. - Name: "#000000", # class: 'n' - Name.Attribute: "#c4a000", # class: 'na' - to be revised - Name.Builtin: "#004461", # class: 'nb' - Name.Builtin.Pseudo: "#3465a4", # class: 'bp' - Name.Class: "#000000", # class: 'nc' - to be revised - Name.Constant: "#000000", # class: 'no' - to be revised - Name.Decorator: "#888", # class: 'nd' - to be revised - Name.Entity: "#ce5c00", # class: 'ni' - Name.Exception: "bold #cc0000", # class: 'ne' - Name.Function: "#000000", # class: 'nf' - Name.Property: "#000000", # class: 'py' - Name.Label: "#f57900", # class: 'nl' - Name.Namespace: "#000000", # class: 'nn' - to be revised - Name.Other: "#000000", # class: 'nx' - Name.Tag: "bold #004461", # class: 'nt' - like a keyword - Name.Variable: "#000000", # class: 'nv' - to be revised - Name.Variable.Class: "#000000", # class: 'vc' - to be revised - Name.Variable.Global: "#000000", # class: 'vg' - to be revised - Name.Variable.Instance: "#000000", # class: 'vi' - to be revised - Number: "#990000", # class: 'm' - Literal: "#000000", # class: 'l' - Literal.Date: "#000000", # class: 'ld' - String: "#4e9a06", # class: 's' - String.Backtick: "#4e9a06", # class: 'sb' - String.Char: "#4e9a06", # class: 'sc' - String.Doc: "italic #8f5902", # class: 'sd' - like a comment - String.Double: "#4e9a06", # class: 's2' - String.Escape: "#4e9a06", # class: 'se' - String.Heredoc: "#4e9a06", # class: 'sh' - String.Interpol: "#4e9a06", # class: 'si' - String.Other: "#4e9a06", # class: 'sx' - String.Regex: "#4e9a06", # class: 'sr' - String.Single: "#4e9a06", # class: 's1' - String.Symbol: "#4e9a06", # class: 'ss' - Generic: "#000000", # class: 'g' - Generic.Deleted: "#a40000", # class: 'gd' - Generic.Emph: "italic #000000", # class: 'ge' - Generic.Error: "#ef2929", # class: 'gr' - Generic.Heading: "bold #000080", # class: 'gh' - Generic.Inserted: "#00A000", # class: 'gi' - Generic.Output: "#888", # class: 'go' - Generic.Prompt: "#745334", # class: 'gp' - Generic.Strong: "bold #000000", # class: 'gs' - Generic.Subheading: "bold #800080", # class: 'gu' - Generic.Traceback: "bold #a40000", # class: 'gt' - } diff --git a/doc/en/adopt.rst b/doc/en/adopt.rst index e3c0477bc0e..82e2111ed3b 100644 --- a/doc/en/adopt.rst +++ b/doc/en/adopt.rst @@ -45,7 +45,7 @@ Partner projects, sign up here! (by 22 March) What does it mean to "adopt pytest"? ----------------------------------------- -There can be many different definitions of "success". Pytest can run many `nose and unittest`_ tests by default, so using pytest as your testrunner may be possible from day 1. Job done, right? +There can be many different definitions of "success". Pytest can run many nose_ and unittest_ tests by default, so using pytest as your testrunner may be possible from day 1. Job done, right? Progressive success might look like: @@ -63,7 +63,8 @@ Progressive success might look like: It may be after the month is up, the partner project decides that pytest is not right for it. That's okay - hopefully the pytest team will also learn something about its weaknesses or deficiencies. -.. _`nose and unittest`: faq.html#how-does-pytest-relate-to-nose-and-unittest +.. _nose: nose.html +.. _unittest: unittest.html .. _assert: assert.html .. _pycmd: https://bitbucket.org/hpk42/pycmd/overview .. _`setUp/tearDown methods`: xunit_setup.html diff --git a/doc/en/announce/index.rst b/doc/en/announce/index.rst index 3bb3b0b9ec9..e7cac2a1c41 100644 --- a/doc/en/announce/index.rst +++ b/doc/en/announce/index.rst @@ -6,6 +6,18 @@ Release announcements :maxdepth: 2 + release-6.2.1 + release-6.2.0 + release-6.1.2 + release-6.1.1 + release-6.1.0 + release-6.0.2 + release-6.0.1 + release-6.0.0 + release-6.0.0rc1 + release-5.4.3 + release-5.4.2 + release-5.4.1 release-5.4.0 release-5.3.5 release-5.3.4 diff --git a/doc/en/announce/release-2.0.0.rst b/doc/en/announce/release-2.0.0.rst index d9d90c09a42..1aaad740a4f 100644 --- a/doc/en/announce/release-2.0.0.rst +++ b/doc/en/announce/release-2.0.0.rst @@ -7,7 +7,7 @@ see below for summary and detailed lists. A lot of long-deprecated code has been removed, resulting in a much smaller and cleaner implementation. See the new docs with examples here: - http://pytest.org/en/latest/index.html + http://pytest.org/en/stable/index.html A note on packaging: pytest used to part of the "py" distribution up until version py-1.3.4 but this has changed now: pytest-2.0.0 only @@ -36,12 +36,12 @@ New Features import pytest ; pytest.main(arglist, pluginlist) - see http://pytest.org/en/latest/usage.html for details. + see http://pytest.org/en/stable/usage.html for details. - new and better reporting information in assert expressions if comparing lists, sequences or strings. - see http://pytest.org/en/latest/assert.html#newreport + see http://pytest.org/en/stable/assert.html#newreport - new configuration through ini-files (setup.cfg or tox.ini recognized), for example:: @@ -50,7 +50,7 @@ New Features norecursedirs = .hg data* # don't ever recurse in such dirs addopts = -x --pyargs # add these command line options by default - see http://pytest.org/en/latest/customize.html + see http://pytest.org/en/stable/customize.html - improved standard unittest support. In general py.test should now better be able to run custom unittest.TestCases like twisted trial diff --git a/doc/en/announce/release-2.0.1.rst b/doc/en/announce/release-2.0.1.rst index f86537e1d01..72401d8098f 100644 --- a/doc/en/announce/release-2.0.1.rst +++ b/doc/en/announce/release-2.0.1.rst @@ -57,7 +57,7 @@ Changes between 2.0.0 and 2.0.1 - refinements to "collecting" output on non-ttys - refine internal plugin registration and --traceconfig output - introduce a mechanism to prevent/unregister plugins from the - command line, see http://pytest.org/en/latest/plugins.html#cmdunregister + command line, see http://pytest.org/en/stable/plugins.html#cmdunregister - activate resultlog plugin by default - fix regression wrt yielded tests which due to the collection-before-running semantics were not diff --git a/doc/en/announce/release-2.1.0.rst b/doc/en/announce/release-2.1.0.rst index 2a2181d9754..29463ab533e 100644 --- a/doc/en/announce/release-2.1.0.rst +++ b/doc/en/announce/release-2.1.0.rst @@ -12,7 +12,7 @@ courtesy of Benjamin Peterson. You can now safely use ``assert`` statements in test modules without having to worry about side effects or python optimization ("-OO") options. This is achieved by rewriting assert statements in test modules upon import, using a PEP302 hook. -See https://docs.pytest.org/en/latest/assert.html for +See https://docs.pytest.org/en/stable/assert.html for detailed information. The work has been partly sponsored by my company, merlinux GmbH. diff --git a/doc/en/announce/release-2.2.0.rst b/doc/en/announce/release-2.2.0.rst index 79e4dfd1590..0193ffb3465 100644 --- a/doc/en/announce/release-2.2.0.rst +++ b/doc/en/announce/release-2.2.0.rst @@ -9,7 +9,7 @@ with these improvements: - new @pytest.mark.parametrize decorator to run tests with different arguments - new metafunc.parametrize() API for parametrizing arguments independently - - see examples at http://pytest.org/en/latest/example/parametrize.html + - see examples at http://pytest.org/en/stable/example/parametrize.html - NOTE that parametrize() related APIs are still a bit experimental and might change in future releases. @@ -18,7 +18,7 @@ with these improvements: - "-m markexpr" option for selecting tests according to their mark - a new "markers" ini-variable for registering test markers for your project - the new "--strict" bails out with an error if using unregistered markers. - - see examples at http://pytest.org/en/latest/example/markers.html + - see examples at http://pytest.org/en/stable/example/markers.html * duration profiling: new "--duration=N" option showing the N slowest test execution or setup/teardown calls. This is most useful if you want to @@ -78,7 +78,7 @@ Changes between 2.1.3 and 2.2.0 or through plugin hooks. Also introduce a "--strict" option which will treat unregistered markers as errors allowing to avoid typos and maintain a well described set of markers - for your test suite. See examples at http://pytest.org/en/latest/mark.html + for your test suite. See examples at http://pytest.org/en/stable/mark.html and its links. - issue50: introduce "-m marker" option to select tests based on markers (this is a stricter and more predictable version of "-k" in that "-m" diff --git a/doc/en/announce/release-2.3.0.rst b/doc/en/announce/release-2.3.0.rst index 1b9d0dcc1a5..bdd92a98fde 100644 --- a/doc/en/announce/release-2.3.0.rst +++ b/doc/en/announce/release-2.3.0.rst @@ -13,12 +13,12 @@ re-usable fixture design. For detailed info and tutorial-style examples, see: - http://pytest.org/en/latest/fixture.html + http://pytest.org/en/stable/fixture.html Moreover, there is now support for using pytest fixtures/funcargs with unittest-style suites, see here for examples: - http://pytest.org/en/latest/unittest.html + http://pytest.org/en/stable/unittest.html Besides, more unittest-test suites are now expected to "simply work" with pytest. @@ -29,11 +29,11 @@ pytest-2.2.4. If you are interested in the precise reasoning (including examples) of the pytest-2.3 fixture evolution, please consult -http://pytest.org/en/latest/funcarg_compare.html +http://pytest.org/en/stable/funcarg_compare.html For general info on installation and getting started: - http://pytest.org/en/latest/getting-started.html + http://pytest.org/en/stable/getting-started.html Docs and PDF access as usual at: @@ -94,7 +94,7 @@ Changes between 2.2.4 and 2.3.0 - pluginmanager.register(...) now raises ValueError if the plugin has been already registered or the name is taken -- fix issue159: improve http://pytest.org/en/latest/faq.html +- fix issue159: improve https://docs.pytest.org/en/6.0.1/faq.html especially with respect to the "magic" history, also mention pytest-django, trial and unittest integration. diff --git a/doc/en/announce/release-2.3.4.rst b/doc/en/announce/release-2.3.4.rst index b00430f943f..26f76630e84 100644 --- a/doc/en/announce/release-2.3.4.rst +++ b/doc/en/announce/release-2.3.4.rst @@ -16,7 +16,7 @@ comes with the following fixes and features: - yielded test functions will now have autouse-fixtures active but cannot accept fixtures as funcargs - it's anyway recommended to rather use the post-2.0 parametrize features instead of yield, see: - http://pytest.org/en/latest/example/parametrize.html + http://pytest.org/en/stable/example/parametrize.html - fix autouse-issue where autouse-fixtures would not be discovered if defined in an a/conftest.py file and tests in a/tests/test_some.py - fix issue226 - LIFO ordering for fixture teardowns diff --git a/doc/en/announce/release-2.3.5.rst b/doc/en/announce/release-2.3.5.rst index 465dd826ed4..d68780a2440 100644 --- a/doc/en/announce/release-2.3.5.rst +++ b/doc/en/announce/release-2.3.5.rst @@ -46,7 +46,7 @@ Changes between 2.3.4 and 2.3.5 - Issue 265 - integrate nose setup/teardown with setupstate so it doesn't try to teardown if it did not setup -- issue 271 - don't write junitxml on slave nodes +- issue 271 - don't write junitxml on worker nodes - Issue 274 - don't try to show full doctest example when doctest does not know the example location diff --git a/doc/en/announce/release-2.4.0.rst b/doc/en/announce/release-2.4.0.rst index 6cd14bc2dfc..68297b26c4e 100644 --- a/doc/en/announce/release-2.4.0.rst +++ b/doc/en/announce/release-2.4.0.rst @@ -7,7 +7,7 @@ from a few supposedly very minor incompatibilities. See below for a full list of details. A few feature highlights: - new yield-style fixtures `pytest.yield_fixture - `_, allowing to use + `_, allowing to use existing with-style context managers in fixture functions. - improved pdb support: ``import pdb ; pdb.set_trace()`` now works diff --git a/doc/en/announce/release-2.5.1.rst b/doc/en/announce/release-2.5.1.rst index 22e69a836b9..ff39db2d52d 100644 --- a/doc/en/announce/release-2.5.1.rst +++ b/doc/en/announce/release-2.5.1.rst @@ -1,7 +1,7 @@ pytest-2.5.1: fixes and new home page styling =========================================================================== -pytest is a mature Python testing tool with more than a 1000 tests +pytest is a mature Python testing tool with more than 1000 tests against itself, passing on many different interpreters and platforms. The 2.5.1 release maintains the "zero-reported-bugs" promise by fixing diff --git a/doc/en/announce/release-2.5.2.rst b/doc/en/announce/release-2.5.2.rst index c389f5f5403..edc4da6e19f 100644 --- a/doc/en/announce/release-2.5.2.rst +++ b/doc/en/announce/release-2.5.2.rst @@ -1,7 +1,7 @@ pytest-2.5.2: fixes =========================================================================== -pytest is a mature Python testing tool with more than a 1000 tests +pytest is a mature Python testing tool with more than 1000 tests against itself, passing on many different interpreters and platforms. The 2.5.2 release fixes a few bugs with two maybe-bugs remaining and diff --git a/doc/en/announce/release-2.6.0.rst b/doc/en/announce/release-2.6.0.rst index 36b545a28b4..56fbd6cc1e4 100644 --- a/doc/en/announce/release-2.6.0.rst +++ b/doc/en/announce/release-2.6.0.rst @@ -1,7 +1,7 @@ pytest-2.6.0: shorter tracebacks, new warning system, test runner compat =========================================================================== -pytest is a mature Python testing tool with more than a 1000 tests +pytest is a mature Python testing tool with more than 1000 tests against itself, passing on many different interpreters and platforms. The 2.6.0 release should be drop-in backward compatible to 2.5.2 and diff --git a/doc/en/announce/release-2.6.1.rst b/doc/en/announce/release-2.6.1.rst index fba6f2993a5..7469c488e5f 100644 --- a/doc/en/announce/release-2.6.1.rst +++ b/doc/en/announce/release-2.6.1.rst @@ -1,7 +1,7 @@ pytest-2.6.1: fixes and new xfail feature =========================================================================== -pytest is a mature Python testing tool with more than a 1100 tests +pytest is a mature Python testing tool with more than 1100 tests against itself, passing on many different interpreters and platforms. The 2.6.1 release is drop-in compatible to 2.5.2 and actually fixes some regressions introduced with 2.6.0. It also brings a little feature @@ -32,7 +32,7 @@ Changes 2.6.1 purely the nodeid. The line number is still shown in failure reports. Thanks Floris Bruynooghe. -- fix issue437 where assertion rewriting could cause pytest-xdist slaves +- fix issue437 where assertion rewriting could cause pytest-xdist worker nodes to collect different tests. Thanks Bruno Oliveira. - fix issue555: add "errors" attribute to capture-streams to satisfy diff --git a/doc/en/announce/release-2.6.2.rst b/doc/en/announce/release-2.6.2.rst index f6ce178a107..9c3b7d96b07 100644 --- a/doc/en/announce/release-2.6.2.rst +++ b/doc/en/announce/release-2.6.2.rst @@ -1,7 +1,7 @@ pytest-2.6.2: few fixes and cx_freeze support =========================================================================== -pytest is a mature Python testing tool with more than a 1100 tests +pytest is a mature Python testing tool with more than 1100 tests against itself, passing on many different interpreters and platforms. This release is drop-in compatible to 2.5.2 and 2.6.X. It also brings support for including pytest with cx_freeze or similar diff --git a/doc/en/announce/release-2.6.3.rst b/doc/en/announce/release-2.6.3.rst index 7353dfee71c..56973a2b2f7 100644 --- a/doc/en/announce/release-2.6.3.rst +++ b/doc/en/announce/release-2.6.3.rst @@ -1,7 +1,7 @@ pytest-2.6.3: fixes and little improvements =========================================================================== -pytest is a mature Python testing tool with more than a 1100 tests +pytest is a mature Python testing tool with more than 1100 tests against itself, passing on many different interpreters and platforms. This release is drop-in compatible to 2.5.2 and 2.6.X. See below for the changes and see docs at: diff --git a/doc/en/announce/release-2.7.0.rst b/doc/en/announce/release-2.7.0.rst index cf798ff2c34..2840178a07f 100644 --- a/doc/en/announce/release-2.7.0.rst +++ b/doc/en/announce/release-2.7.0.rst @@ -1,7 +1,7 @@ pytest-2.7.0: fixes, features, speed improvements =========================================================================== -pytest is a mature Python testing tool with more than a 1100 tests +pytest is a mature Python testing tool with more than 1100 tests against itself, passing on many different interpreters and platforms. This release is supposed to be drop-in compatible to 2.6.X. @@ -52,7 +52,7 @@ holger krekel - add ability to set command line options by environment variable PYTEST_ADDOPTS. - added documentation on the new pytest-dev teams on bitbucket and - github. See https://pytest.org/en/latest/contributing.html . + github. See https://pytest.org/en/stable/contributing.html . Thanks to Anatoly for pushing and initial work on this. - fix issue650: new option ``--docttest-ignore-import-errors`` which diff --git a/doc/en/announce/release-2.7.1.rst b/doc/en/announce/release-2.7.1.rst index fdc71eebba9..5110c085e01 100644 --- a/doc/en/announce/release-2.7.1.rst +++ b/doc/en/announce/release-2.7.1.rst @@ -1,7 +1,7 @@ pytest-2.7.1: bug fixes ======================= -pytest is a mature Python testing tool with more than a 1100 tests +pytest is a mature Python testing tool with more than 1100 tests against itself, passing on many different interpreters and platforms. This release is supposed to be drop-in compatible to 2.7.0. diff --git a/doc/en/announce/release-2.7.2.rst b/doc/en/announce/release-2.7.2.rst index 1e3950de4d0..93e5b64eeed 100644 --- a/doc/en/announce/release-2.7.2.rst +++ b/doc/en/announce/release-2.7.2.rst @@ -1,7 +1,7 @@ pytest-2.7.2: bug fixes ======================= -pytest is a mature Python testing tool with more than a 1100 tests +pytest is a mature Python testing tool with more than 1100 tests against itself, passing on many different interpreters and platforms. This release is supposed to be drop-in compatible to 2.7.1. diff --git a/doc/en/announce/release-2.8.2.rst b/doc/en/announce/release-2.8.2.rst index d7028616142..e4726338852 100644 --- a/doc/en/announce/release-2.8.2.rst +++ b/doc/en/announce/release-2.8.2.rst @@ -1,7 +1,7 @@ pytest-2.8.2: bug fixes ======================= -pytest is a mature Python testing tool with more than a 1100 tests +pytest is a mature Python testing tool with more than 1100 tests against itself, passing on many different interpreters and platforms. This release is supposed to be drop-in compatible to 2.8.1. diff --git a/doc/en/announce/release-2.8.3.rst b/doc/en/announce/release-2.8.3.rst index b131a7e1f14..3f357252bb6 100644 --- a/doc/en/announce/release-2.8.3.rst +++ b/doc/en/announce/release-2.8.3.rst @@ -1,7 +1,7 @@ pytest-2.8.3: bug fixes ======================= -pytest is a mature Python testing tool with more than a 1100 tests +pytest is a mature Python testing tool with more than 1100 tests against itself, passing on many different interpreters and platforms. This release is supposed to be drop-in compatible to 2.8.2. diff --git a/doc/en/announce/release-2.8.4.rst b/doc/en/announce/release-2.8.4.rst index a09629cef09..adbdecc87ea 100644 --- a/doc/en/announce/release-2.8.4.rst +++ b/doc/en/announce/release-2.8.4.rst @@ -1,7 +1,7 @@ pytest-2.8.4 ============ -pytest is a mature Python testing tool with more than a 1100 tests +pytest is a mature Python testing tool with more than 1100 tests against itself, passing on many different interpreters and platforms. This release is supposed to be drop-in compatible to 2.8.2. diff --git a/doc/en/announce/release-2.8.5.rst b/doc/en/announce/release-2.8.5.rst index 7409022a137..c5343d1ea72 100644 --- a/doc/en/announce/release-2.8.5.rst +++ b/doc/en/announce/release-2.8.5.rst @@ -1,7 +1,7 @@ pytest-2.8.5 ============ -pytest is a mature Python testing tool with more than a 1100 tests +pytest is a mature Python testing tool with more than 1100 tests against itself, passing on many different interpreters and platforms. This release is supposed to be drop-in compatible to 2.8.4. diff --git a/doc/en/announce/release-2.8.6.rst b/doc/en/announce/release-2.8.6.rst index 215fae51eac..5d6565b16a3 100644 --- a/doc/en/announce/release-2.8.6.rst +++ b/doc/en/announce/release-2.8.6.rst @@ -1,7 +1,7 @@ pytest-2.8.6 ============ -pytest is a mature Python testing tool with more than a 1100 tests +pytest is a mature Python testing tool with more than 1100 tests against itself, passing on many different interpreters and platforms. This release is supposed to be drop-in compatible to 2.8.5. diff --git a/doc/en/announce/release-2.8.7.rst b/doc/en/announce/release-2.8.7.rst index 9005f56363a..8236a096669 100644 --- a/doc/en/announce/release-2.8.7.rst +++ b/doc/en/announce/release-2.8.7.rst @@ -4,7 +4,7 @@ pytest-2.8.7 This is a hotfix release to solve a regression in the builtin monkeypatch plugin that got introduced in 2.8.6. -pytest is a mature Python testing tool with more than a 1100 tests +pytest is a mature Python testing tool with more than 1100 tests against itself, passing on many different interpreters and platforms. This release is supposed to be drop-in compatible to 2.8.5. diff --git a/doc/en/announce/release-2.9.0.rst b/doc/en/announce/release-2.9.0.rst index 9e085669023..8c2ee05f9bf 100644 --- a/doc/en/announce/release-2.9.0.rst +++ b/doc/en/announce/release-2.9.0.rst @@ -1,7 +1,7 @@ pytest-2.9.0 ============ -pytest is a mature Python testing tool with more than a 1100 tests +pytest is a mature Python testing tool with more than 1100 tests against itself, passing on many different interpreters and platforms. See below for the changes and see docs at: @@ -131,7 +131,7 @@ The py.test Development Team with same name. -.. _`traceback style docs`: https://pytest.org/en/latest/usage.html#modifying-python-traceback-printing +.. _`traceback style docs`: https://pytest.org/en/stable/usage.html#modifying-python-traceback-printing .. _#1422: https://github.com/pytest-dev/pytest/issues/1422 .. _#1379: https://github.com/pytest-dev/pytest/issues/1379 diff --git a/doc/en/announce/release-2.9.1.rst b/doc/en/announce/release-2.9.1.rst index c71f3851638..47bc2e6d38b 100644 --- a/doc/en/announce/release-2.9.1.rst +++ b/doc/en/announce/release-2.9.1.rst @@ -1,7 +1,7 @@ pytest-2.9.1 ============ -pytest is a mature Python testing tool with more than a 1100 tests +pytest is a mature Python testing tool with more than 1100 tests against itself, passing on many different interpreters and platforms. See below for the changes and see docs at: diff --git a/doc/en/announce/release-2.9.2.rst b/doc/en/announce/release-2.9.2.rst index b007a6d99e8..ffd8dc58ed5 100644 --- a/doc/en/announce/release-2.9.2.rst +++ b/doc/en/announce/release-2.9.2.rst @@ -1,7 +1,7 @@ pytest-2.9.2 ============ -pytest is a mature Python testing tool with more than a 1100 tests +pytest is a mature Python testing tool with more than 1100 tests against itself, passing on many different interpreters and platforms. See below for the changes and see docs at: diff --git a/doc/en/announce/release-3.0.0.rst b/doc/en/announce/release-3.0.0.rst index ca3e9e32763..5de38911482 100644 --- a/doc/en/announce/release-3.0.0.rst +++ b/doc/en/announce/release-3.0.0.rst @@ -3,7 +3,7 @@ pytest-3.0.0 The pytest team is proud to announce the 3.0.0 release! -pytest is a mature Python testing tool with more than a 1600 tests +pytest is a mature Python testing tool with more than 1600 tests against itself, passing on many different interpreters and platforms. This release contains a lot of bugs fixes and improvements, and much of diff --git a/doc/en/announce/release-3.0.1.rst b/doc/en/announce/release-3.0.1.rst index eb6f6a50ef7..8f5cfe411aa 100644 --- a/doc/en/announce/release-3.0.1.rst +++ b/doc/en/announce/release-3.0.1.rst @@ -8,7 +8,7 @@ drop-in replacement. To upgrade: pip install --upgrade pytest -The changelog is available at http://doc.pytest.org/en/latest/changelog.html. +The changelog is available at http://doc.pytest.org/en/stable/changelog.html. Thanks to all who contributed to this release, among them: diff --git a/doc/en/announce/release-3.0.2.rst b/doc/en/announce/release-3.0.2.rst index 4af412fc5ee..86ba82ca6e6 100644 --- a/doc/en/announce/release-3.0.2.rst +++ b/doc/en/announce/release-3.0.2.rst @@ -8,7 +8,7 @@ drop-in replacement. To upgrade:: pip install --upgrade pytest -The changelog is available at http://doc.pytest.org/en/latest/changelog.html. +The changelog is available at http://doc.pytest.org/en/stable/changelog.html. Thanks to all who contributed to this release, among them: diff --git a/doc/en/announce/release-3.0.3.rst b/doc/en/announce/release-3.0.3.rst index 896d4787304..89a2e0c744e 100644 --- a/doc/en/announce/release-3.0.3.rst +++ b/doc/en/announce/release-3.0.3.rst @@ -8,7 +8,7 @@ being a drop-in replacement. To upgrade:: pip install --upgrade pytest -The changelog is available at http://doc.pytest.org/en/latest/changelog.html. +The changelog is available at http://doc.pytest.org/en/stable/changelog.html. Thanks to all who contributed to this release, among them: diff --git a/doc/en/announce/release-3.0.4.rst b/doc/en/announce/release-3.0.4.rst index 855bc56d5b8..72c2d29464d 100644 --- a/doc/en/announce/release-3.0.4.rst +++ b/doc/en/announce/release-3.0.4.rst @@ -8,7 +8,7 @@ being a drop-in replacement. To upgrade:: pip install --upgrade pytest -The changelog is available at http://doc.pytest.org/en/latest/changelog.html. +The changelog is available at http://doc.pytest.org/en/stable/changelog.html. Thanks to all who contributed to this release, among them: diff --git a/doc/en/announce/release-3.0.5.rst b/doc/en/announce/release-3.0.5.rst index 2f369827588..97edb7d4628 100644 --- a/doc/en/announce/release-3.0.5.rst +++ b/doc/en/announce/release-3.0.5.rst @@ -7,7 +7,7 @@ This is a bug-fix release, being a drop-in replacement. To upgrade:: pip install --upgrade pytest -The changelog is available at http://doc.pytest.org/en/latest/changelog.html. +The changelog is available at http://doc.pytest.org/en/stable/changelog.html. Thanks to all who contributed to this release, among them: diff --git a/doc/en/announce/release-3.0.6.rst b/doc/en/announce/release-3.0.6.rst index 149c2d65e1a..9c072cedcca 100644 --- a/doc/en/announce/release-3.0.6.rst +++ b/doc/en/announce/release-3.0.6.rst @@ -7,7 +7,7 @@ This is a bug-fix release, being a drop-in replacement. To upgrade:: pip install --upgrade pytest -The full changelog is available at http://doc.pytest.org/en/latest/changelog.html. +The full changelog is available at http://doc.pytest.org/en/stable/changelog.html. Thanks to all who contributed to this release, among them: diff --git a/doc/en/announce/release-3.0.7.rst b/doc/en/announce/release-3.0.7.rst index b37e4f61dee..4b7e075e76a 100644 --- a/doc/en/announce/release-3.0.7.rst +++ b/doc/en/announce/release-3.0.7.rst @@ -7,7 +7,7 @@ This is a bug-fix release, being a drop-in replacement. To upgrade:: pip install --upgrade pytest -The full changelog is available at http://doc.pytest.org/en/latest/changelog.html. +The full changelog is available at http://doc.pytest.org/en/stable/changelog.html. Thanks to all who contributed to this release, among them: diff --git a/doc/en/announce/release-3.1.0.rst b/doc/en/announce/release-3.1.0.rst index 99cc6bdbe20..55277067948 100644 --- a/doc/en/announce/release-3.1.0.rst +++ b/doc/en/announce/release-3.1.0.rst @@ -3,13 +3,13 @@ pytest-3.1.0 The pytest team is proud to announce the 3.1.0 release! -pytest is a mature Python testing tool with more than a 1600 tests +pytest is a mature Python testing tool with more than 1600 tests against itself, passing on many different interpreters and platforms. This release contains a bugs fixes and improvements, so users are encouraged to take a look at the CHANGELOG: -http://doc.pytest.org/en/latest/changelog.html +http://doc.pytest.org/en/stable/changelog.html For complete documentation, please visit: diff --git a/doc/en/announce/release-3.1.1.rst b/doc/en/announce/release-3.1.1.rst index 4ce7531977c..135b2fe8443 100644 --- a/doc/en/announce/release-3.1.1.rst +++ b/doc/en/announce/release-3.1.1.rst @@ -7,7 +7,7 @@ This is a bug-fix release, being a drop-in replacement. To upgrade:: pip install --upgrade pytest -The full changelog is available at http://doc.pytest.org/en/latest/changelog.html. +The full changelog is available at http://doc.pytest.org/en/stable/changelog.html. Thanks to all who contributed to this release, among them: diff --git a/doc/en/announce/release-3.1.2.rst b/doc/en/announce/release-3.1.2.rst index 8ed0c93e9ad..a9b85c4715c 100644 --- a/doc/en/announce/release-3.1.2.rst +++ b/doc/en/announce/release-3.1.2.rst @@ -7,7 +7,7 @@ This is a bug-fix release, being a drop-in replacement. To upgrade:: pip install --upgrade pytest -The full changelog is available at http://doc.pytest.org/en/latest/changelog.html. +The full changelog is available at http://doc.pytest.org/en/stable/changelog.html. Thanks to all who contributed to this release, among them: diff --git a/doc/en/announce/release-3.1.3.rst b/doc/en/announce/release-3.1.3.rst index d7771f92232..bc2b85fcfd5 100644 --- a/doc/en/announce/release-3.1.3.rst +++ b/doc/en/announce/release-3.1.3.rst @@ -7,7 +7,7 @@ This is a bug-fix release, being a drop-in replacement. To upgrade:: pip install --upgrade pytest -The full changelog is available at http://doc.pytest.org/en/latest/changelog.html. +The full changelog is available at http://doc.pytest.org/en/stable/changelog.html. Thanks to all who contributed to this release, among them: diff --git a/doc/en/announce/release-3.10.0.rst b/doc/en/announce/release-3.10.0.rst index b53df270219..ff3c000b0e7 100644 --- a/doc/en/announce/release-3.10.0.rst +++ b/doc/en/announce/release-3.10.0.rst @@ -3,17 +3,17 @@ pytest-3.10.0 The pytest team is proud to announce the 3.10.0 release! -pytest is a mature Python testing tool with more than a 2000 tests +pytest is a mature Python testing tool with more than 2000 tests against itself, passing on many different interpreters and platforms. This release contains a number of bugs fixes and improvements, so users are encouraged to take a look at the CHANGELOG: - https://docs.pytest.org/en/latest/changelog.html + https://docs.pytest.org/en/stable/changelog.html For complete documentation, please visit: - https://docs.pytest.org/en/latest/ + https://docs.pytest.org/en/stable/ As usual, you can upgrade from pypi via: diff --git a/doc/en/announce/release-3.10.1.rst b/doc/en/announce/release-3.10.1.rst index 556b24ae15b..ad365f63474 100644 --- a/doc/en/announce/release-3.10.1.rst +++ b/doc/en/announce/release-3.10.1.rst @@ -7,7 +7,7 @@ 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/latest/changelog.html. +The full changelog is available at https://docs.pytest.org/en/stable/changelog.html. Thanks to all who contributed to this release, among them: diff --git a/doc/en/announce/release-3.2.0.rst b/doc/en/announce/release-3.2.0.rst index 4d2830edd2d..edc66a28e78 100644 --- a/doc/en/announce/release-3.2.0.rst +++ b/doc/en/announce/release-3.2.0.rst @@ -3,13 +3,13 @@ pytest-3.2.0 The pytest team is proud to announce the 3.2.0 release! -pytest is a mature Python testing tool with more than a 1600 tests +pytest is a mature Python testing tool with more than 1600 tests against itself, passing on many different interpreters and platforms. This release contains a number of bugs fixes and improvements, so users are encouraged to take a look at the CHANGELOG: - http://doc.pytest.org/en/latest/changelog.html + http://doc.pytest.org/en/stable/changelog.html For complete documentation, please visit: diff --git a/doc/en/announce/release-3.2.1.rst b/doc/en/announce/release-3.2.1.rst index afe2c5bfe2c..c40217d311d 100644 --- a/doc/en/announce/release-3.2.1.rst +++ b/doc/en/announce/release-3.2.1.rst @@ -7,7 +7,7 @@ This is a bug-fix release, being a drop-in replacement. To upgrade:: pip install --upgrade pytest -The full changelog is available at http://doc.pytest.org/en/latest/changelog.html. +The full changelog is available at http://doc.pytest.org/en/stable/changelog.html. Thanks to all who contributed to this release, among them: diff --git a/doc/en/announce/release-3.2.2.rst b/doc/en/announce/release-3.2.2.rst index 88e32873a1b..5e6c43ab177 100644 --- a/doc/en/announce/release-3.2.2.rst +++ b/doc/en/announce/release-3.2.2.rst @@ -7,7 +7,7 @@ This is a bug-fix release, being a drop-in replacement. To upgrade:: pip install --upgrade pytest -The full changelog is available at http://doc.pytest.org/en/latest/changelog.html. +The full changelog is available at http://doc.pytest.org/en/stable/changelog.html. Thanks to all who contributed to this release, among them: diff --git a/doc/en/announce/release-3.2.3.rst b/doc/en/announce/release-3.2.3.rst index ddfda4d132f..50dce29c1ad 100644 --- a/doc/en/announce/release-3.2.3.rst +++ b/doc/en/announce/release-3.2.3.rst @@ -7,7 +7,7 @@ This is a bug-fix release, being a drop-in replacement. To upgrade:: pip install --upgrade pytest -The full changelog is available at http://doc.pytest.org/en/latest/changelog.html. +The full changelog is available at http://doc.pytest.org/en/stable/changelog.html. Thanks to all who contributed to this release, among them: diff --git a/doc/en/announce/release-3.2.4.rst b/doc/en/announce/release-3.2.4.rst index 65e486b7aa2..ff0b35781b1 100644 --- a/doc/en/announce/release-3.2.4.rst +++ b/doc/en/announce/release-3.2.4.rst @@ -7,7 +7,7 @@ This is a bug-fix release, being a drop-in replacement. To upgrade:: pip install --upgrade pytest -The full changelog is available at http://doc.pytest.org/en/latest/changelog.html. +The full changelog is available at http://doc.pytest.org/en/stable/changelog.html. Thanks to all who contributed to this release, among them: diff --git a/doc/en/announce/release-3.2.5.rst b/doc/en/announce/release-3.2.5.rst index 2e5304c6f27..68caccbdbc5 100644 --- a/doc/en/announce/release-3.2.5.rst +++ b/doc/en/announce/release-3.2.5.rst @@ -7,7 +7,7 @@ This is a bug-fix release, being a drop-in replacement. To upgrade:: pip install --upgrade pytest -The full changelog is available at http://doc.pytest.org/en/latest/changelog.html. +The full changelog is available at http://doc.pytest.org/en/stable/changelog.html. Thanks to all who contributed to this release, among them: diff --git a/doc/en/announce/release-3.3.0.rst b/doc/en/announce/release-3.3.0.rst index e0740e7d592..1cbf2c448c8 100644 --- a/doc/en/announce/release-3.3.0.rst +++ b/doc/en/announce/release-3.3.0.rst @@ -3,13 +3,13 @@ pytest-3.3.0 The pytest team is proud to announce the 3.3.0 release! -pytest is a mature Python testing tool with more than a 1600 tests +pytest is a mature Python testing tool with more than 1600 tests against itself, passing on many different interpreters and platforms. This release contains a number of bugs fixes and improvements, so users are encouraged to take a look at the CHANGELOG: - http://doc.pytest.org/en/latest/changelog.html + http://doc.pytest.org/en/stable/changelog.html For complete documentation, please visit: diff --git a/doc/en/announce/release-3.3.1.rst b/doc/en/announce/release-3.3.1.rst index 7eed836ae6d..98b6fa6c1ba 100644 --- a/doc/en/announce/release-3.3.1.rst +++ b/doc/en/announce/release-3.3.1.rst @@ -7,7 +7,7 @@ This is a bug-fix release, being a drop-in replacement. To upgrade:: pip install --upgrade pytest -The full changelog is available at http://doc.pytest.org/en/latest/changelog.html. +The full changelog is available at http://doc.pytest.org/en/stable/changelog.html. Thanks to all who contributed to this release, among them: diff --git a/doc/en/announce/release-3.3.2.rst b/doc/en/announce/release-3.3.2.rst index d9acef947dd..7a2577d1ff8 100644 --- a/doc/en/announce/release-3.3.2.rst +++ b/doc/en/announce/release-3.3.2.rst @@ -7,7 +7,7 @@ This is a bug-fix release, being a drop-in replacement. To upgrade:: pip install --upgrade pytest -The full changelog is available at http://doc.pytest.org/en/latest/changelog.html. +The full changelog is available at http://doc.pytest.org/en/stable/changelog.html. Thanks to all who contributed to this release, among them: diff --git a/doc/en/announce/release-3.4.0.rst b/doc/en/announce/release-3.4.0.rst index df1e004f1cc..6ab5b124a25 100644 --- a/doc/en/announce/release-3.4.0.rst +++ b/doc/en/announce/release-3.4.0.rst @@ -3,13 +3,13 @@ pytest-3.4.0 The pytest team is proud to announce the 3.4.0 release! -pytest is a mature Python testing tool with more than a 1600 tests +pytest is a mature Python testing tool with more than 1600 tests against itself, passing on many different interpreters and platforms. This release contains a number of bugs fixes and improvements, so users are encouraged to take a look at the CHANGELOG: - http://doc.pytest.org/en/latest/changelog.html + http://doc.pytest.org/en/stable/changelog.html For complete documentation, please visit: diff --git a/doc/en/announce/release-3.4.1.rst b/doc/en/announce/release-3.4.1.rst index e37f5d7e240..d83949453a2 100644 --- a/doc/en/announce/release-3.4.1.rst +++ b/doc/en/announce/release-3.4.1.rst @@ -7,7 +7,7 @@ This is a bug-fix release, being a drop-in replacement. To upgrade:: pip install --upgrade pytest -The full changelog is available at http://doc.pytest.org/en/latest/changelog.html. +The full changelog is available at http://doc.pytest.org/en/stable/changelog.html. Thanks to all who contributed to this release, among them: diff --git a/doc/en/announce/release-3.4.2.rst b/doc/en/announce/release-3.4.2.rst index 8e9988228fa..07cd9d3a8ba 100644 --- a/doc/en/announce/release-3.4.2.rst +++ b/doc/en/announce/release-3.4.2.rst @@ -7,7 +7,7 @@ This is a bug-fix release, being a drop-in replacement. To upgrade:: pip install --upgrade pytest -The full changelog is available at http://doc.pytest.org/en/latest/changelog.html. +The full changelog is available at http://doc.pytest.org/en/stable/changelog.html. Thanks to all who contributed to this release, among them: diff --git a/doc/en/announce/release-3.5.0.rst b/doc/en/announce/release-3.5.0.rst index 54a05cea24d..6bc2f3cd0cb 100644 --- a/doc/en/announce/release-3.5.0.rst +++ b/doc/en/announce/release-3.5.0.rst @@ -3,13 +3,13 @@ pytest-3.5.0 The pytest team is proud to announce the 3.5.0 release! -pytest is a mature Python testing tool with more than a 1600 tests +pytest is a mature Python testing tool with more than 1600 tests against itself, passing on many different interpreters and platforms. This release contains a number of bugs fixes and improvements, so users are encouraged to take a look at the CHANGELOG: - http://doc.pytest.org/en/latest/changelog.html + http://doc.pytest.org/en/stable/changelog.html For complete documentation, please visit: diff --git a/doc/en/announce/release-3.5.1.rst b/doc/en/announce/release-3.5.1.rst index 91f14390eeb..802be036848 100644 --- a/doc/en/announce/release-3.5.1.rst +++ b/doc/en/announce/release-3.5.1.rst @@ -7,7 +7,7 @@ This is a bug-fix release, being a drop-in replacement. To upgrade:: pip install --upgrade pytest -The full changelog is available at http://doc.pytest.org/en/latest/changelog.html. +The full changelog is available at http://doc.pytest.org/en/stable/changelog.html. Thanks to all who contributed to this release, among them: diff --git a/doc/en/announce/release-3.6.0.rst b/doc/en/announce/release-3.6.0.rst index 37361cf4add..44b178c169f 100644 --- a/doc/en/announce/release-3.6.0.rst +++ b/doc/en/announce/release-3.6.0.rst @@ -3,13 +3,13 @@ pytest-3.6.0 The pytest team is proud to announce the 3.6.0 release! -pytest is a mature Python testing tool with more than a 1600 tests +pytest is a mature Python testing tool with more than 1600 tests against itself, passing on many different interpreters and platforms. This release contains a number of bugs fixes and improvements, so users are encouraged to take a look at the CHANGELOG: - http://doc.pytest.org/en/latest/changelog.html + http://doc.pytest.org/en/stable/changelog.html For complete documentation, please visit: diff --git a/doc/en/announce/release-3.6.1.rst b/doc/en/announce/release-3.6.1.rst index 3bedcf46a85..d971a3d4907 100644 --- a/doc/en/announce/release-3.6.1.rst +++ b/doc/en/announce/release-3.6.1.rst @@ -7,7 +7,7 @@ This is a bug-fix release, being a drop-in replacement. To upgrade:: pip install --upgrade pytest -The full changelog is available at http://doc.pytest.org/en/latest/changelog.html. +The full changelog is available at http://doc.pytest.org/en/stable/changelog.html. Thanks to all who contributed to this release, among them: diff --git a/doc/en/announce/release-3.6.2.rst b/doc/en/announce/release-3.6.2.rst index a1215f57689..9d919957939 100644 --- a/doc/en/announce/release-3.6.2.rst +++ b/doc/en/announce/release-3.6.2.rst @@ -7,7 +7,7 @@ This is a bug-fix release, being a drop-in replacement. To upgrade:: pip install --upgrade pytest -The full changelog is available at http://doc.pytest.org/en/latest/changelog.html. +The full changelog is available at http://doc.pytest.org/en/stable/changelog.html. Thanks to all who contributed to this release, among them: diff --git a/doc/en/announce/release-3.6.3.rst b/doc/en/announce/release-3.6.3.rst index 07bb05a3d72..4dda2460dac 100644 --- a/doc/en/announce/release-3.6.3.rst +++ b/doc/en/announce/release-3.6.3.rst @@ -7,7 +7,7 @@ This is a bug-fix release, being a drop-in replacement. To upgrade:: pip install --upgrade pytest -The full changelog is available at http://doc.pytest.org/en/latest/changelog.html. +The full changelog is available at http://doc.pytest.org/en/stable/changelog.html. Thanks to all who contributed to this release, among them: diff --git a/doc/en/announce/release-3.6.4.rst b/doc/en/announce/release-3.6.4.rst index fd6cff50305..2c0f9efeccf 100644 --- a/doc/en/announce/release-3.6.4.rst +++ b/doc/en/announce/release-3.6.4.rst @@ -7,7 +7,7 @@ This is a bug-fix release, being a drop-in replacement. To upgrade:: pip install --upgrade pytest -The full changelog is available at http://doc.pytest.org/en/latest/changelog.html. +The full changelog is available at http://doc.pytest.org/en/stable/changelog.html. Thanks to all who contributed to this release, among them: diff --git a/doc/en/announce/release-3.7.0.rst b/doc/en/announce/release-3.7.0.rst index 922b22517e9..89908a9101c 100644 --- a/doc/en/announce/release-3.7.0.rst +++ b/doc/en/announce/release-3.7.0.rst @@ -3,13 +3,13 @@ pytest-3.7.0 The pytest team is proud to announce the 3.7.0 release! -pytest is a mature Python testing tool with more than a 2000 tests +pytest is a mature Python testing tool with more than 2000 tests against itself, passing on many different interpreters and platforms. This release contains a number of bugs fixes and improvements, so users are encouraged to take a look at the CHANGELOG: - http://doc.pytest.org/en/latest/changelog.html + http://doc.pytest.org/en/stable/changelog.html For complete documentation, please visit: diff --git a/doc/en/announce/release-3.7.1.rst b/doc/en/announce/release-3.7.1.rst index e1c35123dbf..7da5a3e1f7d 100644 --- a/doc/en/announce/release-3.7.1.rst +++ b/doc/en/announce/release-3.7.1.rst @@ -7,7 +7,7 @@ This is a bug-fix release, being a drop-in replacement. To upgrade:: pip install --upgrade pytest -The full changelog is available at http://doc.pytest.org/en/latest/changelog.html. +The full changelog is available at http://doc.pytest.org/en/stable/changelog.html. Thanks to all who contributed to this release, among them: diff --git a/doc/en/announce/release-3.7.2.rst b/doc/en/announce/release-3.7.2.rst index 4f7e0744d50..fcc6121752d 100644 --- a/doc/en/announce/release-3.7.2.rst +++ b/doc/en/announce/release-3.7.2.rst @@ -7,7 +7,7 @@ This is a bug-fix release, being a drop-in replacement. To upgrade:: pip install --upgrade pytest -The full changelog is available at http://doc.pytest.org/en/latest/changelog.html. +The full changelog is available at http://doc.pytest.org/en/stable/changelog.html. Thanks to all who contributed to this release, among them: diff --git a/doc/en/announce/release-3.7.3.rst b/doc/en/announce/release-3.7.3.rst index 454d4fdfee7..ee87da60d23 100644 --- a/doc/en/announce/release-3.7.3.rst +++ b/doc/en/announce/release-3.7.3.rst @@ -7,7 +7,7 @@ This is a bug-fix release, being a drop-in replacement. To upgrade:: pip install --upgrade pytest -The full changelog is available at http://doc.pytest.org/en/latest/changelog.html. +The full changelog is available at http://doc.pytest.org/en/stable/changelog.html. Thanks to all who contributed to this release, among them: diff --git a/doc/en/announce/release-3.7.4.rst b/doc/en/announce/release-3.7.4.rst index 0ab8938f4f6..45be4293885 100644 --- a/doc/en/announce/release-3.7.4.rst +++ b/doc/en/announce/release-3.7.4.rst @@ -7,7 +7,7 @@ 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/latest/changelog.html. +The full changelog is available at https://docs.pytest.org/en/stable/changelog.html. Thanks to all who contributed to this release, among them: diff --git a/doc/en/announce/release-3.8.0.rst b/doc/en/announce/release-3.8.0.rst index 1fc344ea23e..8c35a44f6d5 100644 --- a/doc/en/announce/release-3.8.0.rst +++ b/doc/en/announce/release-3.8.0.rst @@ -3,17 +3,17 @@ pytest-3.8.0 The pytest team is proud to announce the 3.8.0 release! -pytest is a mature Python testing tool with more than a 2000 tests +pytest is a mature Python testing tool with more than 2000 tests against itself, passing on many different interpreters and platforms. This release contains a number of bugs fixes and improvements, so users are encouraged to take a look at the CHANGELOG: - https://docs.pytest.org/en/latest/changelog.html + https://docs.pytest.org/en/stable/changelog.html For complete documentation, please visit: - https://docs.pytest.org/en/latest/ + https://docs.pytest.org/en/stable/ As usual, you can upgrade from pypi via: diff --git a/doc/en/announce/release-3.8.1.rst b/doc/en/announce/release-3.8.1.rst index 3e05e58cb3f..f8f8accc4c9 100644 --- a/doc/en/announce/release-3.8.1.rst +++ b/doc/en/announce/release-3.8.1.rst @@ -7,7 +7,7 @@ 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/latest/changelog.html. +The full changelog is available at https://docs.pytest.org/en/stable/changelog.html. Thanks to all who contributed to this release, among them: diff --git a/doc/en/announce/release-3.8.2.rst b/doc/en/announce/release-3.8.2.rst index ecc47fbb33b..9ea94c98a21 100644 --- a/doc/en/announce/release-3.8.2.rst +++ b/doc/en/announce/release-3.8.2.rst @@ -7,7 +7,7 @@ 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/latest/changelog.html. +The full changelog is available at https://docs.pytest.org/en/stable/changelog.html. Thanks to all who contributed to this release, among them: diff --git a/doc/en/announce/release-3.9.0.rst b/doc/en/announce/release-3.9.0.rst index 14cfbe9037d..0be6cf5be8a 100644 --- a/doc/en/announce/release-3.9.0.rst +++ b/doc/en/announce/release-3.9.0.rst @@ -3,17 +3,17 @@ pytest-3.9.0 The pytest team is proud to announce the 3.9.0 release! -pytest is a mature Python testing tool with more than a 2000 tests +pytest is a mature Python testing tool with more than 2000 tests against itself, passing on many different interpreters and platforms. This release contains a number of bugs fixes and improvements, so users are encouraged to take a look at the CHANGELOG: - https://docs.pytest.org/en/latest/changelog.html + https://docs.pytest.org/en/stable/changelog.html For complete documentation, please visit: - https://docs.pytest.org/en/latest/ + https://docs.pytest.org/en/stable/ As usual, you can upgrade from pypi via: diff --git a/doc/en/announce/release-3.9.1.rst b/doc/en/announce/release-3.9.1.rst index f050e465305..e1afb3759d2 100644 --- a/doc/en/announce/release-3.9.1.rst +++ b/doc/en/announce/release-3.9.1.rst @@ -7,7 +7,7 @@ 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/latest/changelog.html. +The full changelog is available at https://docs.pytest.org/en/stable/changelog.html. Thanks to all who contributed to this release, among them: diff --git a/doc/en/announce/release-3.9.2.rst b/doc/en/announce/release-3.9.2.rst index 1440831cb93..63e94e5aabb 100644 --- a/doc/en/announce/release-3.9.2.rst +++ b/doc/en/announce/release-3.9.2.rst @@ -7,7 +7,7 @@ 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/latest/changelog.html. +The full changelog is available at https://docs.pytest.org/en/stable/changelog.html. Thanks to all who contributed to this release, among them: diff --git a/doc/en/announce/release-3.9.3.rst b/doc/en/announce/release-3.9.3.rst index 8d84b4cabcb..661ddb5cb54 100644 --- a/doc/en/announce/release-3.9.3.rst +++ b/doc/en/announce/release-3.9.3.rst @@ -7,7 +7,7 @@ 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/latest/changelog.html. +The full changelog is available at https://docs.pytest.org/en/stable/changelog.html. Thanks to all who contributed to this release, among them: diff --git a/doc/en/announce/release-4.0.0.rst b/doc/en/announce/release-4.0.0.rst index e5ad69b5fd6..5eb0107758a 100644 --- a/doc/en/announce/release-4.0.0.rst +++ b/doc/en/announce/release-4.0.0.rst @@ -3,17 +3,17 @@ pytest-4.0.0 The pytest team is proud to announce the 4.0.0 release! -pytest is a mature Python testing tool with more than a 2000 tests +pytest is a mature Python testing tool with more than 2000 tests against itself, passing on many different interpreters and platforms. This release contains a number of bugs fixes and improvements, so users are encouraged to take a look at the CHANGELOG: - https://docs.pytest.org/en/latest/changelog.html + https://docs.pytest.org/en/stable/changelog.html For complete documentation, please visit: - https://docs.pytest.org/en/latest/ + https://docs.pytest.org/en/stable/ As usual, you can upgrade from pypi via: diff --git a/doc/en/announce/release-4.0.1.rst b/doc/en/announce/release-4.0.1.rst index 31b222c03b5..2902a6db9fb 100644 --- a/doc/en/announce/release-4.0.1.rst +++ b/doc/en/announce/release-4.0.1.rst @@ -7,7 +7,7 @@ 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/latest/changelog.html. +The full changelog is available at https://docs.pytest.org/en/stable/changelog.html. Thanks to all who contributed to this release, among them: diff --git a/doc/en/announce/release-4.0.2.rst b/doc/en/announce/release-4.0.2.rst index 3b6e4be7183..f439b88fe2c 100644 --- a/doc/en/announce/release-4.0.2.rst +++ b/doc/en/announce/release-4.0.2.rst @@ -7,7 +7,7 @@ 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/latest/changelog.html. +The full changelog is available at https://docs.pytest.org/en/stable/changelog.html. Thanks to all who contributed to this release, among them: diff --git a/doc/en/announce/release-4.1.0.rst b/doc/en/announce/release-4.1.0.rst index b7a076f61c9..314564eeb6f 100644 --- a/doc/en/announce/release-4.1.0.rst +++ b/doc/en/announce/release-4.1.0.rst @@ -3,17 +3,17 @@ pytest-4.1.0 The pytest team is proud to announce the 4.1.0 release! -pytest is a mature Python testing tool with more than a 2000 tests +pytest is a mature Python testing tool with more than 2000 tests against itself, passing on many different interpreters and platforms. This release contains a number of bugs fixes and improvements, so users are encouraged to take a look at the CHANGELOG: - https://docs.pytest.org/en/latest/changelog.html + https://docs.pytest.org/en/stable/changelog.html For complete documentation, please visit: - https://docs.pytest.org/en/latest/ + https://docs.pytest.org/en/stable/ As usual, you can upgrade from pypi via: diff --git a/doc/en/announce/release-4.1.1.rst b/doc/en/announce/release-4.1.1.rst index 80644fc84ef..1f45e082f89 100644 --- a/doc/en/announce/release-4.1.1.rst +++ b/doc/en/announce/release-4.1.1.rst @@ -7,7 +7,7 @@ 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/latest/changelog.html. +The full changelog is available at https://docs.pytest.org/en/stable/changelog.html. Thanks to all who contributed to this release, among them: diff --git a/doc/en/announce/release-4.2.0.rst b/doc/en/announce/release-4.2.0.rst index 6c262c1e01b..bcd7f775479 100644 --- a/doc/en/announce/release-4.2.0.rst +++ b/doc/en/announce/release-4.2.0.rst @@ -3,17 +3,17 @@ pytest-4.2.0 The pytest team is proud to announce the 4.2.0 release! -pytest is a mature Python testing tool with more than a 2000 tests +pytest is a mature Python testing tool with more than 2000 tests against itself, passing on many different interpreters and platforms. This release contains a number of bugs fixes and improvements, so users are encouraged to take a look at the CHANGELOG: - https://docs.pytest.org/en/latest/changelog.html + https://docs.pytest.org/en/stable/changelog.html For complete documentation, please visit: - https://docs.pytest.org/en/latest/ + https://docs.pytest.org/en/stable/ As usual, you can upgrade from pypi via: diff --git a/doc/en/announce/release-4.2.1.rst b/doc/en/announce/release-4.2.1.rst index 5aec022df0b..36beafe11d2 100644 --- a/doc/en/announce/release-4.2.1.rst +++ b/doc/en/announce/release-4.2.1.rst @@ -7,7 +7,7 @@ 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/latest/changelog.html. +The full changelog is available at https://docs.pytest.org/en/stable/changelog.html. Thanks to all who contributed to this release, among them: diff --git a/doc/en/announce/release-4.3.0.rst b/doc/en/announce/release-4.3.0.rst index 59393814846..3b0b4280922 100644 --- a/doc/en/announce/release-4.3.0.rst +++ b/doc/en/announce/release-4.3.0.rst @@ -3,17 +3,17 @@ pytest-4.3.0 The pytest team is proud to announce the 4.3.0 release! -pytest is a mature Python testing tool with more than a 2000 tests +pytest is a mature Python testing tool with more than 2000 tests against itself, passing on many different interpreters and platforms. This release contains a number of bugs fixes and improvements, so users are encouraged to take a look at the CHANGELOG: - https://docs.pytest.org/en/latest/changelog.html + https://docs.pytest.org/en/stable/changelog.html For complete documentation, please visit: - https://docs.pytest.org/en/latest/ + https://docs.pytest.org/en/stable/ As usual, you can upgrade from pypi via: diff --git a/doc/en/announce/release-4.3.1.rst b/doc/en/announce/release-4.3.1.rst index 54cf8b3fcd8..4251c744e55 100644 --- a/doc/en/announce/release-4.3.1.rst +++ b/doc/en/announce/release-4.3.1.rst @@ -7,7 +7,7 @@ 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/latest/changelog.html. +The full changelog is available at https://docs.pytest.org/en/stable/changelog.html. Thanks to all who contributed to this release, among them: diff --git a/doc/en/announce/release-4.4.0.rst b/doc/en/announce/release-4.4.0.rst index 4c5bcbc7d35..dc89739d0aa 100644 --- a/doc/en/announce/release-4.4.0.rst +++ b/doc/en/announce/release-4.4.0.rst @@ -3,17 +3,17 @@ pytest-4.4.0 The pytest team is proud to announce the 4.4.0 release! -pytest is a mature Python testing tool with more than a 2000 tests +pytest is a mature Python testing tool with more than 2000 tests against itself, passing on many different interpreters and platforms. This release contains a number of bugs fixes and improvements, so users are encouraged to take a look at the CHANGELOG: - https://docs.pytest.org/en/latest/changelog.html + https://docs.pytest.org/en/stable/changelog.html For complete documentation, please visit: - https://docs.pytest.org/en/latest/ + https://docs.pytest.org/en/stable/ As usual, you can upgrade from pypi via: diff --git a/doc/en/announce/release-4.4.1.rst b/doc/en/announce/release-4.4.1.rst index 12c0ee7798b..1272cd8fde1 100644 --- a/doc/en/announce/release-4.4.1.rst +++ b/doc/en/announce/release-4.4.1.rst @@ -7,7 +7,7 @@ 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/latest/changelog.html. +The full changelog is available at https://docs.pytest.org/en/stable/changelog.html. Thanks to all who contributed to this release, among them: diff --git a/doc/en/announce/release-4.4.2.rst b/doc/en/announce/release-4.4.2.rst index 4fe2dac56b3..5876e83b3b6 100644 --- a/doc/en/announce/release-4.4.2.rst +++ b/doc/en/announce/release-4.4.2.rst @@ -7,7 +7,7 @@ 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/latest/changelog.html. +The full changelog is available at https://docs.pytest.org/en/stable/changelog.html. Thanks to all who contributed to this release, among them: diff --git a/doc/en/announce/release-4.5.0.rst b/doc/en/announce/release-4.5.0.rst index 37c16cd7224..d2a05d4f795 100644 --- a/doc/en/announce/release-4.5.0.rst +++ b/doc/en/announce/release-4.5.0.rst @@ -3,17 +3,17 @@ pytest-4.5.0 The pytest team is proud to announce the 4.5.0 release! -pytest is a mature Python testing tool with more than a 2000 tests +pytest is a mature Python testing tool with more than 2000 tests against itself, passing on many different interpreters and platforms. This release contains a number of bugs fixes and improvements, so users are encouraged to take a look at the CHANGELOG: - https://docs.pytest.org/en/latest/changelog.html + https://docs.pytest.org/en/stable/changelog.html For complete documentation, please visit: - https://docs.pytest.org/en/latest/ + https://docs.pytest.org/en/stable/ As usual, you can upgrade from pypi via: diff --git a/doc/en/announce/release-4.6.0.rst b/doc/en/announce/release-4.6.0.rst index 373f5d66eb7..a82fdd47d6f 100644 --- a/doc/en/announce/release-4.6.0.rst +++ b/doc/en/announce/release-4.6.0.rst @@ -3,17 +3,17 @@ pytest-4.6.0 The pytest team is proud to announce the 4.6.0 release! -pytest is a mature Python testing tool with more than a 2000 tests +pytest is a mature Python testing tool with more than 2000 tests against itself, passing on many different interpreters and platforms. This release contains a number of bugs fixes and improvements, so users are encouraged to take a look at the CHANGELOG: - https://docs.pytest.org/en/latest/changelog.html + https://docs.pytest.org/en/stable/changelog.html For complete documentation, please visit: - https://docs.pytest.org/en/latest/ + https://docs.pytest.org/en/stable/ As usual, you can upgrade from pypi via: diff --git a/doc/en/announce/release-4.6.1.rst b/doc/en/announce/release-4.6.1.rst index 78d017544d2..c79839b7b52 100644 --- a/doc/en/announce/release-4.6.1.rst +++ b/doc/en/announce/release-4.6.1.rst @@ -7,7 +7,7 @@ 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/latest/changelog.html. +The full changelog is available at https://docs.pytest.org/en/stable/changelog.html. Thanks to all who contributed to this release, among them: diff --git a/doc/en/announce/release-4.6.2.rst b/doc/en/announce/release-4.6.2.rst index 8526579b9e7..cfc595293ae 100644 --- a/doc/en/announce/release-4.6.2.rst +++ b/doc/en/announce/release-4.6.2.rst @@ -7,7 +7,7 @@ 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/latest/changelog.html. +The full changelog is available at https://docs.pytest.org/en/stable/changelog.html. Thanks to all who contributed to this release, among them: diff --git a/doc/en/announce/release-4.6.3.rst b/doc/en/announce/release-4.6.3.rst index 0bfb355a15a..f578464a7a3 100644 --- a/doc/en/announce/release-4.6.3.rst +++ b/doc/en/announce/release-4.6.3.rst @@ -7,7 +7,7 @@ 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/latest/changelog.html. +The full changelog is available at https://docs.pytest.org/en/stable/changelog.html. Thanks to all who contributed to this release, among them: diff --git a/doc/en/announce/release-4.6.4.rst b/doc/en/announce/release-4.6.4.rst index 7b35ed4f0d4..0eefcbeb1c2 100644 --- a/doc/en/announce/release-4.6.4.rst +++ b/doc/en/announce/release-4.6.4.rst @@ -7,7 +7,7 @@ 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/latest/changelog.html. +The full changelog is available at https://docs.pytest.org/en/stable/changelog.html. Thanks to all who contributed to this release, among them: diff --git a/doc/en/announce/release-4.6.5.rst b/doc/en/announce/release-4.6.5.rst index 6998d4e4c5f..1ebf361fdf9 100644 --- a/doc/en/announce/release-4.6.5.rst +++ b/doc/en/announce/release-4.6.5.rst @@ -7,7 +7,7 @@ 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/latest/changelog.html. +The full changelog is available at https://docs.pytest.org/en/stable/changelog.html. Thanks to all who contributed to this release, among them: diff --git a/doc/en/announce/release-4.6.6.rst b/doc/en/announce/release-4.6.6.rst index c47a31695b2..b3bf1e431c7 100644 --- a/doc/en/announce/release-4.6.6.rst +++ b/doc/en/announce/release-4.6.6.rst @@ -7,7 +7,7 @@ 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/latest/changelog.html. +The full changelog is available at https://docs.pytest.org/en/stable/changelog.html. Thanks to all who contributed to this release, among them: diff --git a/doc/en/announce/release-4.6.7.rst b/doc/en/announce/release-4.6.7.rst index 0e6cf6a950a..f9d01845ec2 100644 --- a/doc/en/announce/release-4.6.7.rst +++ b/doc/en/announce/release-4.6.7.rst @@ -7,7 +7,7 @@ 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/latest/changelog.html. +The full changelog is available at https://docs.pytest.org/en/stable/changelog.html. Thanks to all who contributed to this release, among them: diff --git a/doc/en/announce/release-4.6.8.rst b/doc/en/announce/release-4.6.8.rst index 3c04e5dbe9b..5cabe7826e9 100644 --- a/doc/en/announce/release-4.6.8.rst +++ b/doc/en/announce/release-4.6.8.rst @@ -7,7 +7,7 @@ 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/latest/changelog.html. +The full changelog is available at https://docs.pytest.org/en/stable/changelog.html. Thanks to all who contributed to this release, among them: diff --git a/doc/en/announce/release-4.6.9.rst b/doc/en/announce/release-4.6.9.rst index ae0478c52d9..7f7bb5996ea 100644 --- a/doc/en/announce/release-4.6.9.rst +++ b/doc/en/announce/release-4.6.9.rst @@ -7,7 +7,7 @@ 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/latest/changelog.html. +The full changelog is available at https://docs.pytest.org/en/stable/changelog.html. Thanks to all who contributed to this release, among them: diff --git a/doc/en/announce/release-5.0.0.rst b/doc/en/announce/release-5.0.0.rst index ca516060215..f5e593e9d88 100644 --- a/doc/en/announce/release-5.0.0.rst +++ b/doc/en/announce/release-5.0.0.rst @@ -3,17 +3,17 @@ pytest-5.0.0 The pytest team is proud to announce the 5.0.0 release! -pytest is a mature Python testing tool with more than a 2000 tests +pytest is a mature Python testing tool with more than 2000 tests against itself, passing on many different interpreters and platforms. This release contains a number of bugs fixes and improvements, so users are encouraged to take a look at the CHANGELOG: - https://docs.pytest.org/en/latest/changelog.html + https://docs.pytest.org/en/stable/changelog.html For complete documentation, please visit: - https://docs.pytest.org/en/latest/ + https://docs.pytest.org/en/stable/ As usual, you can upgrade from pypi via: diff --git a/doc/en/announce/release-5.0.1.rst b/doc/en/announce/release-5.0.1.rst index 541aeb49109..e16a8f716f1 100644 --- a/doc/en/announce/release-5.0.1.rst +++ b/doc/en/announce/release-5.0.1.rst @@ -7,7 +7,7 @@ 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/latest/changelog.html. +The full changelog is available at https://docs.pytest.org/en/stable/changelog.html. Thanks to all who contributed to this release, among them: diff --git a/doc/en/announce/release-5.1.0.rst b/doc/en/announce/release-5.1.0.rst index 73e956d77e3..9ab54ff9730 100644 --- a/doc/en/announce/release-5.1.0.rst +++ b/doc/en/announce/release-5.1.0.rst @@ -3,17 +3,17 @@ pytest-5.1.0 The pytest team is proud to announce the 5.1.0 release! -pytest is a mature Python testing tool with more than a 2000 tests +pytest is a mature Python testing tool with more than 2000 tests against itself, passing on many different interpreters and platforms. This release contains a number of bugs fixes and improvements, so users are encouraged to take a look at the CHANGELOG: - https://docs.pytest.org/en/latest/changelog.html + https://docs.pytest.org/en/stable/changelog.html For complete documentation, please visit: - https://docs.pytest.org/en/latest/ + https://docs.pytest.org/en/stable/ As usual, you can upgrade from pypi via: diff --git a/doc/en/announce/release-5.1.1.rst b/doc/en/announce/release-5.1.1.rst index 9cb731ebb98..bb8de48014a 100644 --- a/doc/en/announce/release-5.1.1.rst +++ b/doc/en/announce/release-5.1.1.rst @@ -7,7 +7,7 @@ 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/latest/changelog.html. +The full changelog is available at https://docs.pytest.org/en/stable/changelog.html. Thanks to all who contributed to this release, among them: diff --git a/doc/en/announce/release-5.1.2.rst b/doc/en/announce/release-5.1.2.rst index ac6e005819b..c4cb8e3fb44 100644 --- a/doc/en/announce/release-5.1.2.rst +++ b/doc/en/announce/release-5.1.2.rst @@ -7,7 +7,7 @@ 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/latest/changelog.html. +The full changelog is available at https://docs.pytest.org/en/stable/changelog.html. Thanks to all who contributed to this release, among them: diff --git a/doc/en/announce/release-5.1.3.rst b/doc/en/announce/release-5.1.3.rst index 882b79bde2e..c4e88aed28e 100644 --- a/doc/en/announce/release-5.1.3.rst +++ b/doc/en/announce/release-5.1.3.rst @@ -7,7 +7,7 @@ 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/latest/changelog.html. +The full changelog is available at https://docs.pytest.org/en/stable/changelog.html. Thanks to all who contributed to this release, among them: diff --git a/doc/en/announce/release-5.2.0.rst b/doc/en/announce/release-5.2.0.rst index 8eae6dd734d..f43767b7506 100644 --- a/doc/en/announce/release-5.2.0.rst +++ b/doc/en/announce/release-5.2.0.rst @@ -3,17 +3,17 @@ pytest-5.2.0 The pytest team is proud to announce the 5.2.0 release! -pytest is a mature Python testing tool with more than a 2000 tests +pytest is a mature Python testing tool with more than 2000 tests against itself, passing on many different interpreters and platforms. This release contains a number of bugs fixes and improvements, so users are encouraged to take a look at the CHANGELOG: - https://docs.pytest.org/en/latest/changelog.html + https://docs.pytest.org/en/stable/changelog.html For complete documentation, please visit: - https://docs.pytest.org/en/latest/ + https://docs.pytest.org/en/stable/ As usual, you can upgrade from pypi via: diff --git a/doc/en/announce/release-5.2.1.rst b/doc/en/announce/release-5.2.1.rst index 312cfd778e6..fe42b9bf15f 100644 --- a/doc/en/announce/release-5.2.1.rst +++ b/doc/en/announce/release-5.2.1.rst @@ -7,7 +7,7 @@ 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/latest/changelog.html. +The full changelog is available at https://docs.pytest.org/en/stable/changelog.html. Thanks to all who contributed to this release, among them: diff --git a/doc/en/announce/release-5.2.2.rst b/doc/en/announce/release-5.2.2.rst index 8a2ced9eb6e..89fd6a534d4 100644 --- a/doc/en/announce/release-5.2.2.rst +++ b/doc/en/announce/release-5.2.2.rst @@ -7,7 +7,7 @@ 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/latest/changelog.html. +The full changelog is available at https://docs.pytest.org/en/stable/changelog.html. Thanks to all who contributed to this release, among them: diff --git a/doc/en/announce/release-5.2.3.rst b/doc/en/announce/release-5.2.3.rst index bfb62a1b8d9..bab174495d9 100644 --- a/doc/en/announce/release-5.2.3.rst +++ b/doc/en/announce/release-5.2.3.rst @@ -7,7 +7,7 @@ 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/latest/changelog.html. +The full changelog is available at https://docs.pytest.org/en/stable/changelog.html. Thanks to all who contributed to this release, among them: diff --git a/doc/en/announce/release-5.2.4.rst b/doc/en/announce/release-5.2.4.rst index 05677e77f55..5f518967975 100644 --- a/doc/en/announce/release-5.2.4.rst +++ b/doc/en/announce/release-5.2.4.rst @@ -7,7 +7,7 @@ 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/latest/changelog.html. +The full changelog is available at https://docs.pytest.org/en/stable/changelog.html. Thanks to all who contributed to this release, among them: diff --git a/doc/en/announce/release-5.3.0.rst b/doc/en/announce/release-5.3.0.rst index 9855a7a2d07..e13a71f09aa 100644 --- a/doc/en/announce/release-5.3.0.rst +++ b/doc/en/announce/release-5.3.0.rst @@ -3,17 +3,17 @@ pytest-5.3.0 The pytest team is proud to announce the 5.3.0 release! -pytest is a mature Python testing tool with more than a 2000 tests +pytest is a mature Python testing tool with more than 2000 tests against itself, passing on many different interpreters and platforms. This release contains a number of bugs fixes and improvements, so users are encouraged to take a look at the CHANGELOG: - https://docs.pytest.org/en/latest/changelog.html + https://docs.pytest.org/en/stable/changelog.html For complete documentation, please visit: - https://docs.pytest.org/en/latest/ + https://docs.pytest.org/en/stable/ As usual, you can upgrade from pypi via: diff --git a/doc/en/announce/release-5.3.1.rst b/doc/en/announce/release-5.3.1.rst index acf13bf6d8d..d575bb70e3f 100644 --- a/doc/en/announce/release-5.3.1.rst +++ b/doc/en/announce/release-5.3.1.rst @@ -7,7 +7,7 @@ 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/latest/changelog.html. +The full changelog is available at https://docs.pytest.org/en/stable/changelog.html. Thanks to all who contributed to this release, among them: diff --git a/doc/en/announce/release-5.3.2.rst b/doc/en/announce/release-5.3.2.rst index dbd657da3c3..d562a33fb0f 100644 --- a/doc/en/announce/release-5.3.2.rst +++ b/doc/en/announce/release-5.3.2.rst @@ -7,7 +7,7 @@ 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/latest/changelog.html. +The full changelog is available at https://docs.pytest.org/en/stable/changelog.html. Thanks to all who contributed to this release, among them: diff --git a/doc/en/announce/release-5.3.3.rst b/doc/en/announce/release-5.3.3.rst index 39820f3bcd0..40a6fb5b560 100644 --- a/doc/en/announce/release-5.3.3.rst +++ b/doc/en/announce/release-5.3.3.rst @@ -7,7 +7,7 @@ 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/latest/changelog.html. +The full changelog is available at https://docs.pytest.org/en/stable/changelog.html. Thanks to all who contributed to this release, among them: diff --git a/doc/en/announce/release-5.3.4.rst b/doc/en/announce/release-5.3.4.rst index 75bf4e6f34e..0750a9d404e 100644 --- a/doc/en/announce/release-5.3.4.rst +++ b/doc/en/announce/release-5.3.4.rst @@ -7,7 +7,7 @@ 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/latest/changelog.html. +The full changelog is available at https://docs.pytest.org/en/stable/changelog.html. Thanks to all who contributed to this release, among them: diff --git a/doc/en/announce/release-5.3.5.rst b/doc/en/announce/release-5.3.5.rst index 46095339f55..e632ce85388 100644 --- a/doc/en/announce/release-5.3.5.rst +++ b/doc/en/announce/release-5.3.5.rst @@ -7,7 +7,7 @@ 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/latest/changelog.html. +The full changelog is available at https://docs.pytest.org/en/stable/changelog.html. Thanks to all who contributed to this release, among them: diff --git a/doc/en/announce/release-5.4.0.rst b/doc/en/announce/release-5.4.0.rst index a89c3ca6bc7..43dffc9290e 100644 --- a/doc/en/announce/release-5.4.0.rst +++ b/doc/en/announce/release-5.4.0.rst @@ -3,17 +3,17 @@ pytest-5.4.0 The pytest team is proud to announce the 5.4.0 release! -pytest is a mature Python testing tool with more than a 2000 tests +pytest is a mature Python testing tool with more than 2000 tests against itself, passing on many different interpreters and platforms. This release contains a number of bug fixes and improvements, so users are encouraged to take a look at the CHANGELOG: - https://docs.pytest.org/en/latest/changelog.html + https://docs.pytest.org/en/stable/changelog.html For complete documentation, please visit: - https://docs.pytest.org/en/latest/ + https://docs.pytest.org/en/stable/ As usual, you can upgrade from PyPI via: diff --git a/doc/en/announce/release-5.4.1.rst b/doc/en/announce/release-5.4.1.rst new file mode 100644 index 00000000000..f6a64efa492 --- /dev/null +++ b/doc/en/announce/release-5.4.1.rst @@ -0,0 +1,18 @@ +pytest-5.4.1 +======================================= + +pytest 5.4.1 has just been released to PyPI. + +This is a bug-fix release, being a drop-in replacement. To upgrade:: + + pip install --upgrade pytest + +The full changelog is available at https://docs.pytest.org/en/stable/changelog.html. + +Thanks to all who contributed to this release, among them: + +* Bruno Oliveira + + +Happy testing, +The pytest Development Team diff --git a/doc/en/announce/release-5.4.2.rst b/doc/en/announce/release-5.4.2.rst new file mode 100644 index 00000000000..d742dd4aad4 --- /dev/null +++ b/doc/en/announce/release-5.4.2.rst @@ -0,0 +1,22 @@ +pytest-5.4.2 +======================================= + +pytest 5.4.2 has just been released to PyPI. + +This is a bug-fix release, being a drop-in replacement. To upgrade:: + + pip install --upgrade pytest + +The full changelog is available at https://docs.pytest.org/en/stable/changelog.html. + +Thanks to all who contributed to this release, among them: + +* Anthony Sottile +* Bruno Oliveira +* Daniel Hahler +* Ran Benita +* Ronny Pfannschmidt + + +Happy testing, +The pytest Development Team diff --git a/doc/en/announce/release-5.4.3.rst b/doc/en/announce/release-5.4.3.rst new file mode 100644 index 00000000000..6c995c16339 --- /dev/null +++ b/doc/en/announce/release-5.4.3.rst @@ -0,0 +1,21 @@ +pytest-5.4.3 +======================================= + +pytest 5.4.3 has just been released to PyPI. + +This is a bug-fix release, being a drop-in replacement. To upgrade:: + + pip install --upgrade pytest + +The full changelog is available at https://docs.pytest.org/en/stable/changelog.html. + +Thanks to all who contributed to this release, among them: + +* Anthony Sottile +* Bruno Oliveira +* Ran Benita +* Tor Colvin + + +Happy testing, +The pytest Development Team diff --git a/doc/en/announce/release-6.0.0.rst b/doc/en/announce/release-6.0.0.rst new file mode 100644 index 00000000000..9706fe59bc7 --- /dev/null +++ b/doc/en/announce/release-6.0.0.rst @@ -0,0 +1,40 @@ +pytest-6.0.0 +======================================= + +The pytest team is proud to announce the 6.0.0 release! + +pytest is a mature Python testing tool with more than 2000 tests +against itself, passing on many different interpreters and platforms. + +This release contains a number of bug fixes and improvements, so users are encouraged +to take a look at the CHANGELOG: + + https://docs.pytest.org/en/latest/changelog.html + +For complete documentation, please visit: + + https://docs.pytest.org/en/latest/ + +As usual, you can upgrade from PyPI via: + + pip install -U pytest + +Thanks to all who contributed to this release, among them: + +* Anthony Sottile +* Arvin Firouzi +* Bruno Oliveira +* Debi Mishra +* Garrett Thomas +* Hugo van Kemenade +* Kelton Bassingthwaite +* Kostis Anagnostopoulos +* Lewis Cowles +* Miro Hrončok +* Ran Benita +* Simon K +* Zac Hatfield-Dodds + + +Happy testing, +The pytest Development Team diff --git a/doc/en/announce/release-6.0.0rc1.rst b/doc/en/announce/release-6.0.0rc1.rst new file mode 100644 index 00000000000..5690b514baf --- /dev/null +++ b/doc/en/announce/release-6.0.0rc1.rst @@ -0,0 +1,67 @@ +pytest-6.0.0rc1 +======================================= + +pytest 6.0.0rc1 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/latest/changelog.html. + +Thanks to all who contributed to this release, among them: + +* Alfredo Deza +* Andreas Maier +* Andrew +* Anthony Sottile +* ArtyomKaltovich +* Bruno Oliveira +* Claire Cecil +* Curt J. Sampson +* Daniel +* Daniel Hahler +* Danny Sepler +* David Diaz Barquero +* Fabio Zadrozny +* Felix Nieuwenhuizen +* Florian Bruhin +* Florian Dahlitz +* Gleb Nikonorov +* Hugo van Kemenade +* Hunter Richards +* Katarzyna Król +* Katrin Leinweber +* Keri Volans +* Lewis Belcher +* Lukas Geiger +* Martin Michlmayr +* Mattwmaster58 +* Maximilian Cosmo Sitter +* Nikolay Kondratyev +* Pavel Karateev +* Paweł Wilczyński +* Prashant Anand +* Ram Rachum +* Ran Benita +* Ronny Pfannschmidt +* Ruaridh Williamson +* Simon K +* Tim Hoffmann +* Tor Colvin +* Vlad-Radz +* Xinbin Huang +* Zac Hatfield-Dodds +* earonesty +* gaurav dhameeja +* gdhameeja +* ibriquem +* mcsitter +* piotrhm +* smarie +* symonk +* xuiqzy + + +Happy testing, +The pytest Development Team diff --git a/doc/en/announce/release-6.0.1.rst b/doc/en/announce/release-6.0.1.rst new file mode 100644 index 00000000000..33fdbed3f61 --- /dev/null +++ b/doc/en/announce/release-6.0.1.rst @@ -0,0 +1,21 @@ +pytest-6.0.1 +======================================= + +pytest 6.0.1 has just been released to PyPI. + +This is a bug-fix release, being a drop-in replacement. To upgrade:: + + pip install --upgrade pytest + +The full changelog is available at https://docs.pytest.org/en/latest/changelog.html. + +Thanks to all who contributed to this release, among them: + +* Bruno Oliveira +* Mattreex +* Ran Benita +* hp310780 + + +Happy testing, +The pytest Development Team diff --git a/doc/en/announce/release-6.0.2.rst b/doc/en/announce/release-6.0.2.rst new file mode 100644 index 00000000000..16eabc5863d --- /dev/null +++ b/doc/en/announce/release-6.0.2.rst @@ -0,0 +1,19 @@ +pytest-6.0.2 +======================================= + +pytest 6.0.2 has just been released to PyPI. + +This is a bug-fix release, being a drop-in replacement. To upgrade:: + + pip install --upgrade pytest + +The full changelog is available at https://docs.pytest.org/en/stable/changelog.html. + +Thanks to all of the contributors to this release: + +* Bruno Oliveira +* Ran Benita + + +Happy testing, +The pytest Development Team diff --git a/doc/en/announce/release-6.1.0.rst b/doc/en/announce/release-6.1.0.rst new file mode 100644 index 00000000000..f4b571ae846 --- /dev/null +++ b/doc/en/announce/release-6.1.0.rst @@ -0,0 +1,44 @@ +pytest-6.1.0 +======================================= + +The pytest team is proud to announce the 6.1.0 release! + +This release contains new features, improvements, bug fixes, and breaking changes, so users +are encouraged to take a look at the CHANGELOG carefully: + + https://docs.pytest.org/en/stable/changelog.html + +For complete documentation, please visit: + + https://docs.pytest.org/en/stable/ + +As usual, you can upgrade from PyPI via: + + pip install -U pytest + +Thanks to all of the contributors to this release: + +* Anthony Sottile +* Bruno Oliveira +* C. Titus Brown +* Drew Devereux +* Faris A Chugthai +* Florian Bruhin +* Hugo van Kemenade +* Hynek Schlawack +* Joseph Lucas +* Kamran Ahmad +* Mattreex +* Maximilian Cosmo Sitter +* Ran Benita +* Rüdiger Busche +* Sam Estep +* Sorin Sbarnea +* Thomas Grainger +* Vipul Kumar +* Yutaro Ikeda +* hp310780 + + +Happy testing, +The pytest Development Team diff --git a/doc/en/announce/release-6.1.1.rst b/doc/en/announce/release-6.1.1.rst new file mode 100644 index 00000000000..e09408fdeea --- /dev/null +++ b/doc/en/announce/release-6.1.1.rst @@ -0,0 +1,18 @@ +pytest-6.1.1 +======================================= + +pytest 6.1.1 has just been released to PyPI. + +This is a bug-fix release, being a drop-in replacement. To upgrade:: + + pip install --upgrade pytest + +The full changelog is available at https://docs.pytest.org/en/stable/changelog.html. + +Thanks to all of the contributors to this release: + +* Ran Benita + + +Happy testing, +The pytest Development Team diff --git a/doc/en/announce/release-6.1.2.rst b/doc/en/announce/release-6.1.2.rst new file mode 100644 index 00000000000..aa2c8095205 --- /dev/null +++ b/doc/en/announce/release-6.1.2.rst @@ -0,0 +1,22 @@ +pytest-6.1.2 +======================================= + +pytest 6.1.2 has just been released to PyPI. + +This is a bug-fix release, being a drop-in replacement. To upgrade:: + + pip install --upgrade pytest + +The full changelog is available at https://docs.pytest.org/en/stable/changelog.html. + +Thanks to all of the contributors to this release: + +* Bruno Oliveira +* Manuel Mariñez +* Ran Benita +* Vasilis Gerakaris +* William Jamir Silva + + +Happy testing, +The pytest Development Team diff --git a/doc/en/announce/release-6.2.0.rst b/doc/en/announce/release-6.2.0.rst new file mode 100644 index 00000000000..af16b830ddd --- /dev/null +++ b/doc/en/announce/release-6.2.0.rst @@ -0,0 +1,76 @@ +pytest-6.2.0 +======================================= + +The pytest team is proud to announce the 6.2.0 release! + +This release contains new features, improvements, bug fixes, and breaking changes, so users +are encouraged to take a look at the CHANGELOG carefully: + + https://docs.pytest.org/en/stable/changelog.html + +For complete documentation, please visit: + + https://docs.pytest.org/en/stable/ + +As usual, you can upgrade from PyPI via: + + pip install -U pytest + +Thanks to all of the contributors to this release: + +* Adam Johnson +* Albert Villanova del Moral +* Anthony Sottile +* Anton +* Ariel Pillemer +* Bruno Oliveira +* Charles Aracil +* Christine M +* Christine Mecklenborg +* Cserna Zsolt +* Dominic Mortlock +* Emiel van de Laar +* Florian Bruhin +* Garvit Shubham +* Gustavo Camargo +* Hugo Martins +* Hugo van Kemenade +* Jakob van Santen +* Josias Aurel +* Jürgen Gmach +* Karthikeyan Singaravelan +* Katarzyna +* Kyle Altendorf +* Manuel Mariñez +* Matthew Hughes +* Matthias Gabriel +* Max Voitko +* Maximilian Cosmo Sitter +* Mikhail Fesenko +* Nimesh Vashistha +* Pedro Algarvio +* Petter Strandmark +* Prakhar Gurunani +* Prashant Sharma +* Ran Benita +* Ronny Pfannschmidt +* Sanket Duthade +* Shubham Adep +* Simon K +* Tanvi Mehta +* Thomas Grainger +* Tim Hoffmann +* Vasilis Gerakaris +* William Jamir Silva +* Zac Hatfield-Dodds +* crricks +* dependabot[bot] +* duthades +* frankgerhardt +* kwgchi +* mickeypash +* symonk + + +Happy testing, +The pytest Development Team diff --git a/doc/en/announce/release-6.2.1.rst b/doc/en/announce/release-6.2.1.rst new file mode 100644 index 00000000000..f9e71618351 --- /dev/null +++ b/doc/en/announce/release-6.2.1.rst @@ -0,0 +1,20 @@ +pytest-6.2.1 +======================================= + +pytest 6.2.1 has just been released to PyPI. + +This is a bug-fix release, being a drop-in replacement. To upgrade:: + + pip install --upgrade pytest + +The full changelog is available at https://docs.pytest.org/en/stable/changelog.html. + +Thanks to all of the contributors to this release: + +* Bruno Oliveira +* Jakob van Santen +* Ran Benita + + +Happy testing, +The pytest Development Team diff --git a/doc/en/assert.rst b/doc/en/assert.rst index d7c380c60a1..b83e30e76db 100644 --- a/doc/en/assert.rst +++ b/doc/en/assert.rst @@ -31,7 +31,7 @@ you will see the return value of the function call: $ pytest test_assert1.py =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-5.x.y, py-1.x.y, pluggy-0.x.y + platform linux -- Python 3.x.y, pytest-6.x.y, py-1.x.y, pluggy-0.x.y cachedir: $PYTHON_PREFIX/.pytest_cache rootdir: $REGENDOC_TMPDIR collected 1 item @@ -74,7 +74,7 @@ Assertions about expected exceptions ------------------------------------------ In order to write assertions about raised exceptions, you can use -``pytest.raises`` as a context manager like this: +:func:`pytest.raises` as a context manager like this: .. code-block:: python @@ -98,7 +98,7 @@ and if you need to have access to the actual exception info you may use: f() assert "maximum recursion" in str(excinfo.value) -``excinfo`` is a ``ExceptionInfo`` instance, which is a wrapper around +``excinfo`` is an ``ExceptionInfo`` instance, which is a wrapper around the actual exception raised. The main attributes of interest are ``.type``, ``.value`` and ``.traceback``. @@ -123,7 +123,7 @@ The regexp parameter of the ``match`` method is matched with the ``re.search`` function, so in the above example ``match='123'`` would have worked as well. -There's an alternate form of the ``pytest.raises`` function where you pass +There's an alternate form of the :func:`pytest.raises` function where you pass a function that will be executed with the given ``*args`` and ``**kwargs`` and assert that the given exception is raised: @@ -144,8 +144,8 @@ specific way than just having any exception raised: def test_f(): f() -Using ``pytest.raises`` is likely to be better for cases where you are testing -exceptions your own code is deliberately raising, whereas using +Using :func:`pytest.raises` is likely to be better for cases where you are +testing exceptions your own code is deliberately raising, whereas using ``@pytest.mark.xfail`` with a check function is probably better for something like documenting unfixed bugs (where the test describes what "should" happen) or bugs in dependencies. @@ -188,7 +188,7 @@ if you run this module: $ pytest test_assert2.py =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-5.x.y, py-1.x.y, pluggy-0.x.y + platform linux -- Python 3.x.y, pytest-6.x.y, py-1.x.y, pluggy-0.x.y cachedir: $PYTHON_PREFIX/.pytest_cache rootdir: $REGENDOC_TMPDIR collected 1 item @@ -294,8 +294,6 @@ Assertion introspection details ------------------------------- - - Reporting details about a failing assertion is achieved by rewriting assert statements before they are run. Rewritten assert statements put introspection information into the assertion failure message. ``pytest`` only rewrites test @@ -303,7 +301,7 @@ modules directly discovered by its test collection process, so **asserts in supporting modules which are not themselves test modules will not be rewritten**. You can manually enable assertion rewriting for an imported module by calling -`register_assert_rewrite `_ +`register_assert_rewrite `_ before you import it (a good place to do that is in your root ``conftest.py``). For further information, Benjamin Peterson wrote up `Behind the scenes of pytest's new assertion rewriting `_. @@ -342,15 +340,3 @@ If this is the case you have two options: ``PYTEST_DONT_REWRITE`` to its docstring. * Disable rewriting for all modules by using ``--assert=plain``. - - - - Add assert rewriting as an alternate introspection technique. - - - Introduce the ``--assert`` option. Deprecate ``--no-assert`` and - ``--nomagic``. - - - Removes the ``--no-assert`` and ``--nomagic`` options. - Removes the ``--assert=reinterp`` option. diff --git a/doc/en/backwards-compatibility.rst b/doc/en/backwards-compatibility.rst index d5b2d79d633..7b3027e790a 100644 --- a/doc/en/backwards-compatibility.rst +++ b/doc/en/backwards-compatibility.rst @@ -10,7 +10,7 @@ we keep learning about new and better structures to express different details ab While we implement those modifications we try to ensure an easy transition and don't want to impose unnecessary churn on our users and community/plugin authors. -As of now, pytest considers multipe types of backward compatibility transitions: +As of now, pytest considers multiple types of backward compatibility transitions: a) trivial: APIs which trivially translate to the new mechanism, and do not cause problematic changes. @@ -25,7 +25,7 @@ b) transitional: the old and new API don't conflict When the deprecation expires (e.g. 4.0 is released), we won't remove the deprecated functionality immediately, but will use the standard warning filters to turn them into **errors** by default. This approach makes it explicit that removal is imminent, and still gives you time to turn the deprecated feature into a warning instead of an error so it can be dealt with in your own time. In the next minor release (e.g. 4.1), the feature will be effectively removed. -c) true breakage: should only to be considered when normal transition is unreasonably unsustainable and would offset important development/features by years. +c) true breakage: should only be considered when normal transition is unreasonably unsustainable and would offset important development/features by years. In addition, they should be limited to APIs where the number of actual users is very small (for example only impacting some plugins), and can be coordinated with the community in advance. Examples for such upcoming changes: @@ -42,7 +42,7 @@ c) true breakage: should only to be considered when normal transition is unreaso After there's no hard *-1* on the issue it should be followed up by an initial proof-of-concept Pull Request. - This POC serves as both a coordination point to assess impact and potential inspriation to come up with a transitional solution after all. + This POC serves as both a coordination point to assess impact and potential inspiration to come up with a transitional solution after all. After a reasonable amount of time the PR can be merged to base a new major release. diff --git a/doc/en/builtin.rst b/doc/en/builtin.rst index 7864233fc81..5c7c8dfe666 100644 --- a/doc/en/builtin.rst +++ b/doc/en/builtin.rst @@ -23,7 +23,7 @@ For information about fixtures, see :ref:`fixtures`. To see a complete list of a cache.get(key, default) cache.set(key, value) - Keys must be a ``/`` separated value, where the first part is usually the + Keys must be ``/`` separated strings, where the first part is usually the name of your plugin or application to avoid clashes with other cache users. Values can be any object handled by the json stdlib module. @@ -57,7 +57,8 @@ For information about fixtures, see :ref:`fixtures`. To see a complete list of a ``out`` and ``err`` will be ``byte`` objects. doctest_namespace [session scope] - Fixture that returns a :py:class:`dict` that will be injected into the namespace of doctests. + Fixture that returns a :py:class:`dict` that will be injected into the + namespace of doctests. pytestconfig [session scope] Session-scoped fixture that returns the :class:`_pytest.config.Config` object. @@ -69,11 +70,13 @@ For information about fixtures, see :ref:`fixtures`. To see a complete list of a ... record_property - Add an extra properties the calling test. + Add extra properties to the calling test. + User properties become part of the test report and are available to the configured reporters, like JUnit XML. - The fixture is callable with ``(name, value)``, with value being automatically - xml-encoded. + + The fixture is callable with ``name, value``. The value is automatically + XML-encoded. Example:: @@ -82,12 +85,15 @@ For information about fixtures, see :ref:`fixtures`. To see a complete list of a record_xml_attribute Add extra xml attributes to the tag for the calling test. - The fixture is callable with ``(name, value)``, with value being - automatically xml-encoded + + The fixture is callable with ``name, value``. The value is + automatically XML-encoded. record_testsuite_property [session scope] - Records a new ```` tag as child of the root ````. This is suitable to - writing global information regarding the entire test suite, and is compatible with ``xunit2`` JUnit family. + Record a new ```` tag as child of the root ````. + + This is suitable to writing global information regarding the entire test + suite, and is compatible with ``xunit2`` JUnit family. This is a ``session``-scoped fixture which is called with ``(name, value)``. Example: @@ -99,6 +105,12 @@ For information about fixtures, see :ref:`fixtures`. To see a complete list of a ``name`` must be a string, ``value`` will be converted to a string and properly xml-escaped. + .. warning:: + + Currently this fixture **does not work** with the + `pytest-xdist `__ plugin. See issue + `#7767 `__ for details. + caplog Access and control log capturing. @@ -111,8 +123,10 @@ For information about fixtures, see :ref:`fixtures`. To see a complete list of a * caplog.clear() -> clear captured records and formatted log output string monkeypatch - The returned ``monkeypatch`` fixture provides these - helper methods to modify objects, dictionaries or os.environ:: + A convenient fixture for monkey-patching. + + The fixture provides these methods to modify objects, dictionaries or + os.environ:: monkeypatch.setattr(obj, name, value, raising=True) monkeypatch.delattr(obj, name, raising=True) @@ -123,10 +137,9 @@ For information about fixtures, see :ref:`fixtures`. To see a complete list of a monkeypatch.syspath_prepend(path) monkeypatch.chdir(path) - All modifications will be undone after the requesting - test function or fixture has finished. The ``raising`` - parameter determines if a KeyError or AttributeError - will be raised if the set/deletion operation has no target. + All modifications will be undone after the requesting test function or + fixture has finished. The ``raising`` parameter determines if a KeyError + or AttributeError will be raised if the set/deletion operation has no target. recwarn Return a :class:`WarningsRecorder` instance that records all warnings emitted by test functions. @@ -137,30 +150,34 @@ For information about fixtures, see :ref:`fixtures`. To see a complete list of a tmpdir_factory [session scope] Return a :class:`_pytest.tmpdir.TempdirFactory` instance for the test session. - tmp_path_factory [session scope] Return a :class:`_pytest.tmpdir.TempPathFactory` instance for the test session. - tmpdir - Return a temporary directory path object - which is unique to each test function invocation, - created as a sub directory of the base temporary - directory. The returned object is a `py.path.local`_ - path object. + Return a temporary directory path object which is unique to each test + function invocation, created as a sub directory of the base temporary + directory. + + By default, a new base temporary directory is created each test session, + and old bases are removed after 3 sessions, to aid in debugging. If + ``--basetemp`` is used then it is cleared each session. See :ref:`base + temporary directory`. + + The returned object is a `py.path.local`_ path object. .. _`py.path.local`: https://py.readthedocs.io/en/latest/path.html tmp_path - Return a temporary directory path object - which is unique to each test function invocation, - created as a sub directory of the base temporary - directory. The returned object is a :class:`pathlib.Path` - object. + Return a temporary directory path object which is unique to each test + function invocation, created as a sub directory of the base temporary + directory. - .. note:: + By default, a new base temporary directory is created each test session, + and old bases are removed after 3 sessions, to aid in debugging. If + ``--basetemp`` is used then it is cleared each session. See :ref:`base + temporary directory`. - in python < 3.6 this is a pathlib2.Path + The returned object is a :class:`pathlib.Path` object. no tests ran in 0.12s diff --git a/doc/en/cache.rst b/doc/en/cache.rst index b01182d9885..42ca473545d 100644 --- a/doc/en/cache.rst +++ b/doc/en/cache.rst @@ -86,7 +86,7 @@ If you then run it with ``--lf``: $ pytest --lf =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-5.x.y, py-1.x.y, pluggy-0.x.y + platform linux -- Python 3.x.y, pytest-6.x.y, py-1.x.y, pluggy-0.x.y cachedir: $PYTHON_PREFIX/.pytest_cache rootdir: $REGENDOC_TMPDIR collected 2 items @@ -133,7 +133,7 @@ of ``FF`` and dots): $ pytest --ff =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-5.x.y, py-1.x.y, pluggy-0.x.y + platform linux -- Python 3.x.y, pytest-6.x.y, py-1.x.y, pluggy-0.x.y cachedir: $PYTHON_PREFIX/.pytest_cache rootdir: $REGENDOC_TMPDIR collected 50 items @@ -194,7 +194,7 @@ The new config.cache object Plugins or conftest.py support code can get a cached value using the pytest ``config`` object. Here is a basic example plugin which -implements a :ref:`fixture` which re-uses previously created state +implements a :ref:`fixture ` which re-uses previously created state across pytest invocations: .. code-block:: python @@ -264,7 +264,7 @@ the cache and nothing will be printed: FAILED test_caching.py::test_function - assert 42 == 23 1 failed in 0.12s -See the :fixture:`config.cache fixture ` for more details. +See the :fixture:`config.cache fixture ` for more details. Inspecting Cache content @@ -277,7 +277,7 @@ You can always peek at the content of the cache using the $ pytest --cache-show =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-5.x.y, py-1.x.y, pluggy-0.x.y + platform linux -- Python 3.x.y, pytest-6.x.y, py-1.x.y, pluggy-0.x.y cachedir: $PYTHON_PREFIX/.pytest_cache rootdir: $REGENDOC_TMPDIR cachedir: $PYTHON_PREFIX/.pytest_cache @@ -290,19 +290,7 @@ You can always peek at the content of the cache using the 'test_caching.py::test_function': True, 'test_foocompare.py::test_compare': True} cache/nodeids contains: - ['test_assert1.py::test_function', - 'test_assert2.py::test_set_comparison', - 'test_foocompare.py::test_compare', - 'test_50.py::test_num[0]', - 'test_50.py::test_num[1]', - 'test_50.py::test_num[2]', - 'test_50.py::test_num[3]', - 'test_50.py::test_num[4]', - 'test_50.py::test_num[5]', - 'test_50.py::test_num[6]', - 'test_50.py::test_num[7]', - 'test_50.py::test_num[8]', - 'test_50.py::test_num[9]', + ['test_50.py::test_num[0]', 'test_50.py::test_num[10]', 'test_50.py::test_num[11]', 'test_50.py::test_num[12]', @@ -313,6 +301,7 @@ You can always peek at the content of the cache using the 'test_50.py::test_num[17]', 'test_50.py::test_num[18]', 'test_50.py::test_num[19]', + 'test_50.py::test_num[1]', 'test_50.py::test_num[20]', 'test_50.py::test_num[21]', 'test_50.py::test_num[22]', @@ -323,6 +312,7 @@ You can always peek at the content of the cache using the 'test_50.py::test_num[27]', 'test_50.py::test_num[28]', 'test_50.py::test_num[29]', + 'test_50.py::test_num[2]', 'test_50.py::test_num[30]', 'test_50.py::test_num[31]', 'test_50.py::test_num[32]', @@ -333,6 +323,7 @@ You can always peek at the content of the cache using the 'test_50.py::test_num[37]', 'test_50.py::test_num[38]', 'test_50.py::test_num[39]', + 'test_50.py::test_num[3]', 'test_50.py::test_num[40]', 'test_50.py::test_num[41]', 'test_50.py::test_num[42]', @@ -343,7 +334,16 @@ You can always peek at the content of the cache using the 'test_50.py::test_num[47]', 'test_50.py::test_num[48]', 'test_50.py::test_num[49]', - 'test_caching.py::test_function'] + 'test_50.py::test_num[4]', + 'test_50.py::test_num[5]', + 'test_50.py::test_num[6]', + 'test_50.py::test_num[7]', + 'test_50.py::test_num[8]', + 'test_50.py::test_num[9]', + 'test_assert1.py::test_function', + 'test_assert2.py::test_set_comparison', + 'test_caching.py::test_function', + 'test_foocompare.py::test_compare'] cache/stepwise contains: [] example/value contains: @@ -358,7 +358,7 @@ filtering: $ pytest --cache-show example/* =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-5.x.y, py-1.x.y, pluggy-0.x.y + platform linux -- Python 3.x.y, pytest-6.x.y, py-1.x.y, pluggy-0.x.y cachedir: $PYTHON_PREFIX/.pytest_cache rootdir: $REGENDOC_TMPDIR cachedir: $PYTHON_PREFIX/.pytest_cache diff --git a/doc/en/capture.rst b/doc/en/capture.rst index 7c8c25cc5c9..caaebdf81a8 100644 --- a/doc/en/capture.rst +++ b/doc/en/capture.rst @@ -83,7 +83,7 @@ of the failing function and hide the other one: $ pytest =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-5.x.y, py-1.x.y, pluggy-0.x.y + platform linux -- Python 3.x.y, pytest-6.x.y, py-1.x.y, pluggy-0.x.y cachedir: $PYTHON_PREFIX/.pytest_cache rootdir: $REGENDOC_TMPDIR collected 2 items @@ -145,8 +145,7 @@ The return value from ``readouterr`` changed to a ``namedtuple`` with two attrib If the code under test writes non-textual data, you can capture this using the ``capsysbinary`` fixture which instead returns ``bytes`` from -the ``readouterr`` method. The ``capfsysbinary`` fixture is currently only -available in python 3. +the ``readouterr`` method. diff --git a/doc/en/changelog.rst b/doc/en/changelog.rst index 8f6d140c1be..6d66ad1d8dc 100644 --- a/doc/en/changelog.rst +++ b/doc/en/changelog.rst @@ -28,6 +28,1017 @@ with advance notice in the **Deprecations** section of releases. .. towncrier release notes start +pytest 6.2.1 (2020-12-15) +========================= + +Bug Fixes +--------- + +- `#7678 `_: Fixed bug where ``ImportPathMismatchError`` would be raised for files compiled in + the host and loaded later from an UNC mounted path (Windows). + + +- `#8132 `_: Fixed regression in ``approx``: in 6.2.0 ``approx`` no longer raises + ``TypeError`` when dealing with non-numeric types, falling back to normal comparison. + Before 6.2.0, array types like tf.DeviceArray fell through to the scalar case, + and happened to compare correctly to a scalar if they had only one element. + After 6.2.0, these types began failing, because they inherited neither from + standard Python number hierarchy nor from ``numpy.ndarray``. + + ``approx`` now converts arguments to ``numpy.ndarray`` if they expose the array + protocol and are not scalars. This treats array-like objects like numpy arrays, + regardless of size. + + +pytest 6.2.0 (2020-12-12) +========================= + +Breaking Changes +---------------- + +- `#7808 `_: pytest now supports python3.6+ only. + + + +Deprecations +------------ + +- `#7469 `_: Directly constructing/calling the following classes/functions is now deprecated: + + - ``_pytest.cacheprovider.Cache`` + - ``_pytest.cacheprovider.Cache.for_config()`` + - ``_pytest.cacheprovider.Cache.clear_cache()`` + - ``_pytest.cacheprovider.Cache.cache_dir_from_config()`` + - ``_pytest.capture.CaptureFixture`` + - ``_pytest.fixtures.FixtureRequest`` + - ``_pytest.fixtures.SubRequest`` + - ``_pytest.logging.LogCaptureFixture`` + - ``_pytest.pytester.Pytester`` + - ``_pytest.pytester.Testdir`` + - ``_pytest.recwarn.WarningsRecorder`` + - ``_pytest.recwarn.WarningsChecker`` + - ``_pytest.tmpdir.TempPathFactory`` + - ``_pytest.tmpdir.TempdirFactory`` + + These have always been considered private, but now issue a deprecation warning, which may become a hard error in pytest 7.0.0. + + +- `#7530 `_: The ``--strict`` command-line option has been deprecated, use ``--strict-markers`` instead. + + We have plans to maybe in the future to reintroduce ``--strict`` and make it an encompassing flag for all strictness + related options (``--strict-markers`` and ``--strict-config`` at the moment, more might be introduced in the future). + + +- `#7988 `_: The ``@pytest.yield_fixture`` decorator/function is now deprecated. Use :func:`pytest.fixture` instead. + + ``yield_fixture`` has been an alias for ``fixture`` for a very long time, so can be search/replaced safely. + + + +Features +-------- + +- `#5299 `_: pytest now warns about unraisable exceptions and unhandled thread exceptions that occur in tests on Python>=3.8. + See :ref:`unraisable` for more information. + + +- `#7425 `_: New :fixture:`pytester` fixture, which is identical to :fixture:`testdir` but its methods return :class:`pathlib.Path` when appropriate instead of ``py.path.local``. + + This is part of the movement to use :class:`pathlib.Path` objects internally, in order to remove the dependency to ``py`` in the future. + + Internally, the old :class:`Testdir <_pytest.pytester.Testdir>` is now a thin wrapper around :class:`Pytester <_pytest.pytester.Pytester>`, preserving the old interface. + + +- `#7695 `_: A new hook was added, `pytest_markeval_namespace` which should return a dictionary. + This dictionary will be used to augment the "global" variables available to evaluate skipif/xfail/xpass markers. + + Pseudo example + + ``conftest.py``: + + .. code-block:: python + + def pytest_markeval_namespace(): + return {"color": "red"} + + ``test_func.py``: + + .. code-block:: python + + @pytest.mark.skipif("color == 'blue'", reason="Color is not red") + def test_func(): + assert False + + +- `#8006 `_: It is now possible to construct a :class:`~pytest.MonkeyPatch` object directly as ``pytest.MonkeyPatch()``, + in cases when the :fixture:`monkeypatch` fixture cannot be used. Previously some users imported it + from the private `_pytest.monkeypatch.MonkeyPatch` namespace. + + Additionally, :meth:`MonkeyPatch.context ` is now a classmethod, + and can be used as ``with MonkeyPatch.context() as mp: ...``. This is the recommended way to use + ``MonkeyPatch`` directly, since unlike the ``monkeypatch`` fixture, an instance created directly + is not ``undo()``-ed automatically. + + + +Improvements +------------ + +- `#1265 `_: Added an ``__str__`` implementation to the :class:`~pytest.pytester.LineMatcher` class which is returned from ``pytester.run_pytest().stdout`` and similar. It returns the entire output, like the existing ``str()`` method. + + +- `#2044 `_: Verbose mode now shows the reason that a test was skipped in the test's terminal line after the "SKIPPED", "XFAIL" or "XPASS". + + +- `#7469 `_ The types of builtin pytest fixtures are now exported so they may be used in type annotations of test functions. + The newly-exported types are: + + - ``pytest.FixtureRequest`` for the :fixture:`request` fixture. + - ``pytest.Cache`` for the :fixture:`cache` fixture. + - ``pytest.CaptureFixture[str]`` for the :fixture:`capfd` and :fixture:`capsys` fixtures. + - ``pytest.CaptureFixture[bytes]`` for the :fixture:`capfdbinary` and :fixture:`capsysbinary` fixtures. + - ``pytest.LogCaptureFixture`` for the :fixture:`caplog` fixture. + - ``pytest.Pytester`` for the :fixture:`pytester` fixture. + - ``pytest.Testdir`` for the :fixture:`testdir` fixture. + - ``pytest.TempdirFactory`` for the :fixture:`tmpdir_factory` fixture. + - ``pytest.TempPathFactory`` for the :fixture:`tmp_path_factory` fixture. + - ``pytest.MonkeyPatch`` for the :fixture:`monkeypatch` fixture. + - ``pytest.WarningsRecorder`` for the :fixture:`recwarn` fixture. + + Constructing them is not supported (except for `MonkeyPatch`); they are only meant for use in type annotations. + Doing so will emit a deprecation warning, and may become a hard-error in pytest 7.0. + + Subclassing them is also not supported. This is not currently enforced at runtime, but is detected by type-checkers such as mypy. + + +- `#7527 `_: When a comparison between :func:`namedtuple ` instances of the same type fails, pytest now shows the differing field names (possibly nested) instead of their indexes. + + +- `#7615 `_: :meth:`Node.warn <_pytest.nodes.Node.warn>` now permits any subclass of :class:`Warning`, not just :class:`PytestWarning `. + + +- `#7701 `_: Improved reporting when using ``--collected-only``. It will now show the number of collected tests in the summary stats. + + +- `#7710 `_: Use strict equality comparison for non-numeric types in :func:`pytest.approx` instead of + raising :class:`TypeError`. + + This was the undocumented behavior before 3.7, but is now officially a supported feature. + + +- `#7938 `_: New ``--sw-skip`` argument which is a shorthand for ``--stepwise-skip``. + + +- `#8023 `_: Added ``'node_modules'`` to default value for :confval:`norecursedirs`. + + +- `#8032 `_: :meth:`doClassCleanups ` (introduced in :mod:`unittest` in Python and 3.8) is now called appropriately. + + + +Bug Fixes +--------- + +- `#4824 `_: Fixed quadratic behavior and improved performance of collection of items using autouse fixtures and xunit fixtures. + + +- `#7758 `_: Fixed an issue where some files in packages are getting lost from ``--lf`` even though they contain tests that failed. Regressed in pytest 5.4.0. + + +- `#7911 `_: Directories created by by :fixture:`tmp_path` and :fixture:`tmpdir` are now considered stale after 3 days without modification (previous value was 3 hours) to avoid deleting directories still in use in long running test suites. + + +- `#7913 `_: Fixed a crash or hang in :meth:`pytester.spawn <_pytest.pytester.Pytester.spawn>` when the :mod:`readline` module is involved. + + +- `#7951 `_: Fixed handling of recursive symlinks when collecting tests. + + +- `#7981 `_: Fixed symlinked directories not being followed during collection. Regressed in pytest 6.1.0. + + +- `#8016 `_: Fixed only one doctest being collected when using ``pytest --doctest-modules path/to/an/__init__.py``. + + + +Improved Documentation +---------------------- + +- `#7429 `_: Add more information and use cases about skipping doctests. + + +- `#7780 `_: Classes which should not be inherited from are now marked ``final class`` in the API reference. + + +- `#7872 `_: ``_pytest.config.argparsing.Parser.addini()`` accepts explicit ``None`` and ``"string"``. + + +- `#7878 `_: In pull request section, ask to commit after editing changelog and authors file. + + + +Trivial/Internal Changes +------------------------ + +- `#7802 `_: The ``attrs`` dependency requirement is now >=19.2.0 instead of >=17.4.0. + + +- `#8014 `_: `.pyc` files created by pytest's assertion rewriting now conform to the newer PEP-552 format on Python>=3.7. + (These files are internal and only interpreted by pytest itself.) + + +pytest 6.1.2 (2020-10-28) +========================= + +Bug Fixes +--------- + +- `#7758 `_: Fixed an issue where some files in packages are getting lost from ``--lf`` even though they contain tests that failed. Regressed in pytest 5.4.0. + + +- `#7911 `_: Directories created by `tmpdir` are now considered stale after 3 days without modification (previous value was 3 hours) to avoid deleting directories still in use in long running test suites. + + + +Improved Documentation +---------------------- + +- `#7815 `_: Improve deprecation warning message for ``pytest._fillfuncargs()``. + + +pytest 6.1.1 (2020-10-03) +========================= + +Bug Fixes +--------- + +- `#7807 `_: Fixed regression in pytest 6.1.0 causing incorrect rootdir to be determined in some non-trivial cases where parent directories have config files as well. + + +- `#7814 `_: Fixed crash in header reporting when :confval:`testpaths` is used and contains absolute paths (regression in 6.1.0). + + +pytest 6.1.0 (2020-09-26) +========================= + +Breaking Changes +---------------- + +- `#5585 `_: As per our policy, the following features which have been deprecated in the 5.X series are now + removed: + + * The ``funcargnames`` read-only property of ``FixtureRequest``, ``Metafunc``, and ``Function`` classes. Use ``fixturenames`` attribute. + + * ``@pytest.fixture`` no longer supports positional arguments, pass all arguments by keyword instead. + + * Direct construction of ``Node`` subclasses now raise an error, use ``from_parent`` instead. + + * The default value for ``junit_family`` has changed to ``xunit2``. If you require the old format, add ``junit_family=xunit1`` to your configuration file. + + * The ``TerminalReporter`` no longer has a ``writer`` attribute. Plugin authors may use the public functions of the ``TerminalReporter`` instead of accessing the ``TerminalWriter`` object directly. + + * The ``--result-log`` option has been removed. Users are recommended to use the `pytest-reportlog `__ plugin instead. + + + For more information consult + `Deprecations and Removals `__ in the docs. + + + +Deprecations +------------ + +- `#6981 `_: The ``pytest.collect`` module is deprecated: all its names can be imported from ``pytest`` directly. + + +- `#7097 `_: The ``pytest._fillfuncargs`` function is deprecated. This function was kept + for backward compatibility with an older plugin. + + It's functionality is not meant to be used directly, but if you must replace + it, use `function._request._fillfixtures()` instead, though note this is not + a public API and may break in the future. + + +- `#7210 `_: The special ``-k '-expr'`` syntax to ``-k`` is deprecated. Use ``-k 'not expr'`` + instead. + + The special ``-k 'expr:'`` syntax to ``-k`` is deprecated. Please open an issue + if you use this and want a replacement. + + +- `#7255 `_: The :func:`pytest_warning_captured <_pytest.hookspec.pytest_warning_captured>` hook is deprecated in favor + of :func:`pytest_warning_recorded <_pytest.hookspec.pytest_warning_recorded>`, and will be removed in a future version. + + +- `#7648 `_: The ``gethookproxy()`` and ``isinitpath()`` methods of ``FSCollector`` and ``Package`` are deprecated; + use ``self.session.gethookproxy()`` and ``self.session.isinitpath()`` instead. + This should work on all pytest versions. + + + +Features +-------- + +- `#7667 `_: New ``--durations-min`` command-line flag controls the minimal duration for inclusion in the slowest list of tests shown by ``--durations``. Previously this was hard-coded to ``0.005s``. + + + +Improvements +------------ + +- `#6681 `_: Internal pytest warnings issued during the early stages of initialization are now properly handled and can filtered through :confval:`filterwarnings` or ``--pythonwarnings/-W``. + + This also fixes a number of long standing issues: `#2891 `__, `#7620 `__, `#7426 `__. + + +- `#7572 `_: When a plugin listed in ``required_plugins`` is missing or an unknown config key is used with ``--strict-config``, a simple error message is now shown instead of a stacktrace. + + +- `#7685 `_: Added two new attributes :attr:`rootpath <_pytest.config.Config.rootpath>` and :attr:`inipath <_pytest.config.Config.inipath>` to :class:`Config <_pytest.config.Config>`. + These attributes are :class:`pathlib.Path` versions of the existing :attr:`rootdir <_pytest.config.Config.rootdir>` and :attr:`inifile <_pytest.config.Config.inifile>` attributes, + and should be preferred over them when possible. + + +- `#7780 `_: Public classes which are not designed to be inherited from are now marked `@final `_. + Code which inherits from these classes will trigger a type-checking (e.g. mypy) error, but will still work in runtime. + Currently the ``final`` designation does not appear in the API Reference but hopefully will in the future. + + + +Bug Fixes +--------- + +- `#1953 `_: Fixed error when overwriting a parametrized fixture, while also reusing the super fixture value. + + .. code-block:: python + + # conftest.py + import pytest + + + @pytest.fixture(params=[1, 2]) + def foo(request): + return request.param + + + # test_foo.py + import pytest + + + @pytest.fixture + def foo(foo): + return foo * 2 + + +- `#4984 `_: Fixed an internal error crash with ``IndexError: list index out of range`` when + collecting a module which starts with a decorated function, the decorator + raises, and assertion rewriting is enabled. + + +- `#7591 `_: pylint shouldn't complain anymore about unimplemented abstract methods when inheriting from :ref:`File `. + + +- `#7628 `_: Fixed test collection when a full path without a drive letter was passed to pytest on Windows (for example ``\projects\tests\test.py`` instead of ``c:\projects\tests\pytest.py``). + + +- `#7638 `_: Fix handling of command-line options that appear as paths but trigger an OS-level syntax error on Windows, such as the options used internally by ``pytest-xdist``. + + +- `#7742 `_: Fixed INTERNALERROR when accessing locals / globals with faulty ``exec``. + + + +Improved Documentation +---------------------- + +- `#1477 `_: Removed faq.rst and its reference in contents.rst. + + + +Trivial/Internal Changes +------------------------ + +- `#7536 `_: The internal ``junitxml`` plugin has rewritten to use ``xml.etree.ElementTree``. + The order of attributes in XML elements might differ. Some unneeded escaping is + no longer performed. + + +- `#7587 `_: The dependency on the ``more-itertools`` package has been removed. + + +- `#7631 `_: The result type of :meth:`capfd.readouterr() <_pytest.capture.CaptureFixture.readouterr>` (and similar) is no longer a namedtuple, + but should behave like one in all respects. This was done for technical reasons. + + +- `#7671 `_: When collecting tests, pytest finds test classes and functions by examining the + attributes of python objects (modules, classes and instances). To speed up this + process, pytest now ignores builtin attributes (like ``__class__``, + ``__delattr__`` and ``__new__``) without consulting the :confval:`python_classes` and + :confval:`python_functions` configuration options and without passing them to plugins + using the :func:`pytest_pycollect_makeitem <_pytest.hookspec.pytest_pycollect_makeitem>` hook. + + +pytest 6.0.2 (2020-09-04) +========================= + +Bug Fixes +--------- + +- `#7148 `_: Fixed ``--log-cli`` potentially causing unrelated ``print`` output to be swallowed. + + +- `#7672 `_: Fixed log-capturing level restored incorrectly if ``caplog.set_level`` is called more than once. + + +- `#7686 `_: Fixed `NotSetType.token` being used as the parameter ID when the parametrization list is empty. + Regressed in pytest 6.0.0. + + +- `#7707 `_: Fix internal error when handling some exceptions that contain multiple lines or the style uses multiple lines (``--tb=line`` for example). + + +pytest 6.0.1 (2020-07-30) +========================= + +Bug Fixes +--------- + +- `#7394 `_: Passing an empty ``help`` value to ``Parser.add_option`` is now accepted instead of crashing when running ``pytest --help``. + Passing ``None`` raises a more informative ``TypeError``. + + +- `#7558 `_: Fix pylint ``not-callable`` lint on ``pytest.mark.parametrize()`` and the other builtin marks: + ``skip``, ``skipif``, ``xfail``, ``usefixtures``, ``filterwarnings``. + + +- `#7559 `_: Fix regression in plugins using ``TestReport.longreprtext`` (such as ``pytest-html``) when ``TestReport.longrepr`` is not a string. + + +- `#7569 `_: Fix logging capture handler's level not reset on teardown after a call to ``caplog.set_level()``. + + +pytest 6.0.0 (2020-07-28) +========================= + +(**Please see the full set of changes for this release also in the 6.0.0rc1 notes below**) + +Breaking Changes +---------------- + +- `#5584 `_: **PytestDeprecationWarning are now errors by default.** + + Following our plan to remove deprecated features with as little disruption as + possible, all warnings of type ``PytestDeprecationWarning`` now generate errors + instead of warning messages. + + **The affected features will be effectively removed in pytest 6.1**, so please consult the + `Deprecations and Removals `__ + section in the docs for directions on how to update existing code. + + In the pytest ``6.0.X`` series, it is possible to change the errors back into warnings as a + stopgap measure by adding this to your ``pytest.ini`` file: + + .. code-block:: ini + + [pytest] + filterwarnings = + ignore::pytest.PytestDeprecationWarning + + But this will stop working when pytest ``6.1`` is released. + + **If you have concerns** about the removal of a specific feature, please add a + comment to `#5584 `__. + + +- `#7472 `_: The ``exec_()`` and ``is_true()`` methods of ``_pytest._code.Frame`` have been removed. + + + +Features +-------- + +- `#7464 `_: Added support for :envvar:`NO_COLOR` and :envvar:`FORCE_COLOR` environment variables to control colored output. + + + +Improvements +------------ + +- `#7467 `_: ``--log-file`` CLI option and ``log_file`` ini marker now create subdirectories if needed. + + +- `#7489 `_: The :func:`pytest.raises` function has a clearer error message when ``match`` equals the obtained string but is not a regex match. In this case it is suggested to escape the regex. + + + +Bug Fixes +--------- + +- `#7392 `_: Fix the reported location of tests skipped with ``@pytest.mark.skip`` when ``--runxfail`` is used. + + +- `#7491 `_: :fixture:`tmpdir` and :fixture:`tmp_path` no longer raise an error if the lock to check for + stale temporary directories is not accessible. + + +- `#7517 `_: Preserve line endings when captured via ``capfd``. + + +- `#7534 `_: Restored the previous formatting of ``TracebackEntry.__str__`` which was changed by accident. + + + +Improved Documentation +---------------------- + +- `#7422 `_: Clarified when the ``usefixtures`` mark can apply fixtures to test. + + +- `#7441 `_: Add a note about ``-q`` option used in getting started guide. + + + +Trivial/Internal Changes +------------------------ + +- `#7389 `_: Fixture scope ``package`` is no longer considered experimental. + + +pytest 6.0.0rc1 (2020-07-08) +============================ + +Breaking Changes +---------------- + +- `#1316 `_: ``TestReport.longrepr`` is now always an instance of ``ReprExceptionInfo``. Previously it was a ``str`` when a test failed with ``pytest.fail(..., pytrace=False)``. + + +- `#5965 `_: symlinks are no longer resolved during collection and matching `conftest.py` files with test file paths. + + Resolving symlinks for the current directory and during collection was introduced as a bugfix in 3.9.0, but it actually is a new feature which had unfortunate consequences in Windows and surprising results in other platforms. + + The team decided to step back on resolving symlinks at all, planning to review this in the future with a more solid solution (see discussion in + `#6523 `__ for details). + + This might break test suites which made use of this feature; the fix is to create a symlink + for the entire test tree, and not only to partial files/tress as it was possible previously. + + +- `#6505 `_: ``Testdir.run().parseoutcomes()`` now always returns the parsed nouns in plural form. + + Originally ``parseoutcomes()`` would always returns the nouns in plural form, but a change + meant to improve the terminal summary by using singular form single items (``1 warning`` or ``1 error``) + caused an unintended regression by changing the keys returned by ``parseoutcomes()``. + + Now the API guarantees to always return the plural form, so calls like this: + + .. code-block:: python + + result = testdir.runpytest() + result.assert_outcomes(error=1) + + Need to be changed to: + + + .. code-block:: python + + result = testdir.runpytest() + result.assert_outcomes(errors=1) + + +- `#6903 `_: The ``os.dup()`` function is now assumed to exist. We are not aware of any + supported Python 3 implementations which do not provide it. + + +- `#7040 `_: ``-k`` no longer matches against the names of the directories outside the test session root. + + Also, ``pytest.Package.name`` is now just the name of the directory containing the package's + ``__init__.py`` file, instead of the full path. This is consistent with how the other nodes + are named, and also one of the reasons why ``-k`` would match against any directory containing + the test suite. + + +- `#7122 `_: Expressions given to the ``-m`` and ``-k`` options are no longer evaluated using Python's :func:`eval`. + The format supports ``or``, ``and``, ``not``, parenthesis and general identifiers to match against. + Python constants, keywords or other operators are no longer evaluated differently. + + +- `#7135 `_: Pytest now uses its own ``TerminalWriter`` class instead of using the one from the ``py`` library. + Plugins generally access this class through ``TerminalReporter.writer``, ``TerminalReporter.write()`` + (and similar methods), or ``_pytest.config.create_terminal_writer()``. + + The following breaking changes were made: + + - Output (``write()`` method and others) no longer flush implicitly; the flushing behavior + of the underlying file is respected. To flush explicitly (for example, if you + want output to be shown before an end-of-line is printed), use ``write(flush=True)`` or + ``terminal_writer.flush()``. + - Explicit Windows console support was removed, delegated to the colorama library. + - Support for writing ``bytes`` was removed. + - The ``reline`` method and ``chars_on_current_line`` property were removed. + - The ``stringio`` and ``encoding`` arguments was removed. + - Support for passing a callable instead of a file was removed. + + +- `#7224 `_: The `item.catch_log_handler` and `item.catch_log_handlers` attributes, set by the + logging plugin and never meant to be public, are no longer available. + + The deprecated ``--no-print-logs`` option and ``log_print`` ini option are removed. Use ``--show-capture`` instead. + + +- `#7226 `_: Removed the unused ``args`` parameter from ``pytest.Function.__init__``. + + +- `#7418 `_: Removed the `pytest_doctest_prepare_content` hook specification. This hook + hasn't been triggered by pytest for at least 10 years. + + +- `#7438 `_: Some changes were made to the internal ``_pytest._code.source``, listed here + for the benefit of plugin authors who may be using it: + + - The ``deindent`` argument to ``Source()`` has been removed, now it is always true. + - Support for zero or multiple arguments to ``Source()`` has been removed. + - Support for comparing ``Source`` with an ``str`` has been removed. + - The methods ``Source.isparseable()`` and ``Source.putaround()`` have been removed. + - The method ``Source.compile()`` and function ``_pytest._code.compile()`` have + been removed; use plain ``compile()`` instead. + - The function ``_pytest._code.source.getsource()`` has been removed; use + ``Source()`` directly instead. + + + +Deprecations +------------ + +- `#7210 `_: The special ``-k '-expr'`` syntax to ``-k`` is deprecated. Use ``-k 'not expr'`` + instead. + + The special ``-k 'expr:'`` syntax to ``-k`` is deprecated. Please open an issue + if you use this and want a replacement. + +- `#4049 `_: ``pytest_warning_captured`` is deprecated in favor of the ``pytest_warning_recorded`` hook. + + +Features +-------- + +- `#1556 `_: pytest now supports ``pyproject.toml`` files for configuration. + + The configuration options is similar to the one available in other formats, but must be defined + in a ``[tool.pytest.ini_options]`` table to be picked up by pytest: + + .. code-block:: toml + + # pyproject.toml + [tool.pytest.ini_options] + minversion = "6.0" + addopts = "-ra -q" + testpaths = [ + "tests", + "integration", + ] + + More information can be found `in the docs `__. + + +- `#3342 `_: pytest now includes inline type annotations and exposes them to user programs. + Most of the user-facing API is covered, as well as internal code. + + If you are running a type checker such as mypy on your tests, you may start + noticing type errors indicating incorrect usage. If you run into an error that + you believe to be incorrect, please let us know in an issue. + + The types were developed against mypy version 0.780. Versions before 0.750 + are known not to work. We recommend using the latest version. Other type + checkers may work as well, but they are not officially verified to work by + pytest yet. + + +- `#4049 `_: Introduced a new hook named `pytest_warning_recorded` to convey information about warnings captured by the internal `pytest` warnings plugin. + + This hook is meant to replace `pytest_warning_captured`, which is deprecated and will be removed in a future release. + + +- `#6471 `_: New command-line flags: + + * `--no-header`: disables the initial header, including platform, version, and plugins. + * `--no-summary`: disables the final test summary, including warnings. + + +- `#6856 `_: A warning is now shown when an unknown key is read from a config INI file. + + The `--strict-config` flag has been added to treat these warnings as errors. + + +- `#6906 `_: Added `--code-highlight` command line option to enable/disable code highlighting in terminal output. + + +- `#7245 `_: New ``--import-mode=importlib`` option that uses `importlib `__ to import test modules. + + Traditionally pytest used ``__import__`` while changing ``sys.path`` to import test modules (which + also changes ``sys.modules`` as a side-effect), which works but has a number of drawbacks, like requiring test modules + that don't live in packages to have unique names (as they need to reside under a unique name in ``sys.modules``). + + ``--import-mode=importlib`` uses more fine grained import mechanisms from ``importlib`` which don't + require pytest to change ``sys.path`` or ``sys.modules`` at all, eliminating much of the drawbacks + of the previous mode. + + We intend to make ``--import-mode=importlib`` the default in future versions, so users are encouraged + to try the new mode and provide feedback (both positive or negative) in issue `#7245 `__. + + You can read more about this option in `the documentation `__. + + +- `#7305 `_: New ``required_plugins`` configuration option allows the user to specify a list of plugins, including version information, that are required for pytest to run. An error is raised if any required plugins are not found when running pytest. + + +Improvements +------------ + +- `#4375 `_: The ``pytest`` command now suppresses the ``BrokenPipeError`` error message that + is printed to stderr when the output of ``pytest`` is piped and and the pipe is + closed by the piped-to program (common examples are ``less`` and ``head``). + + +- `#4391 `_: Improved precision of test durations measurement. ``CallInfo`` items now have a new ``.duration`` attribute, created using ``time.perf_counter()``. This attribute is used to fill the ``.duration`` attribute, which is more accurate than the previous ``.stop - .start`` (as these are based on ``time.time()``). + + +- `#4675 `_: Rich comparison for dataclasses and `attrs`-classes is now recursive. + + +- `#6285 `_: Exposed the `pytest.FixtureLookupError` exception which is raised by `request.getfixturevalue()` + (where `request` is a `FixtureRequest` fixture) when a fixture with the given name cannot be returned. + + +- `#6433 `_: If an error is encountered while formatting the message in a logging call, for + example ``logging.warning("oh no!: %s: %s", "first")`` (a second argument is + missing), pytest now propagates the error, likely causing the test to fail. + + Previously, such a mistake would cause an error to be printed to stderr, which + is not displayed by default for passing tests. This change makes the mistake + visible during testing. + + You may supress this behavior temporarily or permanently by setting + ``logging.raiseExceptions = False``. + + +- `#6817 `_: Explicit new-lines in help texts of command-line options are preserved, allowing plugins better control + of the help displayed to users. + + +- `#6940 `_: When using the ``--duration`` option, the terminal message output is now more precise about the number and duration of hidden items. + + +- `#6991 `_: Collected files are displayed after any reports from hooks, e.g. the status from ``--lf``. + + +- `#7091 `_: When ``fd`` capturing is used, through ``--capture=fd`` or the ``capfd`` and + ``capfdbinary`` fixtures, and the file descriptor (0, 1, 2) cannot be + duplicated, FD capturing is still performed. Previously, direct writes to the + file descriptors would fail or be lost in this case. + + +- `#7119 `_: Exit with an error if the ``--basetemp`` argument is empty, is the current working directory or is one of the parent directories. + This is done to protect against accidental data loss, as any directory passed to this argument is cleared. + + +- `#7128 `_: `pytest --version` now displays just the pytest version, while `pytest --version --version` displays more verbose information including plugins. This is more consistent with how other tools show `--version`. + + +- `#7133 `_: :meth:`caplog.set_level() <_pytest.logging.LogCaptureFixture.set_level>` will now override any :confval:`log_level` set via the CLI or configuration file. + + +- `#7159 `_: :meth:`caplog.set_level() <_pytest.logging.LogCaptureFixture.set_level>` and :meth:`caplog.at_level() <_pytest.logging.LogCaptureFixture.at_level>` no longer affect + the level of logs that are shown in the *Captured log report* report section. + + +- `#7348 `_: Improve recursive diff report for comparison asserts on dataclasses / attrs. + + +- `#7385 `_: ``--junitxml`` now includes the exception cause in the ``message`` XML attribute for failures during setup and teardown. + + Previously: + + .. code-block:: xml + + + + Now: + + .. code-block:: xml + + + + + +Bug Fixes +--------- + +- `#1120 `_: Fix issue where directories from :fixture:`tmpdir` are not removed properly when multiple instances of pytest are running in parallel. + + +- `#4583 `_: Prevent crashing and provide a user-friendly error when a marker expression (`-m`) invoking of :func:`eval` raises any exception. + + +- `#4677 `_: The path shown in the summary report for SKIPPED tests is now always relative. Previously it was sometimes absolute. + + +- `#5456 `_: Fix a possible race condition when trying to remove lock files used to control access to folders + created by :fixture:`tmp_path` and :fixture:`tmpdir`. + + +- `#6240 `_: Fixes an issue where logging during collection step caused duplication of log + messages to stderr. + + +- `#6428 `_: Paths appearing in error messages are now correct in case the current working directory has + changed since the start of the session. + + +- `#6755 `_: Support deleting paths longer than 260 characters on windows created inside :fixture:`tmpdir`. + + +- `#6871 `_: Fix crash with captured output when using :fixture:`capsysbinary`. + + +- `#6909 `_: Revert the change introduced by `#6330 `_, which required all arguments to ``@pytest.mark.parametrize`` to be explicitly defined in the function signature. + + The intention of the original change was to remove what was expected to be an unintended/surprising behavior, but it turns out many people relied on it, so the restriction has been reverted. + + +- `#6910 `_: Fix crash when plugins return an unknown stats while using the ``--reportlog`` option. + + +- `#6924 `_: Ensure a ``unittest.IsolatedAsyncioTestCase`` is actually awaited. + + +- `#6925 `_: Fix `TerminalRepr` instances to be hashable again. + + +- `#6947 `_: Fix regression where functions registered with :meth:`unittest.TestCase.addCleanup` were not being called on test failures. + + +- `#6951 `_: Allow users to still set the deprecated ``TerminalReporter.writer`` attribute. + + +- `#6956 `_: Prevent pytest from printing `ConftestImportFailure` traceback to stdout. + + +- `#6991 `_: Fix regressions with `--lf` filtering too much since pytest 5.4. + + +- `#6992 `_: Revert "tmpdir: clean up indirection via config for factories" `#6767 `_ as it breaks pytest-xdist. + + +- `#7061 `_: When a yielding fixture fails to yield a value, report a test setup error instead of crashing. + + +- `#7076 `_: The path of file skipped by ``@pytest.mark.skip`` in the SKIPPED report is now relative to invocation directory. Previously it was relative to root directory. + + +- `#7110 `_: Fixed regression: ``asyncbase.TestCase`` tests are executed correctly again. + + +- `#7126 `_: ``--setup-show`` now doesn't raise an error when a bytes value is used as a ``parametrize`` + parameter when Python is called with the ``-bb`` flag. + + +- `#7143 `_: Fix :meth:`pytest.File.from_parent` so it forwards extra keyword arguments to the constructor. + + +- `#7145 `_: Classes with broken ``__getattribute__`` methods are displayed correctly during failures. + + +- `#7150 `_: Prevent hiding the underlying exception when ``ConfTestImportFailure`` is raised. + + +- `#7180 `_: Fix ``_is_setup_py`` for files encoded differently than locale. + + +- `#7215 `_: Fix regression where running with ``--pdb`` would call :meth:`unittest.TestCase.tearDown` for skipped tests. + + +- `#7253 `_: When using ``pytest.fixture`` on a function directly, as in ``pytest.fixture(func)``, + if the ``autouse`` or ``params`` arguments are also passed, the function is no longer + ignored, but is marked as a fixture. + + +- `#7360 `_: Fix possibly incorrect evaluation of string expressions passed to ``pytest.mark.skipif`` and ``pytest.mark.xfail``, + in rare circumstances where the exact same string is used but refers to different global values. + + +- `#7383 `_: Fixed exception causes all over the codebase, i.e. use `raise new_exception from old_exception` when wrapping an exception. + + + +Improved Documentation +---------------------- + +- `#7202 `_: The development guide now links to the contributing section of the docs and `RELEASING.rst` on GitHub. + + +- `#7233 `_: Add a note about ``--strict`` and ``--strict-markers`` and the preference for the latter one. + + +- `#7345 `_: Explain indirect parametrization and markers for fixtures. + + + +Trivial/Internal Changes +------------------------ + +- `#7035 `_: The ``originalname`` attribute of ``_pytest.python.Function`` now defaults to ``name`` if not + provided explicitly, and is always set. + + +- `#7264 `_: The dependency on the ``wcwidth`` package has been removed. + + +- `#7291 `_: Replaced ``py.iniconfig`` with `iniconfig `__. + + +- `#7295 `_: ``src/_pytest/config/__init__.py`` now uses the ``warnings`` module to report warnings instead of ``sys.stderr.write``. + + +- `#7356 `_: Remove last internal uses of deprecated *slave* term from old ``pytest-xdist``. + + +- `#7357 `_: ``py``>=1.8.2 is now required. + + +pytest 5.4.3 (2020-06-02) +========================= + +Bug Fixes +--------- + +- `#6428 `_: Paths appearing in error messages are now correct in case the current working directory has + changed since the start of the session. + + +- `#6755 `_: Support deleting paths longer than 260 characters on windows created inside tmpdir. + + +- `#6956 `_: Prevent pytest from printing ConftestImportFailure traceback to stdout. + + +- `#7150 `_: Prevent hiding the underlying exception when ``ConfTestImportFailure`` is raised. + + +- `#7215 `_: Fix regression where running with ``--pdb`` would call the ``tearDown`` methods of ``unittest.TestCase`` + subclasses for skipped tests. + + +pytest 5.4.2 (2020-05-08) +========================= + +Bug Fixes +--------- + +- `#6871 `_: Fix crash with captured output when using the :fixture:`capsysbinary fixture `. + + +- `#6924 `_: Ensure a ``unittest.IsolatedAsyncioTestCase`` is actually awaited. + + +- `#6925 `_: Fix TerminalRepr instances to be hashable again. + + +- `#6947 `_: Fix regression where functions registered with ``TestCase.addCleanup`` were not being called on test failures. + + +- `#6951 `_: Allow users to still set the deprecated ``TerminalReporter.writer`` attribute. + + +- `#6992 `_: Revert "tmpdir: clean up indirection via config for factories" #6767 as it breaks pytest-xdist. + + +- `#7110 `_: Fixed regression: ``asyncbase.TestCase`` tests are executed correctly again. + + +- `#7143 `_: Fix ``File.from_constructor`` so it forwards extra keyword arguments to the constructor. + + +- `#7145 `_: Classes with broken ``__getattribute__`` methods are displayed correctly during failures. + + +- `#7180 `_: Fix ``_is_setup_py`` for files encoded differently than locale. + + +pytest 5.4.1 (2020-03-13) +========================= + +Bug Fixes +--------- + +- `#6909 `_: Revert the change introduced by `#6330 `_, which required all arguments to ``@pytest.mark.parametrize`` to be explicitly defined in the function signature. + + The intention of the original change was to remove what was expected to be an unintended/surprising behavior, but it turns out many people relied on it, so the restriction has been reverted. + + +- `#6910 `_: Fix crash when plugins return an unknown stats while using the ``--reportlog`` option. + + pytest 5.4.0 (2020-03-12) ========================= @@ -63,10 +1074,10 @@ Breaking Changes Deprecations ------------ -- `#3238 `_: Option ``--no-print-logs`` is deprecated and meant to be removed in a future release. If you use ``--no-print-logs``, please try out ``--show-capture`` and - provide feedback. - - ``--show-capture`` command-line option was added in ``pytest 3.5.0`` and allows to specify how to +- `#3238 `_: Option ``--no-print-logs`` is deprecated and meant to be removed in a future release. If you use ``--no-print-logs``, please try out ``--show-capture`` and + provide feedback. + + ``--show-capture`` command-line option was added in ``pytest 3.5.0`` and allows to specify how to display captured output when tests fail: ``no``, ``stdout``, ``stderr``, ``log`` or ``all`` (the default). @@ -77,12 +1088,12 @@ Deprecations - `#5975 `_: Deprecate using direct constructors for ``Nodes``. - Instead they are new constructed via ``Node.from_parent``. + Instead they are now constructed via ``Node.from_parent``. - This transitional mechanism enables us to detangle the very intensely - entangled ``Node`` relationships by enforcing more controlled creation/configruation patterns. + This transitional mechanism enables us to untangle the very intensely + entangled ``Node`` relationships by enforcing more controlled creation/configuration patterns. - As part of that session/config are already disallowed parameters and as we work on the details we might need disallow a few more as well. + As part of this change, session/config are already disallowed parameters and as we work on the details we might need disallow a few more as well. Subclasses are expected to use `super().from_parent` if they intend to expand the creation of `Nodes`. @@ -214,7 +1225,7 @@ Bug Fixes - `#6646 `_: Assertion rewriting hooks are (re)stored for the current item, which fixes them being still used after e.g. pytester's :func:`testdir.runpytest <_pytest.pytester.Testdir.runpytest>` etc. -- `#6660 `_: :func:`pytest.exit() <_pytest.outcomes.exit>` is handled when emitted from the :func:`pytest_sessionfinish <_pytest.hookspec.pytest_sessionfinish>` hook. This includes quitting from a debugger. +- `#6660 `_: :py:func:`pytest.exit` is handled when emitted from the :func:`pytest_sessionfinish <_pytest.hookspec.pytest_sessionfinish>` hook. This includes quitting from a debugger. - `#6752 `_: When :py:func:`pytest.raises` is used as a function (as opposed to a context manager), @@ -326,7 +1337,7 @@ Improvements - `#6231 `_: Improve check for misspelling of :ref:`pytest.mark.parametrize ref`. -- `#6257 `_: Handle :py:func:`_pytest.outcomes.exit` being used via :py:func:`~_pytest.hookspec.pytest_internalerror`, e.g. when quitting pdb from post mortem. +- `#6257 `_: Handle :py:func:`pytest.exit` being used via :py:func:`~_pytest.hookspec.pytest_internalerror`, e.g. when quitting pdb from post mortem. @@ -360,7 +1371,7 @@ Deprecations In order to smooth the transition, pytest will issue a warning in case the ``--junitxml`` option is given in the command line but :confval:`junit_family` is not explicitly configured in ``pytest.ini``. - For more information, `see the docs `__. + For more information, `see the docs `__. @@ -627,7 +1638,7 @@ Features - `#1682 `_: The ``scope`` parameter of ``@pytest.fixture`` can now be a callable that receives the fixture name and the ``config`` object as keyword-only parameters. - See `the docs `__ for more information. + See `the docs `__ for more information. - `#5764 `_: New behavior of the ``--pastebin`` option: failures to connect to the pastebin server are reported, without failing the pytest run @@ -732,7 +1743,7 @@ Removals For more information consult - `Deprecations and Removals `__ in the docs. + `Deprecations and Removals `__ in the docs. - `#5565 `_: Removed unused support code for `unittest2 `__. @@ -766,7 +1777,7 @@ Features - `#5564 `_: New ``Config.invocation_args`` attribute containing the unchanged arguments passed to ``pytest.main()``. -- `#5576 `_: New `NUMBER `__ +- `#5576 `_: New `NUMBER `__ option for doctests to ignore irrelevant differences in floating-point numbers. Inspired by Sébastien Boisgérault's `numtest `__ extension for doctest. @@ -886,7 +1897,7 @@ Important This release is a Python3.5+ only release. -For more details, see our `Python 2.7 and 3.4 support plan `__. +For more details, see our `Python 2.7 and 3.4 support plan `__. Removals -------- @@ -907,7 +1918,7 @@ Removals instead of warning messages. **The affected features will be effectively removed in pytest 5.1**, so please consult the - `Deprecations and Removals `__ + `Deprecations and Removals `__ section in the docs for directions on how to update existing code. In the pytest ``5.0.X`` series, it is possible to change the errors back into warnings as a stop @@ -963,7 +1974,7 @@ Deprecations Features -------- -- `#3457 `_: New `pytest_assertion_pass `__ +- `#3457 `_: New `pytest_assertion_pass `__ hook, called with context information when an assertion *passes*. This hook is still **experimental** so use it with caution. @@ -976,7 +1987,7 @@ Features `pytest-faulthandler `__ plugin into the core, so users should remove that plugin from their requirements if used. - For more information see the docs: https://docs.pytest.org/en/latest/usage.html#fault-handler + For more information see the docs: https://docs.pytest.org/en/stable/usage.html#fault-handler - `#5452 `_: When warnings are configured as errors, pytest warnings now appear as originating from ``pytest.`` instead of the internal ``_pytest.warning_types.`` module. @@ -1081,6 +2092,44 @@ Improved Documentation - `#5416 `_: Fix PytestUnknownMarkWarning in run/skip example. +pytest 4.6.11 (2020-06-04) +========================== + +Bug Fixes +--------- + +- `#6334 `_: Fix summary entries appearing twice when ``f/F`` and ``s/S`` report chars were used at the same time in the ``-r`` command-line option (for example ``-rFf``). + + The upper case variants were never documented and the preferred form should be the lower case. + + +- `#7310 `_: Fix ``UnboundLocalError: local variable 'letter' referenced before + assignment`` in ``_pytest.terminal.pytest_report_teststatus()`` + when plugins return report objects in an unconventional state. + + This was making ``pytest_report_teststatus()`` skip + entering if-block branches that declare the ``letter`` variable. + + The fix was to set the initial value of the ``letter`` before + the if-block cascade so that it always has a value. + + +pytest 4.6.10 (2020-05-08) +========================== + +Features +-------- + +- `#6870 `_: New ``Config.invocation_args`` attribute containing the unchanged arguments passed to ``pytest.main()``. + + Remark: while this is technically a new feature and according to our `policy `_ it should not have been backported, we have opened an exception in this particular case because it fixes a serious interaction with ``pytest-xdist``, so it can also be considered a bugfix. + +Trivial/Internal Changes +------------------------ + +- `#6404 `_: Remove usage of ``parser`` module, deprecated in Python 3.9. + + pytest 4.6.9 (2020-01-04) ========================= @@ -1245,7 +2294,7 @@ Important The ``4.6.X`` series will be the last series to support **Python 2 and Python 3.4**. -For more details, see our `Python 2.7 and 3.4 support plan `__. +For more details, see our `Python 2.7 and 3.4 support plan `__. Features @@ -1335,7 +2384,7 @@ Features The existing ``--strict`` option has the same behavior currently, but can be augmented in the future for additional checks. - .. _`markers option`: https://docs.pytest.org/en/latest/reference.html#confval-markers + .. _`markers option`: https://docs.pytest.org/en/stable/reference.html#confval-markers - `#5026 `_: Assertion failure messages for sequences and dicts contain the number of different items now. @@ -1392,7 +2441,7 @@ Features CRITICAL root:test_log_cli_enabled_disabled.py:3 critical message logged by test - The formatting can be changed through the `log_format `__ configuration option. + The formatting can be changed through the `log_format `__ configuration option. - `#5220 `_: ``--fixtures`` now also shows fixture scope for scopes other than ``"function"``. @@ -1528,7 +2577,7 @@ Features .. _pdb++: https://pypi.org/project/pdbpp/ -- `#4875 `_: The `testpaths `__ configuration option is now displayed next +- `#4875 `_: The `testpaths `__ configuration option is now displayed next to the ``rootdir`` and ``inifile`` lines in the pytest header if the option is in effect, i.e., directories or file names were not explicitly passed in the command line. @@ -1698,7 +2747,7 @@ Features - `#3711 `_: Add the ``--ignore-glob`` parameter to exclude test-modules with Unix shell-style wildcards. - Add the ``collect_ignore_glob`` for ``conftest.py`` to exclude test-modules with Unix shell-style wildcards. + Add the :globalvar:`collect_ignore_glob` for ``conftest.py`` to exclude test-modules with Unix shell-style wildcards. - `#4698 `_: The warning about Python 2.7 and 3.4 not being supported in pytest 5.0 has been removed. @@ -1783,7 +2832,7 @@ pytest 4.2.0 (2019-01-30) Features -------- -- `#3094 `_: `Classic xunit-style `__ functions and methods +- `#3094 `_: `Classic xunit-style `__ functions and methods now obey the scope of *autouse* fixtures. This fixes a number of surprising issues like ``setup_method`` being called before session-scoped @@ -1901,27 +2950,27 @@ Removals - `#3078 `_: Remove legacy internal warnings system: ``config.warn``, ``Node.warn``. The ``pytest_logwarning`` now issues a warning when implemented. - See our `docs `__ on information on how to update your code. + See our `docs `__ on information on how to update your code. - `#3079 `_: Removed support for yield tests - they are fundamentally broken because they don't support fixtures properly since collection and test execution were separated. - See our `docs `__ on information on how to update your code. + See our `docs `__ on information on how to update your code. - `#3082 `_: Removed support for applying marks directly to values in ``@pytest.mark.parametrize``. Use ``pytest.param`` instead. - See our `docs `__ on information on how to update your code. + See our `docs `__ on information on how to update your code. - `#3083 `_: Removed ``Metafunc.addcall``. This was the predecessor mechanism to ``@pytest.mark.parametrize``. - See our `docs `__ on information on how to update your code. + See our `docs `__ on information on how to update your code. - `#3085 `_: Removed support for passing strings to ``pytest.main``. Now, always pass a list of strings instead. - See our `docs `__ on information on how to update your code. + See our `docs `__ on information on how to update your code. - `#3086 `_: ``[pytest]`` section in **setup.cfg** files is no longer supported, use ``[tool:pytest]`` instead. ``setup.cfg`` files @@ -1932,17 +2981,17 @@ Removals - `#3616 `_: Removed the deprecated compat properties for ``node.Class/Function/Module`` - use ``pytest.Class/Function/Module`` now. - See our `docs `__ on information on how to update your code. + See our `docs `__ on information on how to update your code. - `#4421 `_: Removed the implementation of the ``pytest_namespace`` hook. - See our `docs `__ on information on how to update your code. + See our `docs `__ on information on how to update your code. - `#4489 `_: Removed ``request.cached_setup``. This was the predecessor mechanism to modern fixtures. - See our `docs `__ on information on how to update your code. + See our `docs `__ on information on how to update your code. - `#4535 `_: Removed the deprecated ``PyCollector.makeitem`` method. This method was made public by mistake a long time ago. @@ -1950,12 +2999,12 @@ Removals - `#4543 `_: Removed support to define fixtures using the ``pytest_funcarg__`` prefix. Use the ``@pytest.fixture`` decorator instead. - See our `docs `__ on information on how to update your code. + See our `docs `__ on information on how to update your code. - `#4545 `_: Calling fixtures directly is now always an error instead of a warning. - See our `docs `__ on information on how to update your code. + See our `docs `__ on information on how to update your code. - `#4546 `_: Remove ``Node.get_marker(name)`` the return value was not usable for more than a existence check. @@ -1965,12 +3014,12 @@ Removals - `#4547 `_: The deprecated ``record_xml_property`` fixture has been removed, use the more generic ``record_property`` instead. - See our `docs `__ for more information. + See our `docs `__ for more information. - `#4548 `_: An error is now raised if the ``pytest_plugins`` variable is defined in a non-top-level ``conftest.py`` file (i.e., not residing in the ``rootdir``). - See our `docs `__ for more information. + See our `docs `__ for more information. - `#891 `_: Remove ``testfunction.markername`` attributes - use ``Node.iter_markers(name=None)`` to iterate them. @@ -1982,7 +3031,7 @@ Deprecations - `#3050 `_: Deprecated the ``pytest.config`` global. - See https://docs.pytest.org/en/latest/deprecations.html#pytest-config-global for rationale. + See https://docs.pytest.org/en/stable/deprecations.html#pytest-config-global for rationale. - `#3974 `_: Passing the ``message`` parameter of ``pytest.raises`` now issues a ``DeprecationWarning``. @@ -1997,7 +3046,7 @@ Deprecations - `#4435 `_: Deprecated ``raises(..., 'code(as_a_string)')`` and ``warns(..., 'code(as_a_string)')``. - See https://docs.pytest.org/en/latest/deprecations.html#raises-warns-exec for rationale and examples. + See https://docs.pytest.org/en/stable/deprecations.html#raises-warns-exec for rationale and examples. @@ -2191,7 +3240,7 @@ Removals instead of warning messages. **The affected features will be effectively removed in pytest 4.1**, so please consult the - `Deprecations and Removals `__ + `Deprecations and Removals `__ section in the docs for directions on how to update existing code. In the pytest ``4.0.X`` series, it is possible to change the errors back into warnings as a stop @@ -2291,7 +3340,7 @@ Features existing ``pytest_enter_pdb`` hook. -- `#4147 `_: Add ``--sw``, ``--stepwise`` as an alternative to ``--lf -x`` for stopping at the first failure, but starting the next test invocation from that test. See `the documentation `__ for more info. +- `#4147 `_: Add ``--sw``, ``--stepwise`` as an alternative to ``--lf -x`` for stopping at the first failure, but starting the next test invocation from that test. See `the documentation `__ for more info. - `#4188 `_: Make ``--color`` emit colorful dots when not running in verbose mode. Earlier, it would only colorize the test-by-test output if ``--verbose`` was also passed. @@ -2443,7 +3492,7 @@ Deprecations Users should just ``import pytest`` and access those objects using the ``pytest`` module. * ``request.cached_setup``, this was the precursor of the setup/teardown mechanism available to fixtures. You can - consult `funcarg comparison section in the docs `_. + consult `funcarg comparison section in the docs `_. * Using objects named ``"Class"`` as a way to customize the type of nodes that are collected in ``Collector`` subclasses has been deprecated. Users instead should use ``pytest_collect_make_item`` to customize node types during @@ -2656,7 +3705,7 @@ Bug Fixes Improved Documentation ---------------------- -- `#3996 `_: New `Deprecations and Removals `_ page shows all currently +- `#3996 `_: New `Deprecations and Removals `_ page shows all currently deprecated features, the rationale to do so, and alternatives to update your code. It also list features removed from pytest in past major releases to help those with ancient pytest versions to upgrade. @@ -2678,7 +3727,7 @@ Deprecations and Removals ------------------------- - `#2452 `_: ``Config.warn`` and ``Node.warn`` have been - deprecated, see ``_ for rationale and + deprecated, see ``_ for rationale and examples. - `#3936 `_: ``@pytest.mark.filterwarnings`` second parameter is no longer regex-escaped, @@ -2696,13 +3745,13 @@ Features the standard warnings filters to manage those warnings. This introduces ``PytestWarning``, ``PytestDeprecationWarning`` and ``RemovedInPytest4Warning`` warning types as part of the public API. - Consult `the documentation `__ for more info. + Consult `the documentation `__ for more info. - `#2908 `_: ``DeprecationWarning`` and ``PendingDeprecationWarning`` are now shown by default if no other warning filter is configured. This makes pytest more compliant with `PEP-0506 `_. See - `the docs `_ for + `the docs `_ for more info. @@ -2896,10 +3945,10 @@ pytest 3.7.0 (2018-07-30) Deprecations and Removals ------------------------- -- `#2639 `_: ``pytest_namespace`` has been `deprecated `_. +- `#2639 `_: ``pytest_namespace`` has been `deprecated `_. -- `#3661 `_: Calling a fixture function directly, as opposed to request them in a test function, now issues a ``RemovedInPytest4Warning``. See `the documentation for rationale and examples `_. +- `#3661 `_: Calling a fixture function directly, as opposed to request them in a test function, now issues a ``RemovedInPytest4Warning``. See `the documentation for rationale and examples `_. @@ -3123,9 +4172,9 @@ Features design. This introduces new ``Node.iter_markers(name)`` and ``Node.get_closest_marker(name)`` APIs. Users are **strongly encouraged** to read the `reasons for the revamp in the docs - `_, + `_, or jump over to details about `updating existing code to use the new APIs - `_. + `_. (`#3317 `_) - Now when ``@pytest.fixture`` is applied more than once to the same function a @@ -3135,7 +4184,7 @@ Features - Support for Python 3.7's builtin ``breakpoint()`` method, see `Using the builtin breakpoint function - `_ for + `_ for details. (`#3180 `_) - ``monkeypatch`` now supports a ``context()`` function which acts as a context @@ -3261,7 +4310,7 @@ Deprecations and Removals `_) - Defining ``pytest_plugins`` is now deprecated in non-top-level conftest.py - files, because they "leak" to the entire directory tree. `See the docs `_ for the rationale behind this decision (`#3084 + files, because they "leak" to the entire directory tree. `See the docs `_ for the rationale behind this decision (`#3084 `_) @@ -3275,7 +4324,7 @@ Features - New ``--rootdir`` command-line option to override the rules for discovering the root directory. See `customize - `_ in the documentation for + `_ in the documentation for details. (`#1642 `_) - Fixtures are now instantiated based on their scopes, with higher-scoped @@ -3362,7 +4411,7 @@ Bug Fixes Improved Documentation ---------------------- -- Added a `reference `_ page +- Added a `reference `_ page to the docs. (`#1713 `_) @@ -3522,9 +4571,9 @@ Features `_) - **Incompatible change**: after community feedback the `logging - `_ functionality has + `_ functionality has undergone some changes. Please consult the `logging documentation - `_ + `_ for details. (`#3013 `_) - Console output falls back to "classic" mode when capturing is disabled (``-s``), @@ -3532,10 +4581,10 @@ Features `_) - New `pytest_runtest_logfinish - `_ + `_ hook which is called when a test item has finished executing, analogous to `pytest_runtest_logstart - `_. + `_. (`#3101 `_) - Improve performance when collecting tests using many fixtures. (`#3107 @@ -3777,7 +4826,7 @@ Features markers. Also, a ``caplog`` fixture is available that enables users to test the captured log during specific tests (similar to ``capsys`` for example). For more information, please see the `logging docs - `_. This feature was + `_. This feature was introduced by merging the popular `pytest-catchlog `_ plugin, thanks to `Thomas Hisch `_. Be advised that during the merging the @@ -4073,7 +5122,7 @@ Deprecations and Removals - ``pytest.approx`` no longer supports ``>``, ``>=``, ``<`` and ``<=`` operators to avoid surprising/inconsistent behavior. See `the approx docs - `_ for more + `_ for more information. (`#2003 `_) - All old-style specific behavior in current classes in the pytest's API is @@ -4119,13 +5168,13 @@ Features - Introduce the ``PYTEST_CURRENT_TEST`` environment variable that is set with the ``nodeid`` and stage (``setup``, ``call`` and ``teardown``) of the test being currently executed. See the `documentation - `_ for more info. (`#2583 `_) - Introduced ``@pytest.mark.filterwarnings`` mark which allows overwriting the warnings filter on a per test, class or module level. See the `docs - `_ for more information. (`#2598 `_) @@ -4355,7 +5404,7 @@ New Features [pytest] addopts = -p no:warnings - See the `warnings documentation page `_ for more + See the `warnings documentation page `_ for more information. Thanks `@nicoddemus`_ for the PR. @@ -5429,7 +6478,7 @@ time or change existing behaviors in order to make them less surprising/more use * Fix (`#1422`_): junit record_xml_property doesn't allow multiple records with same name. -.. _`traceback style docs`: https://pytest.org/en/latest/usage.html#modifying-python-traceback-printing +.. _`traceback style docs`: https://pytest.org/en/stable/usage.html#modifying-python-traceback-printing .. _#1609: https://github.com/pytest-dev/pytest/issues/1609 .. _#1422: https://github.com/pytest-dev/pytest/issues/1422 @@ -5947,7 +6996,7 @@ time or change existing behaviors in order to make them less surprising/more use - add ability to set command line options by environment variable PYTEST_ADDOPTS. - added documentation on the new pytest-dev teams on bitbucket and - github. See https://pytest.org/en/latest/contributing.html . + github. See https://pytest.org/en/stable/contributing.html . Thanks to Anatoly for pushing and initial work on this. - fix issue650: new option ``--docttest-ignore-import-errors`` which @@ -6086,7 +7135,7 @@ time or change existing behaviors in order to make them less surprising/more use purely the nodeid. The line number is still shown in failure reports. Thanks Floris Bruynooghe. -- fix issue437 where assertion rewriting could cause pytest-xdist slaves +- fix issue437 where assertion rewriting could cause pytest-xdist worker nodes to collect different tests. Thanks Bruno Oliveira. - fix issue555: add "errors" attribute to capture-streams to satisfy @@ -6633,7 +7682,7 @@ Bug fixes: - Issue 265 - integrate nose setup/teardown with setupstate so it doesn't try to teardown if it did not setup -- issue 271 - don't write junitxml on slave nodes +- issue 271 - don't write junitxml on worker nodes - Issue 274 - don't try to show full doctest example when doctest does not know the example location @@ -6688,7 +7737,7 @@ Bug fixes: - yielded test functions will now have autouse-fixtures active but cannot accept fixtures as funcargs - it's anyway recommended to rather use the post-2.0 parametrize features instead of yield, see: - http://pytest.org/en/latest/example/parametrize.html + http://pytest.org/en/stable/example/parametrize.html - fix autouse-issue where autouse-fixtures would not be discovered if defined in an a/conftest.py file and tests in a/tests/test_some.py - fix issue226 - LIFO ordering for fixture teardowns @@ -6821,7 +7870,7 @@ Bug fixes: - pluginmanager.register(...) now raises ValueError if the plugin has been already registered or the name is taken -- fix issue159: improve http://pytest.org/en/latest/faq.html +- fix issue159: improve https://docs.pytest.org/en/6.0.1/faq.html especially with respect to the "magic" history, also mention pytest-django, trial and unittest integration. @@ -6934,7 +7983,7 @@ Bug fixes: or through plugin hooks. Also introduce a "--strict" option which will treat unregistered markers as errors allowing to avoid typos and maintain a well described set of markers - for your test suite. See exaples at http://pytest.org/en/latest/mark.html + for your test suite. See exaples at http://pytest.org/en/stable/mark.html and its links. - issue50: introduce "-m marker" option to select tests based on markers (this is a stricter and more predictable version of '-k' in that "-m" @@ -7117,7 +8166,7 @@ Bug fixes: - refinements to "collecting" output on non-ttys - refine internal plugin registration and --traceconfig output - introduce a mechanism to prevent/unregister plugins from the - command line, see http://pytest.org/en/latest/plugins.html#cmdunregister + command line, see http://pytest.org/en/stable/plugins.html#cmdunregister - activate resultlog plugin by default - fix regression wrt yielded tests which due to the collection-before-running semantics were not @@ -7515,7 +8564,7 @@ Bug fixes: - fix assert reinterpreation that sees a call containing "keyword=..." - fix issue66: invoke pytest_sessionstart and pytest_sessionfinish - hooks on slaves during dist-testing, report module/session teardown + hooks on worker nodes during dist-testing, report module/session teardown hooks correctly. - fix issue65: properly handle dist-testing if no diff --git a/doc/en/conf.py b/doc/en/conf.py index 3da401f892e..2f3a2baf44b 100644 --- a/doc/en/conf.py +++ b/doc/en/conf.py @@ -15,11 +15,13 @@ # # The full version, including alpha/beta/rc tags. # The short X.Y version. +import ast import os import sys +from typing import List +from typing import TYPE_CHECKING from _pytest import __version__ as version -from _pytest.compat import TYPE_CHECKING if TYPE_CHECKING: import sphinx.application @@ -43,6 +45,7 @@ # Add any Sphinx extension module names here, as strings. They can be extensions # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. extensions = [ + "pallets_sphinx_themes", "pygments_pytest", "sphinx.ext.autodoc", "sphinx.ext.autosummary", @@ -142,7 +145,7 @@ # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. -html_theme_options = {"index_logo": None} +# html_theme_options = {"index_logo": None} # Add any paths that contain custom themes here, relative to this directory. # html_theme_path = [] @@ -343,7 +346,10 @@ # Example configuration for intersphinx: refer to the Python standard library. -intersphinx_mapping = {"python": ("https://docs.python.org/3", None)} +intersphinx_mapping = { + "pluggy": ("https://pluggy.readthedocs.io/en/latest", None), + "python": ("https://docs.python.org/3", None), +} def configure_logging(app: "sphinx.application.Sphinx") -> None: @@ -386,4 +392,30 @@ def setup(app: "sphinx.application.Sphinx") -> None: indextemplate="pair: %s; configuration value", ) + app.add_object_type( + "globalvar", + "globalvar", + objname="global variable interpreted by pytest", + indextemplate="pair: %s; global variable interpreted by pytest", + ) + configure_logging(app) + + # Make Sphinx mark classes with "final" when decorated with @final. + # We need this because we import final from pytest._compat, not from + # typing (for Python < 3.8 compat), so Sphinx doesn't detect it. + # To keep things simple we accept any `@final` decorator. + # Ref: https://github.com/pytest-dev/pytest/pull/7780 + import sphinx.pycode.ast + import sphinx.pycode.parser + + original_is_final = sphinx.pycode.parser.VariableCommentPicker.is_final + + def patched_is_final(self, decorators: List[ast.expr]) -> bool: + if original_is_final(self, decorators): + return True + return any( + sphinx.pycode.ast.unparse(decorator) == "final" for decorator in decorators + ) + + sphinx.pycode.parser.VariableCommentPicker.is_final = patched_is_final diff --git a/doc/en/contents.rst b/doc/en/contents.rst index c623d0602ab..58a08744ced 100644 --- a/doc/en/contents.rst +++ b/doc/en/contents.rst @@ -38,7 +38,6 @@ Full pytest documentation customize example/index bash-completion - faq backwards-compatibility deprecations diff --git a/doc/en/customize.rst b/doc/en/customize.rst index 9554ab7b518..9f7c365dc45 100644 --- a/doc/en/customize.rst +++ b/doc/en/customize.rst @@ -14,15 +14,112 @@ configurations files by using the general help option: This will display command line and configuration file settings which were registered by installed plugins. +.. _`config file formats`: + +Configuration file formats +-------------------------- + +Many :ref:`pytest settings ` can be set in a *configuration file*, which +by convention resides on the root of your repository or in your +tests folder. + +A quick example of the configuration files supported by pytest: + +pytest.ini +~~~~~~~~~~ + +``pytest.ini`` files take precedence over other files, even when empty. + +.. code-block:: ini + + # pytest.ini + [pytest] + minversion = 6.0 + addopts = -ra -q + testpaths = + tests + integration + + +pyproject.toml +~~~~~~~~~~~~~~ + +.. versionadded:: 6.0 + +``pyproject.toml`` are considered for configuration when they contain a ``tool.pytest.ini_options`` table. + +.. code-block:: toml + + # pyproject.toml + [tool.pytest.ini_options] + minversion = "6.0" + addopts = "-ra -q" + testpaths = [ + "tests", + "integration", + ] + +.. note:: + + One might wonder why ``[tool.pytest.ini_options]`` instead of ``[tool.pytest]`` as is the + case with other tools. + + The reason is that the pytest team intends to fully utilize the rich TOML data format + for configuration in the future, reserving the ``[tool.pytest]`` table for that. + The ``ini_options`` table is being used, for now, as a bridge between the existing + ``.ini`` configuration system and the future configuration format. + +tox.ini +~~~~~~~ + +``tox.ini`` files are the configuration files of the `tox `__ project, +and can also be used to hold pytest configuration if they have a ``[pytest]`` section. + +.. code-block:: ini + + # tox.ini + [pytest] + minversion = 6.0 + addopts = -ra -q + testpaths = + tests + integration + + +setup.cfg +~~~~~~~~~ + +``setup.cfg`` files are general purpose configuration files, used originally by `distutils `__, and can also be used to hold pytest configuration +if they have a ``[tool:pytest]`` section. + +.. code-block:: ini + + # setup.cfg + [tool:pytest] + minversion = 6.0 + addopts = -ra -q + testpaths = + tests + integration + +.. warning:: + + Usage of ``setup.cfg`` is not recommended unless for very simple use cases. ``.cfg`` + files use a different parser than ``pytest.ini`` and ``tox.ini`` which might cause hard to track + down problems. + When possible, it is recommended to use the latter files, or ``pyproject.toml``, to hold your + pytest configuration. + + .. _rootdir: -.. _inifiles: +.. _configfiles: -Initialization: determining rootdir and inifile ------------------------------------------------ +Initialization: determining rootdir and configfile +-------------------------------------------------- pytest determines a ``rootdir`` for each test run which depends on the command line arguments (specified test files, paths) and on -the existence of *ini-files*. The determined ``rootdir`` and *ini-file* are +the existence of configuration files. The determined ``rootdir`` and ``configfile`` are printed as part of the pytest header during startup. Here's a summary what ``pytest`` uses ``rootdir`` for: @@ -39,56 +136,61 @@ Here's a summary what ``pytest`` uses ``rootdir`` for: influence how modules are imported. See :ref:`pythonpath` for more details. The ``--rootdir=path`` command-line option can be used to force a specific directory. -The directory passed may contain environment variables when it is used in conjunction -with ``addopts`` in a ``pytest.ini`` file. +Note that contrary to other command-line options, ``--rootdir`` cannot be used with +:confval:`addopts` inside ``pytest.ini`` because the ``rootdir`` is used to *find* ``pytest.ini`` +already. Finding the ``rootdir`` ~~~~~~~~~~~~~~~~~~~~~~~ Here is the algorithm which finds the rootdir from ``args``: -- determine the common ancestor directory for the specified ``args`` that are +- Determine the common ancestor directory for the specified ``args`` that are recognised as paths that exist in the file system. If no such paths are found, the common ancestor directory is set to the current working directory. -- look for ``pytest.ini``, ``tox.ini`` and ``setup.cfg`` files in the ancestor - directory and upwards. If one is matched, it becomes the ini-file and its - directory becomes the rootdir. +- Look for ``pytest.ini``, ``pyproject.toml``, ``tox.ini``, and ``setup.cfg`` files in the ancestor + directory and upwards. If one is matched, it becomes the ``configfile`` and its + directory becomes the ``rootdir``. -- if no ini-file was found, look for ``setup.py`` upwards from the common +- If no configuration file was found, look for ``setup.py`` upwards from the common ancestor directory to determine the ``rootdir``. -- if no ``setup.py`` was found, look for ``pytest.ini``, ``tox.ini`` and +- If no ``setup.py`` was found, look for ``pytest.ini``, ``pyproject.toml``, ``tox.ini``, and ``setup.cfg`` in each of the specified ``args`` and upwards. If one is - matched, it becomes the ini-file and its directory becomes the rootdir. + matched, it becomes the ``configfile`` and its directory becomes the ``rootdir``. -- if no ini-file was found, use the already determined common ancestor as root +- If no ``configfile`` was found, use the already determined common ancestor as root directory. This allows the use of pytest in structures that are not part of - a package and don't have any particular ini-file configuration. + a package and don't have any particular configuration file. If no ``args`` are given, pytest collects test below the current working -directory and also starts determining the rootdir from there. +directory and also starts determining the ``rootdir`` from there. -:warning: custom pytest plugin commandline arguments may include a path, as in - ``pytest --log-output ../../test.log args``. Then ``args`` is mandatory, - otherwise pytest uses the folder of test.log for rootdir determination - (see also `issue 1435 `_). - A dot ``.`` for referencing to the current working directory is also - possible. +Files will only be matched for configuration if: + +* ``pytest.ini``: will always match and take precedence, even if empty. +* ``pyproject.toml``: contains a ``[tool.pytest.ini_options]`` table. +* ``tox.ini``: contains a ``[pytest]`` section. +* ``setup.cfg``: contains a ``[tool:pytest]`` section. + +The files are considered in the order above. Options from multiple ``configfiles`` candidates +are never merged - the first match wins. -Note that an existing ``pytest.ini`` file will always be considered a match, -whereas ``tox.ini`` and ``setup.cfg`` will only match if they contain a -``[pytest]`` or ``[tool:pytest]`` section, respectively. Options from multiple ini-files candidates are never -merged - the first one wins (``pytest.ini`` always wins, even if it does not -contain a ``[pytest]`` section). +The internal :class:`Config <_pytest.config.Config>` object (accessible via hooks or through the :fixture:`pytestconfig` fixture) +will subsequently carry these attributes: -The ``config`` object will subsequently carry these attributes: +- :attr:`config.rootpath <_pytest.config.Config.rootpath>`: the determined root directory, guaranteed to exist. -- ``config.rootdir``: the determined root directory, guaranteed to exist. +- :attr:`config.inipath <_pytest.config.Config.inipath>`: the determined ``configfile``, may be ``None`` + (it is named ``inipath`` for historical reasons). -- ``config.inifile``: the determined ini-file, may be ``None``. +.. versionadded:: 6.1 + The ``config.rootpath`` and ``config.inipath`` properties. They are :class:`pathlib.Path` + versions of the older ``config.rootdir`` and ``config.inifile``, which have type + ``py.path.local``, and still exist for backward compatibility. -The rootdir is used as a reference directory for constructing test +The ``rootdir`` is used as a reference directory for constructing test addresses ("nodeids") and can be used also by plugins for storing per-testrun information. @@ -99,73 +201,36 @@ Example: pytest path/to/testdir path/other/ will determine the common ancestor as ``path`` and then -check for ini-files as follows: +check for configuration files as follows: .. code-block:: text # first look for pytest.ini files path/pytest.ini - path/tox.ini # must also contain [pytest] section to match - path/setup.cfg # must also contain [tool:pytest] section to match + path/pyproject.toml # must contain a [tool.pytest.ini_options] table to match + path/tox.ini # must contain [pytest] section to match + path/setup.cfg # must contain [tool:pytest] section to match pytest.ini - ... # all the way down to the root + ... # all the way up to the root # now look for setup.py path/setup.py setup.py - ... # all the way down to the root - - -.. _`how to change command line options defaults`: -.. _`adding default options`: - - - -How to change command line options defaults ------------------------------------------------- - -It can be tedious to type the same series of command line options -every time you use ``pytest``. For example, if you always want to see -detailed info on skipped and xfailed tests, as well as have terser "dot" -progress output, you can write it into a configuration file: - -.. code-block:: ini - - # content of pytest.ini or tox.ini - [pytest] - addopts = -ra -q - - # content of setup.cfg - [tool:pytest] - addopts = -ra -q - -Alternatively, you can set a ``PYTEST_ADDOPTS`` environment variable to add command -line options while the environment is in use: - -.. code-block:: bash - - export PYTEST_ADDOPTS="-v" - -Here's how the command-line is built in the presence of ``addopts`` or the environment variable: - -.. code-block:: text - - $PYTEST_ADDOPTS - -So if the user executes in the command-line: + ... # all the way up to the root -.. code-block:: bash - - pytest -m slow -The actual command line executed is: +.. warning:: -.. code-block:: bash + Custom pytest plugin commandline arguments may include a path, as in + ``pytest --log-output ../../test.log args``. Then ``args`` is mandatory, + otherwise pytest uses the folder of test.log for rootdir determination + (see also `issue 1435 `_). + A dot ``.`` for referencing to the current working directory is also + possible. - pytest -ra -q -v -m slow -Note that as usual for other command-line applications, in case of conflicting options the last one wins, so the example -above will show verbose output because ``-v`` overwrites ``-q``. +.. _`how to change command line options defaults`: +.. _`adding default options`: Builtin configuration file options diff --git a/doc/en/deprecations.rst b/doc/en/deprecations.rst index 732f92985f1..5ef1053e0b4 100644 --- a/doc/en/deprecations.rst +++ b/doc/en/deprecations.rst @@ -16,73 +16,88 @@ Deprecated Features ------------------- Below is a complete list of all pytest features which are considered deprecated. Using those features will issue -:class:`_pytest.warning_types.PytestWarning` or subclasses, which can be filtered using -:ref:`standard warning filters `. +:class:`PytestWarning` or subclasses, which can be filtered using :ref:`standard warning filters `. +The ``--strict`` command-line option +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -``--no-print-logs`` command-line option -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +.. deprecated:: 6.2 -.. deprecated:: 5.4 +The ``--strict`` command-line option has been deprecated in favor of ``--strict-markers``, which +better conveys what the option does. +We have plans to maybe in the future to reintroduce ``--strict`` and make it an encompassing +flag for all strictness related options (``--strict-markers`` and ``--strict-config`` +at the moment, more might be introduced in the future). -Option ``--no-print-logs`` is deprecated and meant to be removed in a future release. If you use ``--no-print-logs``, please try out ``--show-capture`` and -provide feedback. -``--show-capture`` command-line option was added in ``pytest 3.5.0` and allows to specify how to -display captured output when tests fail: ``no``, ``stdout``, ``stderr``, ``log`` or ``all`` (the default). +The ``yield_fixture`` function/decorator +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +.. deprecated:: 6.2 +``pytest.yield_fixture`` is a deprecated alias for :func:`pytest.fixture`. -Node Construction changed to ``Node.from_parent`` -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +It has been so for a very long time, so can be search/replaced safely. -.. deprecated:: 5.4 -The construction of nodes new should use the named constructor ``from_parent``. -This limitation in api surface intends to enable better/simpler refactoring of the collection tree. +The ``pytest_warning_captured`` hook +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +.. deprecated:: 6.0 -``junit_family`` default value change to "xunit2" +This hook has an `item` parameter which cannot be serialized by ``pytest-xdist``. + +Use the ``pytest_warning_recored`` hook instead, which replaces the ``item`` parameter +by a ``nodeid`` parameter. + +The ``pytest.collect`` module +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. deprecated:: 6.0 + +The ``pytest.collect`` module is no longer part of the public API, all its names +should now be imported from ``pytest`` directly instead. + + +The ``pytest._fillfuncargs`` function ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -.. deprecated:: 5.2 +.. deprecated:: 6.0 -The default value of ``junit_family`` option will change to ``xunit2`` in pytest 6.0, given -that this is the version supported by default in modern tools that manipulate this type of file. +This function was kept for backward compatibility with an older plugin. -In order to smooth the transition, pytest will issue a warning in case the ``--junitxml`` option -is given in the command line but ``junit_family`` is not explicitly configured in ``pytest.ini``:: +It's functionality is not meant to be used directly, but if you must replace +it, use `function._request._fillfixtures()` instead, though note this is not +a public API and may break in the future. - PytestDeprecationWarning: The 'junit_family' default value will change to 'xunit2' in pytest 6.0. - Add 'junit_family=legacy' to your pytest.ini file to silence this warning and make your suite compatible. -In order to silence this warning, users just need to configure the ``junit_family`` option explicitly: +Removed Features +---------------- -.. code-block:: ini +As stated in our :ref:`backwards-compatibility` policy, deprecated features are removed only in major releases after +an appropriate period of deprecation has passed. - [pytest] - junit_family=legacy +``--no-print-logs`` command-line option +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +.. deprecated:: 5.4 +.. versionremoved:: 6.0 -``funcargnames`` alias for ``fixturenames`` -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -.. deprecated:: 5.0 +The ``--no-print-logs`` option and ``log_print`` ini setting are removed. If +you used them, please use ``--show-capture`` instead. -The ``FixtureRequest``, ``Metafunc``, and ``Function`` classes track the names of -their associated fixtures, with the aptly-named ``fixturenames`` attribute. +A ``--show-capture`` command-line option was added in ``pytest 3.5.0`` which allows to specify how to +display captured output when tests fail: ``no``, ``stdout``, ``stderr``, ``log`` or ``all`` (the default). -Prior to pytest 2.3, this attribute was named ``funcargnames``, and we have kept -that as an alias since. It is finally due for removal, as it is often confusing -in places where we or plugin authors must distinguish between fixture names and -names supplied by non-fixture things such as ``pytest.mark.parametrize``. Result log (``--result-log``) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. deprecated:: 4.0 +.. versionremoved:: 6.0 The ``--result-log`` option produces a stream of test reports which can be analysed at runtime, but it uses a custom format which requires users to implement their own @@ -91,14 +106,21 @@ parser. The `pytest-reportlog `__ plugin provides a ``--report-log`` option, a more standard and extensible alternative, producing one JSON object per-line, and should cover the same use cases. Please try it out and provide feedback. -The plan is remove the ``--result-log`` option in pytest 6.0 if ``pytest-reportlog`` proves satisfactory -to all users and is deemed stable. The ``pytest-reportlog`` plugin might even be merged into the core +The ``pytest-reportlog`` plugin might even be merged into the core at some point, depending on the plans for the plugins and number of users using it. +``pytest_collect_directory`` hook +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. versionremoved:: 6.0 + +The ``pytest_collect_directory`` has not worked properly for years (it was called +but the results were ignored). Users may consider using :func:`pytest_collection_modifyitems <_pytest.hookspec.pytest_collection_modifyitems>` instead. + TerminalReporter.writer ~~~~~~~~~~~~~~~~~~~~~~~ -.. deprecated:: 5.4 +.. versionremoved:: 6.0 The ``TerminalReporter.writer`` attribute has been deprecated and should no longer be used. This was inadvertently exposed as part of the public API of that plugin and ties it too much @@ -107,12 +129,91 @@ with ``py.io.TerminalWriter``. Plugins that used ``TerminalReporter.writer`` directly should instead use ``TerminalReporter`` methods that provide the same functionality. +``junit_family`` default value change to "xunit2" +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Removed Features ----------------- +.. versionchanged:: 6.0 -As stated in our :ref:`backwards-compatibility` policy, deprecated features are removed only in major releases after -an appropriate period of deprecation has passed. +The default value of ``junit_family`` option will change to ``xunit2`` in pytest 6.0, which +is an update of the old ``xunit1`` format and is supported by default in modern tools +that manipulate this type of file (for example, Jenkins, Azure Pipelines, etc.). + +Users are recommended to try the new ``xunit2`` format and see if their tooling that consumes the JUnit +XML file supports it. + +To use the new format, update your ``pytest.ini``: + +.. code-block:: ini + + [pytest] + junit_family=xunit2 + +If you discover that your tooling does not support the new format, and want to keep using the +legacy version, set the option to ``legacy`` instead: + +.. code-block:: ini + + [pytest] + junit_family=legacy + +By using ``legacy`` you will keep using the legacy/xunit1 format when upgrading to +pytest 6.0, where the default format will be ``xunit2``. + +In order to let users know about the transition, pytest will issue a warning in case +the ``--junitxml`` option is given in the command line but ``junit_family`` is not explicitly +configured in ``pytest.ini``. + +Services known to support the ``xunit2`` format: + +* `Jenkins `__ with the `JUnit `__ plugin. +* `Azure Pipelines `__. + +Node Construction changed to ``Node.from_parent`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. versionchanged:: 6.0 + +The construction of nodes now should use the named constructor ``from_parent``. +This limitation in api surface intends to enable better/simpler refactoring of the collection tree. + +This means that instead of :code:`MyItem(name="foo", parent=collector, obj=42)` +one now has to invoke :code:`MyItem.from_parent(collector, name="foo")`. + +Plugins that wish to support older versions of pytest and suppress the warning can use +`hasattr` to check if `from_parent` exists in that version: + +.. code-block:: python + + def pytest_pycollect_makeitem(collector, name, obj): + if hasattr(MyItem, "from_parent"): + item = MyItem.from_parent(collector, name="foo") + item.obj = 42 + return item + else: + return MyItem(name="foo", parent=collector, obj=42) + +Note that ``from_parent`` should only be called with keyword arguments for the parameters. + + +``pytest.fixture`` arguments are keyword only +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. versionremoved:: 6.0 + +Passing arguments to pytest.fixture() as positional arguments has been removed - pass them by keyword instead. + +``funcargnames`` alias for ``fixturenames`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. versionremoved:: 6.0 + +The ``FixtureRequest``, ``Metafunc``, and ``Function`` classes track the names of +their associated fixtures, with the aptly-named ``fixturenames`` attribute. + +Prior to pytest 2.3, this attribute was named ``funcargnames``, and we have kept +that as an alias since. It is finally due for removal, as it is often confusing +in places where we or plugin authors must distinguish between fixture names and +names supplied by non-fixture things such as ``pytest.mark.parametrize``. ``pytest.config`` global @@ -296,7 +397,7 @@ Metafunc.addcall .. versionremoved:: 4.0 -:meth:`_pytest.python.Metafunc.addcall` was a precursor to the current parametrized mechanism. Users should use +``_pytest.python.Metafunc.addcall`` was a precursor to the current parametrized mechanism. Users should use :meth:`_pytest.python.Metafunc.parametrize` instead. Example: @@ -343,7 +444,7 @@ This should be updated to make use of standard fixture mechanisms: session.close() -You can consult `funcarg comparison section in the docs `_ for +You can consult `funcarg comparison section in the docs `_ for more information. @@ -352,7 +453,7 @@ pytest_plugins in non-top-level conftest files .. versionremoved:: 4.0 -Defining ``pytest_plugins`` is now deprecated in non-top-level conftest.py +Defining :globalvar:`pytest_plugins` is now deprecated in non-top-level conftest.py files because they will activate referenced plugins *globally*, which is surprising because for all other pytest features ``conftest.py`` files are only *active* for tests at or below it. @@ -531,7 +632,7 @@ This has been documented as deprecated for years, but only now we are actually e .. versionremoved:: 4.0 -As part of a large :ref:`marker-revamp`, :meth:`_pytest.nodes.Node.get_marker` is deprecated. See +As part of a large :ref:`marker-revamp`, ``_pytest.nodes.Node.get_marker`` is removed. See :ref:`the documentation ` on tips on how to update your code. @@ -575,40 +676,3 @@ As a stopgap measure, plugin authors may still inject their names into pytest's def pytest_configure(): pytest.my_symbol = MySymbol() - - - - -Reinterpretation mode (``--assert=reinterp``) -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -.. versionremoved:: 3.0 - -Reinterpretation mode has now been removed and only plain and rewrite -mode are available, consequently the ``--assert=reinterp`` option is -no longer available. This also means files imported from plugins or -``conftest.py`` will not benefit from improved assertions by -default, you should use ``pytest.register_assert_rewrite()`` to -explicitly turn on assertion rewriting for those files. - -Removed command-line options -~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -.. versionremoved:: 3.0 - -The following deprecated commandline options were removed: - -* ``--genscript``: no longer supported; -* ``--no-assert``: use ``--assert=plain`` instead; -* ``--nomagic``: use ``--assert=plain`` instead; -* ``--report``: use ``-r`` instead; - -py.test-X* entry points -~~~~~~~~~~~~~~~~~~~~~~~ - -.. versionremoved:: 3.0 - -Removed all ``py.test-X*`` entry points. The versioned, suffixed entry points -were never documented and a leftover from a pre-virtualenv era. These entry -points also created broken entry points in wheels, so removing them also -removes a source of confusion for users. diff --git a/doc/en/development_guide.rst b/doc/en/development_guide.rst index 2f9762f2a84..77076d4834e 100644 --- a/doc/en/development_guide.rst +++ b/doc/en/development_guide.rst @@ -2,59 +2,6 @@ Development Guide ================= -Some general guidelines regarding development in pytest for maintainers and contributors. Nothing here -is set in stone and can't be changed, feel free to suggest improvements or changes in the workflow. - - -Code Style ----------- - -* `PEP-8 `_ -* `flake8 `_ for quality checks -* `invoke `_ to automate development tasks - - -Branches --------- - -We have two long term branches: - -* ``master``: contains the code for the next bug-fix release. -* ``features``: contains the code with new features for the next minor release. - -The official repository usually does not contain topic branches, developers and contributors should create topic -branches in their own forks. - -Exceptions can be made for cases where more than one contributor is working on the same -topic or where it makes sense to use some automatic capability of the main repository, such as automatic docs from -`readthedocs `_ for a branch dealing with documentation refactoring. - -Issues ------- - -Any question, feature, bug or proposal is welcome as an issue. Users are encouraged to use them whenever they need. - -GitHub issues should use labels to categorize them. Labels should be created sporadically, to fill a niche; we should -avoid creating labels just for the sake of creating them. - -Each label should include a description in the GitHub's interface stating its purpose. - -Labels are managed using `labels `_. All the labels in the repository -are kept in ``.github/labels.toml``, so any changes should be via PRs to that file. -After a PR is accepted and merged, one of the maintainers must manually synchronize the labels file with the -GitHub repository. - -Temporary labels -~~~~~~~~~~~~~~~~ - -To classify issues for a special event it is encouraged to create a temporary label. This helps those involved to find -the relevant issues to work on. Examples of that are sprints in Python events or global hacking events. - -* ``temporary: EP2017 sprint``: candidate issues or PRs tackled during the EuroPython 2017 - -Issues created at those events should have other relevant labels added as well. - -Those labels should be removed after they are no longer relevant. - - -.. include:: ../../RELEASING.rst +The contributing guidelines are to be found :ref:`here `. +The release procedure for pytest is documented on +`GitHub `_. diff --git a/doc/en/doctest.rst b/doc/en/doctest.rst index b73cc994ab3..f8d010679f0 100644 --- a/doc/en/doctest.rst +++ b/doc/en/doctest.rst @@ -2,13 +2,13 @@ Doctest integration for modules and test files ========================================================= -By default all files matching the ``test*.txt`` pattern will +By default, all files matching the ``test*.txt`` pattern will be run through the python standard ``doctest`` module. You can change the pattern by issuing: .. code-block:: bash - pytest --doctest-glob='*.rst' + pytest --doctest-glob="*.rst" on the command line. ``--doctest-glob`` can be given multiple times in the command-line. @@ -29,7 +29,7 @@ then you can just invoke ``pytest`` directly: $ pytest =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-5.x.y, py-1.x.y, pluggy-0.x.y + platform linux -- Python 3.x.y, pytest-6.x.y, py-1.x.y, pluggy-0.x.y cachedir: $PYTHON_PREFIX/.pytest_cache rootdir: $REGENDOC_TMPDIR collected 1 item @@ -58,7 +58,7 @@ and functions, including from test modules: $ pytest --doctest-modules =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-5.x.y, py-1.x.y, pluggy-0.x.y + platform linux -- Python 3.x.y, pytest-6.x.y, py-1.x.y, pluggy-0.x.y cachedir: $PYTHON_PREFIX/.pytest_cache rootdir: $REGENDOC_TMPDIR collected 2 items @@ -77,15 +77,6 @@ putting them into a pytest.ini file like this: [pytest] addopts = --doctest-modules -.. note:: - - The builtin pytest doctest supports only ``doctest`` blocks, but if you are looking - for more advanced checking over *all* your documentation, - including doctests, ``.. codeblock:: python`` Sphinx directive support, - and any other examples your documentation may include, you may wish to - consider `Sybil `__. - It provides pytest integration out of the box. - Encoding -------- @@ -113,7 +104,7 @@ lengthy exception stack traces you can just write: .. code-block:: ini [pytest] - doctest_optionflags= NORMALIZE_WHITESPACE IGNORE_EXCEPTION_DETAIL + doctest_optionflags = NORMALIZE_WHITESPACE IGNORE_EXCEPTION_DETAIL Alternatively, options can be enabled by an inline comment in the doc test itself: @@ -206,7 +197,11 @@ It is possible to use fixtures using the ``getfixture`` helper: >>> ... >>> -Also, :ref:`usefixtures` and :ref:`autouse` fixtures are supported +Note that the fixture needs to be defined in a place visible by pytest, for example, a `conftest.py` +file or plugin; normal python files containing docstrings are not normally scanned for fixtures +unless explicitly configured by :confval:`python_files`. + +Also, the :ref:`usefixtures ` mark and fixtures marked as :ref:`autouse ` are supported when executing text doctest files. @@ -249,12 +244,32 @@ Note that like the normal ``conftest.py``, the fixtures are discovered in the di Meaning that if you put your doctest with your source code, the relevant conftest.py needs to be in the same directory tree. Fixtures will not be discovered in a sibling directory tree! -Skipping tests dynamically -^^^^^^^^^^^^^^^^^^^^^^^^^^ +Skipping tests +^^^^^^^^^^^^^^ + +For the same reasons one might want to skip normal tests, it is also possible to skip +tests inside doctests. + +To skip a single check inside a doctest you can use the standard +`doctest.SKIP `__ directive: + +.. code-block:: python + + def test_random(y): + """ + >>> random.random() # doctest: +SKIP + 0.156231223 + + >>> 1 + 1 + 2 + """ + +This will skip the first check, but not the second. -.. versionadded:: 4.4 +pytest also allows using the standard pytest functions :func:`pytest.skip` and +:func:`pytest.xfail` inside doctests, which might be useful because you can +then skip/xfail tests based on external conditions: -You can use ``pytest.skip`` to dynamically skip doctests. For example: .. code-block:: text @@ -262,3 +277,35 @@ You can use ``pytest.skip`` to dynamically skip doctests. For example: >>> if sys.platform.startswith('win'): ... pytest.skip('this doctest does not work on Windows') ... + >>> import fcntl + >>> ... + +However using those functions is discouraged because it reduces the readability of the +docstring. + +.. note:: + + :func:`pytest.skip` and :func:`pytest.xfail` behave differently depending + if the doctests are in a Python file (in docstrings) or a text file containing + doctests intermingled with text: + + * Python modules (docstrings): the functions only act in that specific docstring, + letting the other docstrings in the same module execute as normal. + + * Text files: the functions will skip/xfail the checks for the rest of the entire + file. + + +Alternatives +------------ + +While the built-in pytest support provides a good set of functionalities for using +doctests, if you use them extensively you might be interested in those external packages +which add many more features, and include pytest integration: + +* `pytest-doctestplus `__: provides + advanced doctest support and enables the testing of reStructuredText (".rst") files. + +* `Sybil `__: provides a way to test examples in + your documentation by parsing them from the documentation source and evaluating + the parsed examples as part of your normal test run. diff --git a/doc/en/example/assertion/failure_demo.py b/doc/en/example/assertion/failure_demo.py index 26454e48d76..abb9bce5097 100644 --- a/doc/en/example/assertion/failure_demo.py +++ b/doc/en/example/assertion/failure_demo.py @@ -1,4 +1,3 @@ -import _pytest._code import pytest from pytest import raises @@ -167,7 +166,7 @@ def test_raises(self): raises(TypeError, int, s) def test_raises_doesnt(self): - raises(IOError, int, "3") + raises(OSError, int, "3") def test_raise(self): raise ValueError("demo error") @@ -177,7 +176,7 @@ def test_tupleerror(self): def test_reinterpret_fails_with_print_for_the_fun_of_it(self): items = [1, 2, 3] - print("items is {!r}".format(items)) + print(f"items is {items!r}") a, b = items.pop() def test_some_error(self): @@ -197,7 +196,7 @@ def test_dynamic_compile_shows_nicely(): name = "abc-123" spec = importlib.util.spec_from_loader(name, loader=None) module = importlib.util.module_from_spec(spec) - code = _pytest._code.compile(src, name, "exec") + code = compile(src, name, "exec") exec(code, module.__dict__) sys.modules[name] = module module.foo() diff --git a/doc/en/example/assertion/global_testmodule_config/conftest.py b/doc/en/example/assertion/global_testmodule_config/conftest.py index da89047fe09..7cdf18cdbc1 100644 --- a/doc/en/example/assertion/global_testmodule_config/conftest.py +++ b/doc/en/example/assertion/global_testmodule_config/conftest.py @@ -1,8 +1,8 @@ -import py +import os.path import pytest -mydir = py.path.local(__file__).dirpath() +mydir = os.path.dirname(__file__) def pytest_runtest_setup(item): @@ -11,4 +11,4 @@ def pytest_runtest_setup(item): return mod = item.getparent(pytest.Module).obj if hasattr(mod, "hello"): - print("mod.hello {!r}".format(mod.hello)) + print(f"mod.hello {mod.hello!r}") diff --git a/doc/en/example/assertion/test_failures.py b/doc/en/example/assertion/test_failures.py index 30ebc72dc37..eda06dfc598 100644 --- a/doc/en/example/assertion/test_failures.py +++ b/doc/en/example/assertion/test_failures.py @@ -1,13 +1,13 @@ -import py +import os.path +import shutil -failure_demo = py.path.local(__file__).dirpath("failure_demo.py") +failure_demo = os.path.join(os.path.dirname(__file__), "failure_demo.py") pytest_plugins = ("pytester",) def test_failure_demo_fails_properly(testdir): - target = testdir.tmpdir.join(failure_demo.basename) - failure_demo.copy(target) - failure_demo.copy(testdir.tmpdir.join(failure_demo.basename)) + target = testdir.tmpdir.join(os.path.basename(failure_demo)) + shutil.copy(failure_demo, target) result = testdir.runpytest(target, syspathinsert=True) result.stdout.fnmatch_lines(["*44 failed*"]) assert result.ret != 0 diff --git a/doc/en/example/index.rst b/doc/en/example/index.rst index f63cb822a41..6876082d418 100644 --- a/doc/en/example/index.rst +++ b/doc/en/example/index.rst @@ -15,7 +15,7 @@ For basic examples, see - :doc:`../getting-started` for basic introductory examples - :ref:`assert` for basic assertion examples -- :ref:`fixtures` for basic fixture/setup examples +- :ref:`Fixtures ` for basic fixture/setup examples - :ref:`parametrize` for basic test function parametrization - :doc:`../unittest` for basic unittest integration - :doc:`../nose` for basic nosetests integration diff --git a/doc/en/example/markers.rst b/doc/en/example/markers.rst index 467c2a2faf8..f0effa02631 100644 --- a/doc/en/example/markers.rst +++ b/doc/en/example/markers.rst @@ -45,7 +45,7 @@ You can then restrict a test run to only run tests marked with ``webtest``: $ pytest -v -m webtest =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-5.x.y, py-1.x.y, pluggy-0.x.y -- $PYTHON_PREFIX/bin/python + platform linux -- Python 3.x.y, pytest-6.x.y, py-1.x.y, pluggy-0.x.y -- $PYTHON_PREFIX/bin/python cachedir: $PYTHON_PREFIX/.pytest_cache rootdir: $REGENDOC_TMPDIR collecting ... collected 4 items / 3 deselected / 1 selected @@ -60,7 +60,7 @@ Or the inverse, running all tests except the webtest ones: $ pytest -v -m "not webtest" =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-5.x.y, py-1.x.y, pluggy-0.x.y -- $PYTHON_PREFIX/bin/python + platform linux -- Python 3.x.y, pytest-6.x.y, py-1.x.y, pluggy-0.x.y -- $PYTHON_PREFIX/bin/python cachedir: $PYTHON_PREFIX/.pytest_cache rootdir: $REGENDOC_TMPDIR collecting ... collected 4 items / 1 deselected / 3 selected @@ -82,7 +82,7 @@ tests based on their module, class, method, or function name: $ pytest -v test_server.py::TestClass::test_method =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-5.x.y, py-1.x.y, pluggy-0.x.y -- $PYTHON_PREFIX/bin/python + platform linux -- Python 3.x.y, pytest-6.x.y, py-1.x.y, pluggy-0.x.y -- $PYTHON_PREFIX/bin/python cachedir: $PYTHON_PREFIX/.pytest_cache rootdir: $REGENDOC_TMPDIR collecting ... collected 1 item @@ -97,7 +97,7 @@ You can also select on the class: $ pytest -v test_server.py::TestClass =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-5.x.y, py-1.x.y, pluggy-0.x.y -- $PYTHON_PREFIX/bin/python + platform linux -- Python 3.x.y, pytest-6.x.y, py-1.x.y, pluggy-0.x.y -- $PYTHON_PREFIX/bin/python cachedir: $PYTHON_PREFIX/.pytest_cache rootdir: $REGENDOC_TMPDIR collecting ... collected 1 item @@ -112,7 +112,7 @@ Or select multiple nodes: $ pytest -v test_server.py::TestClass test_server.py::test_send_http =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-5.x.y, py-1.x.y, pluggy-0.x.y -- $PYTHON_PREFIX/bin/python + platform linux -- Python 3.x.y, pytest-6.x.y, py-1.x.y, pluggy-0.x.y -- $PYTHON_PREFIX/bin/python cachedir: $PYTHON_PREFIX/.pytest_cache rootdir: $REGENDOC_TMPDIR collecting ... collected 2 items @@ -141,14 +141,14 @@ Or select multiple nodes: Using ``-k expr`` to select tests based on their name ------------------------------------------------------- -.. versionadded: 2.0/2.3.4 +.. versionadded:: 2.0/2.3.4 You can use the ``-k`` command line option to specify an expression which implements a substring match on the test names instead of the exact match on markers that ``-m`` provides. This makes it easy to select tests based on their names: -.. versionadded: 5.4 +.. versionchanged:: 5.4 The expression matching is now case-insensitive. @@ -156,7 +156,7 @@ The expression matching is now case-insensitive. $ pytest -v -k http # running with the above defined example module =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-5.x.y, py-1.x.y, pluggy-0.x.y -- $PYTHON_PREFIX/bin/python + platform linux -- Python 3.x.y, pytest-6.x.y, py-1.x.y, pluggy-0.x.y -- $PYTHON_PREFIX/bin/python cachedir: $PYTHON_PREFIX/.pytest_cache rootdir: $REGENDOC_TMPDIR collecting ... collected 4 items / 3 deselected / 1 selected @@ -171,7 +171,7 @@ And you can also run all tests except the ones that match the keyword: $ pytest -k "not send_http" -v =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-5.x.y, py-1.x.y, pluggy-0.x.y -- $PYTHON_PREFIX/bin/python + platform linux -- Python 3.x.y, pytest-6.x.y, py-1.x.y, pluggy-0.x.y -- $PYTHON_PREFIX/bin/python cachedir: $PYTHON_PREFIX/.pytest_cache rootdir: $REGENDOC_TMPDIR collecting ... collected 4 items / 1 deselected / 3 selected @@ -188,7 +188,7 @@ Or to select "http" and "quick" tests: $ pytest -k "http or quick" -v =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-5.x.y, py-1.x.y, pluggy-0.x.y -- $PYTHON_PREFIX/bin/python + platform linux -- Python 3.x.y, pytest-6.x.y, py-1.x.y, pluggy-0.x.y -- $PYTHON_PREFIX/bin/python cachedir: $PYTHON_PREFIX/.pytest_cache rootdir: $REGENDOC_TMPDIR collecting ... collected 4 items / 2 deselected / 2 selected @@ -198,20 +198,13 @@ Or to select "http" and "quick" tests: ===================== 2 passed, 2 deselected in 0.12s ====================== -.. note:: - - If you are using expressions such as ``"X and Y"`` then both ``X`` and ``Y`` - need to be simple non-keyword names. For example, ``"pass"`` or ``"from"`` - will result in SyntaxErrors because ``"-k"`` evaluates the expression using - Python's `eval`_ function. +You can use ``and``, ``or``, ``not`` and parentheses. -.. _`eval`: https://docs.python.org/3.6/library/functions.html#eval +In addition to the test's name, ``-k`` also matches the names of the test's parents (usually, the name of the file and class it's in), +attributes set on the test function, markers applied to it or its parents and any :attr:`extra keywords <_pytest.nodes.Node.extra_keyword_matches>` +explicitly added to it or its parents. - However, if the ``"-k"`` argument is a simple string, no such restrictions - apply. Also ``"-k 'not STRING'"`` has no restrictions. You can also - specify numbers like ``"-k 1.3"`` to match tests which are parametrized - with the float ``"1.3"``. Registering markers ------------------------------------- @@ -228,25 +221,30 @@ Registering markers for your test suite is simple: [pytest] markers = webtest: mark a test as a webtest. + slow: mark test as slow. + +Multiple custom markers can be registered, by defining each one in its own line, as shown in above example. -You can ask which markers exist for your test suite - the list includes our just defined ``webtest`` markers: +You can ask which markers exist for your test suite - the list includes our just defined ``webtest`` and ``slow`` markers: .. code-block:: pytest $ pytest --markers @pytest.mark.webtest: mark a test as a webtest. - @pytest.mark.filterwarnings(warning): add a warning filter to the given test. see https://docs.pytest.org/en/latest/warnings.html#pytest-mark-filterwarnings + @pytest.mark.slow: mark test as slow. + + @pytest.mark.filterwarnings(warning): add a warning filter to the given test. see https://docs.pytest.org/en/stable/warnings.html#pytest-mark-filterwarnings @pytest.mark.skip(reason=None): skip the given test function with an optional reason. Example: skip(reason="no way of currently testing this") skips the test. - @pytest.mark.skipif(condition): skip the given test function if eval(condition) results in a True value. Evaluation happens within the module global context. Example: skipif('sys.platform == "win32"') skips the test if we are on the win32 platform. see https://docs.pytest.org/en/latest/skipping.html + @pytest.mark.skipif(condition, ..., *, reason=...): skip the given test function if any of the conditions evaluate to True. Example: skipif(sys.platform == 'win32') skips the test if we are on the win32 platform. See https://docs.pytest.org/en/stable/reference.html#pytest-mark-skipif - @pytest.mark.xfail(condition, reason=None, run=True, raises=None, strict=False): mark the test function as an expected failure if eval(condition) has a True value. Optionally specify a reason for better reporting and run=False if you don't even want to execute the test function. If only specific exception(s) are expected, you can list them in raises, and if the test fails in other ways, it will be reported as a true failure. See https://docs.pytest.org/en/latest/skipping.html + @pytest.mark.xfail(condition, ..., *, reason=..., run=True, raises=None, strict=xfail_strict): mark the test function as an expected failure if any of the conditions evaluate to True. Optionally specify a reason for better reporting and run=False if you don't even want to execute the test function. If only specific exception(s) are expected, you can list them in raises, and if the test fails in other ways, it will be reported as a true failure. See https://docs.pytest.org/en/stable/reference.html#pytest-mark-xfail - @pytest.mark.parametrize(argnames, argvalues): call a test function multiple times passing in different arguments in turn. argvalues generally needs to be a list of values if argnames specifies only one name or a list of tuples of values if argnames specifies multiple names. Example: @parametrize('arg1', [1,2]) would lead to two calls of the decorated test function, one with arg1=1 and another with arg1=2.see https://docs.pytest.org/en/latest/parametrize.html for more info and examples. + @pytest.mark.parametrize(argnames, argvalues): call a test function multiple times passing in different arguments in turn. argvalues generally needs to be a list of values if argnames specifies only one name or a list of tuples of values if argnames specifies multiple names. Example: @parametrize('arg1', [1,2]) would lead to two calls of the decorated test function, one with arg1=1 and another with arg1=2.see https://docs.pytest.org/en/stable/parametrize.html for more info and examples. - @pytest.mark.usefixtures(fixturename1, fixturename2, ...): mark tests as needing all of the specified fixtures. see https://docs.pytest.org/en/latest/fixture.html#usefixtures + @pytest.mark.usefixtures(fixturename1, fixturename2, ...): mark tests as needing all of the specified fixtures. see https://docs.pytest.org/en/stable/fixture.html#usefixtures @pytest.mark.tryfirst: mark a hook implementation function such that the plugin machinery will try to call it first/as early as possible. @@ -292,17 +290,18 @@ its test methods: This is equivalent to directly applying the decorator to the two test functions. -Due to legacy reasons, it is possible to set the ``pytestmark`` attribute on a TestClass like this: - -.. code-block:: python +To apply marks at the module level, use the :globalvar:`pytestmark` global variable:: import pytest + pytestmark = pytest.mark.webtest +or multiple markers:: - class TestClass: - pytestmark = pytest.mark.webtest + pytestmark = [pytest.mark.webtest, pytest.mark.slowtest] -or if you need to use multiple markers you can use a list: + +Due to legacy reasons, before class decorators were introduced, it is possible to set the +:globalvar:`pytestmark` attribute on a test class like this: .. code-block:: python @@ -310,19 +309,7 @@ or if you need to use multiple markers you can use a list: class TestClass: - pytestmark = [pytest.mark.webtest, pytest.mark.slowtest] - -You can also set a module level marker:: - - import pytest - pytestmark = pytest.mark.webtest - -or multiple markers:: - - pytestmark = [pytest.mark.webtest, pytest.mark.slowtest] - -in which case markers will be applied (in left-to-right order) to -all functions and methods defined in the module. + pytestmark = pytest.mark.webtest .. _`marking individual tests when using parametrize`: @@ -410,7 +397,7 @@ the test needs: $ pytest -E stage2 =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-5.x.y, py-1.x.y, pluggy-0.x.y + platform linux -- Python 3.x.y, pytest-6.x.y, py-1.x.y, pluggy-0.x.y cachedir: $PYTHON_PREFIX/.pytest_cache rootdir: $REGENDOC_TMPDIR collected 1 item @@ -425,7 +412,7 @@ and here is one that specifies exactly the environment needed: $ pytest -E stage1 =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-5.x.y, py-1.x.y, pluggy-0.x.y + platform linux -- Python 3.x.y, pytest-6.x.y, py-1.x.y, pluggy-0.x.y cachedir: $PYTHON_PREFIX/.pytest_cache rootdir: $REGENDOC_TMPDIR collected 1 item @@ -441,17 +428,17 @@ The ``--markers`` option always gives you a list of available markers: $ pytest --markers @pytest.mark.env(name): mark test to run only on named environment - @pytest.mark.filterwarnings(warning): add a warning filter to the given test. see https://docs.pytest.org/en/latest/warnings.html#pytest-mark-filterwarnings + @pytest.mark.filterwarnings(warning): add a warning filter to the given test. see https://docs.pytest.org/en/stable/warnings.html#pytest-mark-filterwarnings @pytest.mark.skip(reason=None): skip the given test function with an optional reason. Example: skip(reason="no way of currently testing this") skips the test. - @pytest.mark.skipif(condition): skip the given test function if eval(condition) results in a True value. Evaluation happens within the module global context. Example: skipif('sys.platform == "win32"') skips the test if we are on the win32 platform. see https://docs.pytest.org/en/latest/skipping.html + @pytest.mark.skipif(condition, ..., *, reason=...): skip the given test function if any of the conditions evaluate to True. Example: skipif(sys.platform == 'win32') skips the test if we are on the win32 platform. See https://docs.pytest.org/en/stable/reference.html#pytest-mark-skipif - @pytest.mark.xfail(condition, reason=None, run=True, raises=None, strict=False): mark the test function as an expected failure if eval(condition) has a True value. Optionally specify a reason for better reporting and run=False if you don't even want to execute the test function. If only specific exception(s) are expected, you can list them in raises, and if the test fails in other ways, it will be reported as a true failure. See https://docs.pytest.org/en/latest/skipping.html + @pytest.mark.xfail(condition, ..., *, reason=..., run=True, raises=None, strict=xfail_strict): mark the test function as an expected failure if any of the conditions evaluate to True. Optionally specify a reason for better reporting and run=False if you don't even want to execute the test function. If only specific exception(s) are expected, you can list them in raises, and if the test fails in other ways, it will be reported as a true failure. See https://docs.pytest.org/en/stable/reference.html#pytest-mark-xfail - @pytest.mark.parametrize(argnames, argvalues): call a test function multiple times passing in different arguments in turn. argvalues generally needs to be a list of values if argnames specifies only one name or a list of tuples of values if argnames specifies multiple names. Example: @parametrize('arg1', [1,2]) would lead to two calls of the decorated test function, one with arg1=1 and another with arg1=2.see https://docs.pytest.org/en/latest/parametrize.html for more info and examples. + @pytest.mark.parametrize(argnames, argvalues): call a test function multiple times passing in different arguments in turn. argvalues generally needs to be a list of values if argnames specifies only one name or a list of tuples of values if argnames specifies multiple names. Example: @parametrize('arg1', [1,2]) would lead to two calls of the decorated test function, one with arg1=1 and another with arg1=2.see https://docs.pytest.org/en/stable/parametrize.html for more info and examples. - @pytest.mark.usefixtures(fixturename1, fixturename2, ...): mark tests as needing all of the specified fixtures. see https://docs.pytest.org/en/latest/fixture.html#usefixtures + @pytest.mark.usefixtures(fixturename1, fixturename2, ...): mark tests as needing all of the specified fixtures. see https://docs.pytest.org/en/stable/fixture.html#usefixtures @pytest.mark.tryfirst: mark a hook implementation function such that the plugin machinery will try to call it first/as early as possible. @@ -557,7 +544,7 @@ Let's run this without capturing output and see what we get: . 1 passed in 0.12s -marking platform specific tests with pytest +Marking platform specific tests with pytest -------------------------------------------------------------- .. regendoc:wipe @@ -618,7 +605,7 @@ then you will see two tests skipped and two executed tests as expected: $ pytest -rs # this option reports skip reasons =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-5.x.y, py-1.x.y, pluggy-0.x.y + platform linux -- Python 3.x.y, pytest-6.x.y, py-1.x.y, pluggy-0.x.y cachedir: $PYTHON_PREFIX/.pytest_cache rootdir: $REGENDOC_TMPDIR collected 4 items @@ -626,7 +613,7 @@ then you will see two tests skipped and two executed tests as expected: test_plat.py s.s. [100%] ========================= short test summary info ========================== - SKIPPED [2] $REGENDOC_TMPDIR/conftest.py:12: cannot run on platform linux + SKIPPED [2] conftest.py:12: cannot run on platform linux ======================= 2 passed, 2 skipped in 0.12s ======================= Note that if you specify a platform via the marker-command line option like this: @@ -635,7 +622,7 @@ Note that if you specify a platform via the marker-command line option like this $ pytest -m linux =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-5.x.y, py-1.x.y, pluggy-0.x.y + platform linux -- Python 3.x.y, pytest-6.x.y, py-1.x.y, pluggy-0.x.y cachedir: $PYTHON_PREFIX/.pytest_cache rootdir: $REGENDOC_TMPDIR collected 4 items / 3 deselected / 1 selected @@ -651,7 +638,7 @@ Automatically adding markers based on test names .. regendoc:wipe -If you a test suite where test function names indicate a certain +If you have a test suite where test function names indicate a certain type of test, you can implement a hook that automatically defines markers so that you can use the ``-m`` option with it. Let's look at this test module: @@ -699,7 +686,7 @@ We can now use the ``-m option`` to select one set: $ pytest -m interface --tb=short =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-5.x.y, py-1.x.y, pluggy-0.x.y + platform linux -- Python 3.x.y, pytest-6.x.y, py-1.x.y, pluggy-0.x.y cachedir: $PYTHON_PREFIX/.pytest_cache rootdir: $REGENDOC_TMPDIR collected 4 items / 2 deselected / 2 selected @@ -726,7 +713,7 @@ or to select both "event" and "interface" tests: $ pytest -m "interface or event" --tb=short =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-5.x.y, py-1.x.y, pluggy-0.x.y + platform linux -- Python 3.x.y, pytest-6.x.y, py-1.x.y, pluggy-0.x.y cachedir: $PYTHON_PREFIX/.pytest_cache rootdir: $REGENDOC_TMPDIR collected 4 items / 1 deselected / 3 selected diff --git a/doc/en/example/multipython.py b/doc/en/example/multipython.py index 9db6879edae..21bddcd0353 100644 --- a/doc/en/example/multipython.py +++ b/doc/en/example/multipython.py @@ -26,7 +26,7 @@ class Python: def __init__(self, version, picklefile): self.pythonpath = shutil.which(version) if not self.pythonpath: - pytest.skip("{!r} not found".format(version)) + pytest.skip(f"{version!r} not found") self.picklefile = picklefile def dumps(self, obj): @@ -69,4 +69,4 @@ def load_and_is_true(self, expression): @pytest.mark.parametrize("obj", [42, {}, {1: 3}]) def test_basic_objects(python1, python2, obj): python1.dumps(obj) - python2.load_and_is_true("obj == {}".format(obj)) + python2.load_and_is_true(f"obj == {obj}") diff --git a/doc/en/example/nonpython.rst b/doc/en/example/nonpython.rst index 083f6b43912..a3477fe1e1d 100644 --- a/doc/en/example/nonpython.rst +++ b/doc/en/example/nonpython.rst @@ -12,7 +12,7 @@ A basic example for specifying tests in Yaml files .. _`pytest-yamlwsgi`: http://bitbucket.org/aafshar/pytest-yamlwsgi/src/tip/pytest_yamlwsgi.py .. _`PyYAML`: https://pypi.org/project/PyYAML/ -Here is an example ``conftest.py`` (extracted from Ali Afshnars special purpose `pytest-yamlwsgi`_ plugin). This ``conftest.py`` will collect ``test*.yaml`` files and will execute the yaml-formatted content as custom tests: +Here is an example ``conftest.py`` (extracted from Ali Afshar's special purpose `pytest-yamlwsgi`_ plugin). This ``conftest.py`` will collect ``test*.yaml`` files and will execute the yaml-formatted content as custom tests: .. include:: nonpython/conftest.py :literal: @@ -29,7 +29,7 @@ now execute the test specification: nonpython $ pytest test_simple.yaml =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-5.x.y, py-1.x.y, pluggy-0.x.y + platform linux -- Python 3.x.y, pytest-6.x.y, py-1.x.y, pluggy-0.x.y cachedir: $PYTHON_PREFIX/.pytest_cache rootdir: $REGENDOC_TMPDIR/nonpython collected 2 items @@ -66,7 +66,7 @@ consulted when reporting in ``verbose`` mode: nonpython $ pytest -v =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-5.x.y, py-1.x.y, pluggy-0.x.y -- $PYTHON_PREFIX/bin/python + platform linux -- Python 3.x.y, pytest-6.x.y, py-1.x.y, pluggy-0.x.y -- $PYTHON_PREFIX/bin/python cachedir: $PYTHON_PREFIX/.pytest_cache rootdir: $REGENDOC_TMPDIR/nonpython collecting ... collected 2 items @@ -92,13 +92,14 @@ interesting to just look at the collection tree: nonpython $ pytest --collect-only =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-5.x.y, py-1.x.y, pluggy-0.x.y + platform linux -- Python 3.x.y, pytest-6.x.y, py-1.x.y, pluggy-0.x.y cachedir: $PYTHON_PREFIX/.pytest_cache rootdir: $REGENDOC_TMPDIR/nonpython collected 2 items - + + - ========================== no tests ran in 0.12s =========================== + ======================== 2 tests collected in 0.12s ======================== diff --git a/doc/en/example/nonpython/conftest.py b/doc/en/example/nonpython/conftest.py index d30ab3841dc..bdcc8b76222 100644 --- a/doc/en/example/nonpython/conftest.py +++ b/doc/en/example/nonpython/conftest.py @@ -9,7 +9,8 @@ def pytest_collect_file(parent, path): class YamlFile(pytest.File): def collect(self): - import yaml # we need a yaml parser, e.g. PyYAML + # We need a yaml parser, e.g. PyYAML. + import yaml raw = yaml.safe_load(self.fspath.open()) for name, spec in sorted(raw.items()): @@ -23,12 +24,12 @@ def __init__(self, name, parent, spec): def runtest(self): for name, value in sorted(self.spec.items()): - # some custom test execution (dumb example follows) + # Some custom test execution (dumb example follows). if name != value: raise YamlException(self, name, value) def repr_failure(self, excinfo): - """ called when self.runtest() raises an exception. """ + """Called when self.runtest() raises an exception.""" if isinstance(excinfo.value, YamlException): return "\n".join( [ @@ -39,8 +40,8 @@ def repr_failure(self, excinfo): ) def reportinfo(self): - return self.fspath, 0, "usecase: {}".format(self.name) + return self.fspath, 0, f"usecase: {self.name}" class YamlException(Exception): - """ custom exception for error reporting. """ + """Custom exception for error reporting.""" diff --git a/doc/en/example/parametrize.rst b/doc/en/example/parametrize.rst index df558d1bae6..6e2f53984ee 100644 --- a/doc/en/example/parametrize.rst +++ b/doc/en/example/parametrize.rst @@ -160,10 +160,11 @@ objects, they are still using the default pytest representation: $ pytest test_time.py --collect-only =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-5.x.y, py-1.x.y, pluggy-0.x.y + platform linux -- Python 3.x.y, pytest-6.x.y, py-1.x.y, pluggy-0.x.y cachedir: $PYTHON_PREFIX/.pytest_cache rootdir: $REGENDOC_TMPDIR collected 8 items + @@ -174,7 +175,7 @@ objects, they are still using the default pytest representation: - ========================== no tests ran in 0.12s =========================== + ======================== 8 tests collected in 0.12s ======================== In ``test_timedistance_v3``, we used ``pytest.param`` to specify the test IDs together with the actual data, instead of listing them separately. @@ -224,7 +225,7 @@ this is a fully self-contained example which you can run with: $ pytest test_scenarios.py =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-5.x.y, py-1.x.y, pluggy-0.x.y + platform linux -- Python 3.x.y, pytest-6.x.y, py-1.x.y, pluggy-0.x.y cachedir: $PYTHON_PREFIX/.pytest_cache rootdir: $REGENDOC_TMPDIR collected 4 items @@ -239,10 +240,11 @@ If you just collect tests you'll also nicely see 'advanced' and 'basic' as varia $ pytest --collect-only test_scenarios.py =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-5.x.y, py-1.x.y, pluggy-0.x.y + platform linux -- Python 3.x.y, pytest-6.x.y, py-1.x.y, pluggy-0.x.y cachedir: $PYTHON_PREFIX/.pytest_cache rootdir: $REGENDOC_TMPDIR collected 4 items + @@ -250,7 +252,7 @@ If you just collect tests you'll also nicely see 'advanced' and 'basic' as varia - ========================== no tests ran in 0.12s =========================== + ======================== 4 tests collected in 0.12s ======================== Note that we told ``metafunc.parametrize()`` that your scenario values should be considered class-scoped. With pytest-2.3 this leads to a @@ -317,15 +319,16 @@ Let's first see how it looks like at collection time: $ pytest test_backends.py --collect-only =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-5.x.y, py-1.x.y, pluggy-0.x.y + platform linux -- Python 3.x.y, pytest-6.x.y, py-1.x.y, pluggy-0.x.y cachedir: $PYTHON_PREFIX/.pytest_cache rootdir: $REGENDOC_TMPDIR collected 2 items + - ========================== no tests ran in 0.12s =========================== + ======================== 2 tests collected in 0.12s ======================== And then when we run the test: @@ -351,6 +354,30 @@ And then when we run the test: The first invocation with ``db == "DB1"`` passed while the second with ``db == "DB2"`` failed. Our ``db`` fixture function has instantiated each of the DB values during the setup phase while the ``pytest_generate_tests`` generated two according calls to the ``test_db_initialized`` during the collection phase. +Indirect parametrization +--------------------------------------------------- + +Using the ``indirect=True`` parameter when parametrizing a test allows to +parametrize a test with a fixture receiving the values before passing them to a +test: + +.. code-block:: python + + import pytest + + + @pytest.fixture + def fixt(request): + return request.param * 3 + + + @pytest.mark.parametrize("fixt", ["a", "b"], indirect=True) + def test_indirect(fixt): + assert len(fixt) == 3 + +This can be used, for example, to do more expensive setup at test run time in +the fixture, rather than having to run those setup steps at collection time. + .. regendoc:wipe Apply indirect on particular arguments @@ -389,21 +416,18 @@ The result of this test will be successful: .. code-block:: pytest - $ pytest test_indirect_list.py --collect-only + $ pytest -v test_indirect_list.py =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-5.x.y, py-1.x.y, pluggy-0.x.y + platform linux -- Python 3.x.y, pytest-6.x.y, py-1.x.y, pluggy-0.x.y -- $PYTHON_PREFIX/bin/python cachedir: $PYTHON_PREFIX/.pytest_cache rootdir: $REGENDOC_TMPDIR - collected 1 item - - + collecting ... collected 1 item - ========================== no tests ran in 0.12s =========================== + test_indirect_list.py::test_indirect[a-b] PASSED [100%] -.. regendoc:wipe + ============================ 1 passed in 0.12s ============================= -Note, that each argument in `parametrize` list should be explicitly declared in corresponding -python test function or via `indirect`. +.. regendoc:wipe Parametrizing test methods through per-class configuration -------------------------------------------------------------- @@ -486,8 +510,8 @@ Running it results in some skips if we don't have all the python interpreters in . $ pytest -rs -q multipython.py ssssssssssss...ssssssssssss [100%] ========================= short test summary info ========================== - SKIPPED [12] $REGENDOC_TMPDIR/CWD/multipython.py:29: 'python3.5' not found - SKIPPED [12] $REGENDOC_TMPDIR/CWD/multipython.py:29: 'python3.7' not found + SKIPPED [12] multipython.py:29: 'python3.5' not found + SKIPPED [12] multipython.py:29: 'python3.7' not found 3 passed, 24 skipped in 0.12s Indirect parametrization of optional implementations/imports @@ -548,7 +572,7 @@ If you run this with reporting for skips enabled: $ pytest -rs test_module.py =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-5.x.y, py-1.x.y, pluggy-0.x.y + platform linux -- Python 3.x.y, pytest-6.x.y, py-1.x.y, pluggy-0.x.y cachedir: $PYTHON_PREFIX/.pytest_cache rootdir: $REGENDOC_TMPDIR collected 2 items @@ -556,7 +580,7 @@ If you run this with reporting for skips enabled: test_module.py .s [100%] ========================= short test summary info ========================== - SKIPPED [1] $REGENDOC_TMPDIR/conftest.py:12: could not import 'opt2': No module named 'opt2' + SKIPPED [1] conftest.py:12: 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 @@ -610,7 +634,7 @@ Then run ``pytest`` with verbose mode and with only the ``basic`` marker: $ pytest -v -m basic =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-5.x.y, py-1.x.y, pluggy-0.x.y -- $PYTHON_PREFIX/bin/python + platform linux -- Python 3.x.y, pytest-6.x.y, py-1.x.y, pluggy-0.x.y -- $PYTHON_PREFIX/bin/python cachedir: $PYTHON_PREFIX/.pytest_cache rootdir: $REGENDOC_TMPDIR collecting ... collected 14 items / 11 deselected / 3 selected diff --git a/doc/en/example/pythoncollection.rst b/doc/en/example/pythoncollection.rst index d8261a94928..a6ce2e742e5 100644 --- a/doc/en/example/pythoncollection.rst +++ b/doc/en/example/pythoncollection.rst @@ -115,15 +115,13 @@ Changing naming conventions You can configure different naming conventions by setting the :confval:`python_files`, :confval:`python_classes` and -:confval:`python_functions` configuration options. +:confval:`python_functions` in your :ref:`configuration file `. Here is an example: .. code-block:: ini # content of pytest.ini # Example 1: have pytest look for "check" instead of "test" - # can also be defined in tox.ini or setup.cfg file, although the section - # name in setup.cfg files should be "tool:pytest" [pytest] python_files = check_*.py python_classes = Check @@ -149,24 +147,24 @@ The test collection would look like this: $ pytest --collect-only =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-5.x.y, py-1.x.y, pluggy-0.x.y + platform linux -- Python 3.x.y, pytest-6.x.y, py-1.x.y, pluggy-0.x.y cachedir: $PYTHON_PREFIX/.pytest_cache - rootdir: $REGENDOC_TMPDIR, inifile: pytest.ini + rootdir: $REGENDOC_TMPDIR, configfile: pytest.ini collected 2 items + - ========================== no tests ran in 0.12s =========================== + ======================== 2 tests collected in 0.12s ======================== You can check for multiple glob patterns by adding a space between the patterns: .. code-block:: ini # Example 2: have pytest look for files with "test" and "example" - # content of pytest.ini, tox.ini, or setup.cfg file (replace "pytest" - # with "tool:pytest" for setup.cfg) + # content of pytest.ini [pytest] python_files = test_*.py example_*.py @@ -211,17 +209,18 @@ You can always peek at the collection tree without running tests like this: . $ pytest --collect-only pythoncollection.py =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-5.x.y, py-1.x.y, pluggy-0.x.y + platform linux -- Python 3.x.y, pytest-6.x.y, py-1.x.y, pluggy-0.x.y cachedir: $PYTHON_PREFIX/.pytest_cache - rootdir: $REGENDOC_TMPDIR, inifile: pytest.ini + rootdir: $REGENDOC_TMPDIR, configfile: pytest.ini collected 3 items + - ========================== no tests ran in 0.12s =========================== + ======================== 3 tests collected in 0.12s ======================== .. _customizing-test-collection: @@ -283,7 +282,7 @@ leave out the ``setup.py`` file: - ====== no tests ran in 0.04 seconds ====== + ====== 1 tests found in 0.04 seconds ====== If you run with a Python 3 interpreter both the one test and the ``setup.py`` file will be left out: @@ -292,15 +291,15 @@ file will be left out: $ pytest --collect-only =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-5.x.y, py-1.x.y, pluggy-0.x.y + platform linux -- Python 3.x.y, pytest-6.x.y, py-1.x.y, pluggy-0.x.y cachedir: $PYTHON_PREFIX/.pytest_cache - rootdir: $REGENDOC_TMPDIR, inifile: pytest.ini + rootdir: $REGENDOC_TMPDIR, configfile: pytest.ini collected 0 items - ========================== no tests ran in 0.12s =========================== + ======================= no tests collected in 0.12s ======================== It's also possible to ignore files based on Unix shell-style wildcards by adding -patterns to ``collect_ignore_glob``. +patterns to :globalvar:`collect_ignore_glob`. The following example ``conftest.py`` ignores the file ``setup.py`` and in addition all files that end with ``*_py2.py`` when executed with a Python 3 @@ -314,3 +313,12 @@ interpreter: collect_ignore = ["setup.py"] if sys.version_info[0] > 2: collect_ignore_glob = ["*_py2.py"] + +Since Pytest 2.6, users can prevent pytest from discovering classes that start +with ``Test`` by setting a boolean ``__test__`` attribute to ``False``. + +.. code-block:: python + + # Will not be discovered as a test + class TestClass: + __test__ = False diff --git a/doc/en/example/reportingdemo.rst b/doc/en/example/reportingdemo.rst index 2a1b2ed6562..6e7dbe49683 100644 --- a/doc/en/example/reportingdemo.rst +++ b/doc/en/example/reportingdemo.rst @@ -9,7 +9,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: assertion $ pytest failure_demo.py =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-5.x.y, py-1.x.y, pluggy-0.x.y + platform linux -- Python 3.x.y, pytest-6.x.y, py-1.x.y, pluggy-0.x.y cachedir: $PYTHON_PREFIX/.pytest_cache rootdir: $REGENDOC_TMPDIR/assertion collected 44 items @@ -26,7 +26,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: > assert param1 * 2 < param2 E assert (3 * 2) < 6 - failure_demo.py:20: AssertionError + failure_demo.py:19: AssertionError _________________________ TestFailing.test_simple __________________________ self = @@ -43,7 +43,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: E + where 42 = .f at 0xdeadbeef>() E + and 43 = .g at 0xdeadbeef>() - failure_demo.py:31: AssertionError + failure_demo.py:30: AssertionError ____________________ TestFailing.test_simple_multiline _____________________ self = @@ -51,7 +51,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: def test_simple_multiline(self): > otherfunc_multi(42, 6 * 9) - failure_demo.py:34: + failure_demo.py:33: _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ a = 42, b = 54 @@ -60,7 +60,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: > assert a == b E assert 42 == 54 - failure_demo.py:15: AssertionError + failure_demo.py:14: AssertionError ___________________________ TestFailing.test_not ___________________________ self = @@ -73,7 +73,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: E assert not 42 E + where 42 = .f at 0xdeadbeef>() - failure_demo.py:40: AssertionError + failure_demo.py:39: AssertionError _________________ TestSpecialisedExplanations.test_eq_text _________________ self = @@ -84,7 +84,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: E - eggs E + spam - failure_demo.py:45: AssertionError + failure_demo.py:44: AssertionError _____________ TestSpecialisedExplanations.test_eq_similar_text _____________ self = @@ -97,7 +97,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: E + foo 1 bar E ? ^ - failure_demo.py:48: AssertionError + failure_demo.py:47: AssertionError ____________ TestSpecialisedExplanations.test_eq_multiline_text ____________ self = @@ -110,7 +110,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: E + spam E bar - failure_demo.py:51: AssertionError + failure_demo.py:50: AssertionError ______________ TestSpecialisedExplanations.test_eq_long_text _______________ self = @@ -127,7 +127,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: E + 1111111111a222222222 E ? ^ - failure_demo.py:56: AssertionError + failure_demo.py:55: AssertionError _________ TestSpecialisedExplanations.test_eq_long_text_multiline __________ self = @@ -147,7 +147,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: E E ...Full output truncated (7 lines hidden), use '-vv' to show - failure_demo.py:61: AssertionError + failure_demo.py:60: AssertionError _________________ TestSpecialisedExplanations.test_eq_list _________________ self = @@ -158,7 +158,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: E At index 2 diff: 2 != 3 E Use -v to get the full diff - failure_demo.py:64: AssertionError + failure_demo.py:63: AssertionError ______________ TestSpecialisedExplanations.test_eq_list_long _______________ self = @@ -171,7 +171,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: E At index 100 diff: 1 != 2 E Use -v to get the full diff - failure_demo.py:69: AssertionError + failure_demo.py:68: AssertionError _________________ TestSpecialisedExplanations.test_eq_dict _________________ self = @@ -189,7 +189,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: E E ...Full output truncated (2 lines hidden), use '-vv' to show - failure_demo.py:72: AssertionError + failure_demo.py:71: AssertionError _________________ TestSpecialisedExplanations.test_eq_set __________________ self = @@ -207,7 +207,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: E E ...Full output truncated (2 lines hidden), use '-vv' to show - failure_demo.py:75: AssertionError + failure_demo.py:74: AssertionError _____________ TestSpecialisedExplanations.test_eq_longer_list ______________ self = @@ -218,7 +218,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: E Right contains one more item: 3 E Use -v to get the full diff - failure_demo.py:78: AssertionError + failure_demo.py:77: AssertionError _________________ TestSpecialisedExplanations.test_in_list _________________ self = @@ -227,7 +227,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: > assert 1 in [0, 2, 3, 4, 5] E assert 1 in [0, 2, 3, 4, 5] - failure_demo.py:81: AssertionError + failure_demo.py:80: AssertionError __________ TestSpecialisedExplanations.test_not_in_text_multiline __________ self = @@ -246,7 +246,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: E E ...Full output truncated (2 lines hidden), use '-vv' to show - failure_demo.py:85: AssertionError + failure_demo.py:84: AssertionError ___________ TestSpecialisedExplanations.test_not_in_text_single ____________ self = @@ -259,7 +259,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: E single foo line E ? +++ - failure_demo.py:89: AssertionError + failure_demo.py:88: AssertionError _________ TestSpecialisedExplanations.test_not_in_text_single_long _________ self = @@ -272,7 +272,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: E head head foo tail tail tail tail tail tail tail tail tail tail tail tail tail tail tail tail tail tail tail tail E ? +++ - failure_demo.py:93: AssertionError + failure_demo.py:92: AssertionError ______ TestSpecialisedExplanations.test_not_in_text_single_long_term _______ self = @@ -285,7 +285,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: E head head fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffftail tail tail tail tail tail tail tail tail tail tail tail tail tail tail tail tail tail tail tail E ? ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ - failure_demo.py:97: AssertionError + failure_demo.py:96: AssertionError ______________ TestSpecialisedExplanations.test_eq_dataclass _______________ self = @@ -302,11 +302,17 @@ Here is a nice run of several failures and how ``pytest`` presents things: right = Foo(1, "c") > assert left == right E AssertionError: assert TestSpecialis...oo(a=1, b='b') == TestSpecialis...oo(a=1, b='c') + E E Omitting 1 identical items, use -vv to show E Differing attributes: - E b: 'b' != 'c' + 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 - failure_demo.py:109: AssertionError + failure_demo.py:108: AssertionError ________________ TestSpecialisedExplanations.test_eq_attrs _________________ self = @@ -323,11 +329,17 @@ Here is a nice run of several failures and how ``pytest`` presents things: right = Foo(1, "c") > assert left == right E AssertionError: assert Foo(a=1, b='b') == Foo(a=1, b='c') + E E Omitting 1 identical items, use -vv to show E Differing attributes: - E b: 'b' != 'c' + 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 - failure_demo.py:121: AssertionError + failure_demo.py:120: AssertionError ______________________________ test_attribute ______________________________ def test_attribute(): @@ -339,7 +351,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: E assert 1 == 2 E + where 1 = .Foo object at 0xdeadbeef>.b - failure_demo.py:129: AssertionError + failure_demo.py:128: AssertionError _________________________ test_attribute_instance __________________________ def test_attribute_instance(): @@ -351,7 +363,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: E + where 1 = .Foo object at 0xdeadbeef>.b E + where .Foo object at 0xdeadbeef> = .Foo'>() - failure_demo.py:136: AssertionError + failure_demo.py:135: AssertionError __________________________ test_attribute_failure __________________________ def test_attribute_failure(): @@ -364,7 +376,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: i = Foo() > assert i.b == 2 - failure_demo.py:147: + failure_demo.py:146: _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ self = .Foo object at 0xdeadbeef> @@ -373,7 +385,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: > raise Exception("Failed to get attrib") E Exception: Failed to get attrib - failure_demo.py:142: Exception + failure_demo.py:141: Exception _________________________ test_attribute_multiple __________________________ def test_attribute_multiple(): @@ -390,7 +402,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: E + and 2 = .Bar object at 0xdeadbeef>.b E + where .Bar object at 0xdeadbeef> = .Bar'>() - failure_demo.py:157: AssertionError + failure_demo.py:156: AssertionError __________________________ TestRaises.test_raises __________________________ self = @@ -400,16 +412,16 @@ Here is a nice run of several failures and how ``pytest`` presents things: > raises(TypeError, int, s) E ValueError: invalid literal for int() with base 10: 'qwe' - failure_demo.py:167: ValueError + failure_demo.py:166: ValueError ______________________ TestRaises.test_raises_doesnt _______________________ self = def test_raises_doesnt(self): - > raises(IOError, int, "3") + > raises(OSError, int, "3") E Failed: DID NOT RAISE - failure_demo.py:170: Failed + failure_demo.py:169: Failed __________________________ TestRaises.test_raise ___________________________ self = @@ -418,7 +430,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: > raise ValueError("demo error") E ValueError: demo error - failure_demo.py:173: ValueError + failure_demo.py:172: ValueError ________________________ TestRaises.test_tupleerror ________________________ self = @@ -427,18 +439,18 @@ Here is a nice run of several failures and how ``pytest`` presents things: > a, b = [1] # NOQA E ValueError: not enough values to unpack (expected 2, got 1) - failure_demo.py:176: ValueError + failure_demo.py:175: ValueError ______ TestRaises.test_reinterpret_fails_with_print_for_the_fun_of_it ______ self = def test_reinterpret_fails_with_print_for_the_fun_of_it(self): items = [1, 2, 3] - print("items is {!r}".format(items)) + print(f"items is {items!r}") > a, b = items.pop() E TypeError: cannot unpack non-iterable int object - failure_demo.py:181: TypeError + failure_demo.py:180: TypeError --------------------------- Captured stdout call --------------------------- items is [1, 2, 3] ________________________ TestRaises.test_some_error ________________________ @@ -449,7 +461,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: > if namenotexi: # NOQA E NameError: name 'namenotexi' is not defined - failure_demo.py:184: NameError + failure_demo.py:183: NameError ____________________ test_dynamic_compile_shows_nicely _____________________ def test_dynamic_compile_shows_nicely(): @@ -460,19 +472,18 @@ Here is a nice run of several failures and how ``pytest`` presents things: name = "abc-123" spec = importlib.util.spec_from_loader(name, loader=None) module = importlib.util.module_from_spec(spec) - code = _pytest._code.compile(src, name, "exec") + code = compile(src, name, "exec") exec(code, module.__dict__) sys.modules[name] = module > module.foo() - failure_demo.py:203: + failure_demo.py:202: _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ - def foo(): - > assert 1 == 0 - E AssertionError + > ??? + E AssertionError - <0-codegen 'abc-123' $REGENDOC_TMPDIR/assertion/failure_demo.py:200>:2: AssertionError + abc-123:2: AssertionError ____________________ TestMoreErrors.test_complex_error _____________________ self = @@ -486,9 +497,9 @@ Here is a nice run of several failures and how ``pytest`` presents things: > somefunc(f(), g()) - failure_demo.py:214: + failure_demo.py:213: _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ - failure_demo.py:11: in somefunc + failure_demo.py:10: in somefunc otherfunc(x, y) _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ @@ -498,7 +509,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: > assert a == b E assert 44 == 43 - failure_demo.py:7: AssertionError + failure_demo.py:6: AssertionError ___________________ TestMoreErrors.test_z1_unpack_error ____________________ self = @@ -508,7 +519,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: > a, b = items E ValueError: not enough values to unpack (expected 2, got 0) - failure_demo.py:218: ValueError + failure_demo.py:217: ValueError ____________________ TestMoreErrors.test_z2_type_error _____________________ self = @@ -518,7 +529,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: > a, b = items E TypeError: cannot unpack non-iterable int object - failure_demo.py:222: TypeError + failure_demo.py:221: TypeError ______________________ TestMoreErrors.test_startswith ______________________ self = @@ -531,7 +542,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: E + where False = ('456') E + where = '123'.startswith - failure_demo.py:227: AssertionError + failure_demo.py:226: AssertionError __________________ TestMoreErrors.test_startswith_nested ___________________ self = @@ -550,7 +561,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: E + where '123' = .f at 0xdeadbeef>() E + and '456' = .g at 0xdeadbeef>() - failure_demo.py:236: AssertionError + failure_demo.py:235: AssertionError _____________________ TestMoreErrors.test_global_func ______________________ self = @@ -561,7 +572,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: E + where False = isinstance(43, float) E + where 43 = globf(42) - failure_demo.py:239: AssertionError + failure_demo.py:238: AssertionError _______________________ TestMoreErrors.test_instance _______________________ self = @@ -572,7 +583,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: E assert 42 != 42 E + where 42 = .x - failure_demo.py:243: AssertionError + failure_demo.py:242: AssertionError _______________________ TestMoreErrors.test_compare ________________________ self = @@ -582,7 +593,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: E assert 11 < 5 E + where 11 = globf(10) - failure_demo.py:246: AssertionError + failure_demo.py:245: AssertionError _____________________ TestMoreErrors.test_try_finally ______________________ self = @@ -593,7 +604,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: > assert x == 0 E assert 1 == 0 - failure_demo.py:251: AssertionError + failure_demo.py:250: AssertionError ___________________ TestCustomAssertMsg.test_single_line ___________________ self = @@ -608,7 +619,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: E assert 1 == 2 E + where 1 = .A'>.a - failure_demo.py:262: AssertionError + failure_demo.py:261: AssertionError ____________________ TestCustomAssertMsg.test_multiline ____________________ self = @@ -627,7 +638,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: E assert 1 == 2 E + where 1 = .A'>.a - failure_demo.py:269: AssertionError + failure_demo.py:268: AssertionError ___________________ TestCustomAssertMsg.test_custom_repr ___________________ self = @@ -649,7 +660,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: E assert 1 == 2 E + where 1 = This is JSON\n{\n 'foo': 'bar'\n}.a - failure_demo.py:282: AssertionError + failure_demo.py:281: AssertionError ========================= short test summary info ========================== FAILED failure_demo.py::test_generative[3-6] - assert (3 * 2) < 6 FAILED failure_demo.py::TestFailing::test_simple - assert 42 == 43 diff --git a/doc/en/example/simple.rst b/doc/en/example/simple.rst index d149553c75c..b641e61f718 100644 --- a/doc/en/example/simple.rst +++ b/doc/en/example/simple.rst @@ -3,6 +3,50 @@ Basic patterns and examples ========================================================== +How to change command line options defaults +------------------------------------------- + +It can be tedious to type the same series of command line options +every time you use ``pytest``. For example, if you always want to see +detailed info on skipped and xfailed tests, as well as have terser "dot" +progress output, you can write it into a configuration file: + +.. code-block:: ini + + # content of pytest.ini + [pytest] + addopts = -ra -q + + +Alternatively, you can set a ``PYTEST_ADDOPTS`` environment variable to add command +line options while the environment is in use: + +.. code-block:: bash + + export PYTEST_ADDOPTS="-v" + +Here's how the command-line is built in the presence of ``addopts`` or the environment variable: + +.. code-block:: text + + $PYTEST_ADDOPTS + +So if the user executes in the command-line: + +.. code-block:: bash + + pytest -m slow + +The actual command line executed is: + +.. code-block:: bash + + pytest -ra -q -v -m slow + +Note that as usual for other command-line applications, in case of conflicting options the last one wins, so the example +above will show verbose output because ``-v`` overwrites ``-q``. + + .. _request example: Pass different values to a test function, depending on command line options @@ -131,7 +175,7 @@ directory with the above conftest.py: $ pytest =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-5.x.y, py-1.x.y, pluggy-0.x.y + platform linux -- Python 3.x.y, pytest-6.x.y, py-1.x.y, pluggy-0.x.y cachedir: $PYTHON_PREFIX/.pytest_cache rootdir: $REGENDOC_TMPDIR collected 0 items @@ -196,7 +240,7 @@ and when running it will see a skipped "slow" test: $ pytest -rs # "-rs" means report details on the little 's' =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-5.x.y, py-1.x.y, pluggy-0.x.y + platform linux -- Python 3.x.y, pytest-6.x.y, py-1.x.y, pluggy-0.x.y cachedir: $PYTHON_PREFIX/.pytest_cache rootdir: $REGENDOC_TMPDIR collected 2 items @@ -213,7 +257,7 @@ Or run it including the ``slow`` marked test: $ pytest --runslow =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-5.x.y, py-1.x.y, pluggy-0.x.y + platform linux -- Python 3.x.y, pytest-6.x.y, py-1.x.y, pluggy-0.x.y cachedir: $PYTHON_PREFIX/.pytest_cache rootdir: $REGENDOC_TMPDIR collected 2 items @@ -222,8 +266,10 @@ Or run it including the ``slow`` marked test: ============================ 2 passed in 0.12s ============================= +.. _`__tracebackhide__`: + Writing well integrated assertion helpers --------------------------------------------------- +----------------------------------------- .. regendoc:wipe @@ -355,7 +401,7 @@ which will add the string to the test header accordingly: $ pytest =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-5.x.y, py-1.x.y, pluggy-0.x.y + platform linux -- Python 3.x.y, pytest-6.x.y, py-1.x.y, pluggy-0.x.y cachedir: $PYTHON_PREFIX/.pytest_cache project deps: mylib-1.1 rootdir: $REGENDOC_TMPDIR @@ -384,7 +430,7 @@ which will add info only when run with "--v": $ pytest -v =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-5.x.y, py-1.x.y, pluggy-0.x.y -- $PYTHON_PREFIX/bin/python + platform linux -- Python 3.x.y, pytest-6.x.y, py-1.x.y, pluggy-0.x.y -- $PYTHON_PREFIX/bin/python cachedir: $PYTHON_PREFIX/.pytest_cache info1: did you know that ... did you? @@ -399,14 +445,14 @@ and nothing when run plainly: $ pytest =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-5.x.y, py-1.x.y, pluggy-0.x.y + platform linux -- Python 3.x.y, pytest-6.x.y, py-1.x.y, pluggy-0.x.y cachedir: $PYTHON_PREFIX/.pytest_cache rootdir: $REGENDOC_TMPDIR collected 0 items ========================== no tests ran in 0.12s =========================== -profiling test duration +Profiling test duration -------------------------- .. regendoc:wipe @@ -439,20 +485,20 @@ Now we can profile which test functions execute the slowest: $ pytest --durations=3 =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-5.x.y, py-1.x.y, pluggy-0.x.y + platform linux -- Python 3.x.y, pytest-6.x.y, py-1.x.y, pluggy-0.x.y cachedir: $PYTHON_PREFIX/.pytest_cache rootdir: $REGENDOC_TMPDIR collected 3 items test_some_are_slow.py ... [100%] - ========================= slowest 3 test durations ========================= + =========================== slowest 3 durations ============================ 0.30s call test_some_are_slow.py::test_funcslow2 0.20s call test_some_are_slow.py::test_funcslow1 0.10s call test_some_are_slow.py::test_funcfast ============================ 3 passed in 0.12s ============================= -incremental testing - test steps +Incremental testing - test steps --------------------------------------------------- .. regendoc:wipe @@ -545,7 +591,7 @@ If we run this: $ pytest -rx =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-5.x.y, py-1.x.y, pluggy-0.x.y + platform linux -- Python 3.x.y, pytest-6.x.y, py-1.x.y, pluggy-0.x.y cachedir: $PYTHON_PREFIX/.pytest_cache rootdir: $REGENDOC_TMPDIR collected 4 items @@ -629,7 +675,7 @@ We can run this: $ pytest =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-5.x.y, py-1.x.y, pluggy-0.x.y + platform linux -- Python 3.x.y, pytest-6.x.y, py-1.x.y, pluggy-0.x.y cachedir: $PYTHON_PREFIX/.pytest_cache rootdir: $REGENDOC_TMPDIR collected 7 items @@ -693,7 +739,7 @@ it (unless you use "autouse" fixture which are always executed ahead of the firs executing). -post-process test reports / failures +Post-process test reports / failures --------------------------------------- If you want to postprocess test reports and need access to the executing @@ -748,7 +794,7 @@ and run them: $ pytest test_module.py =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-5.x.y, py-1.x.y, pluggy-0.x.y + platform linux -- Python 3.x.y, pytest-6.x.y, py-1.x.y, pluggy-0.x.y cachedir: $PYTHON_PREFIX/.pytest_cache rootdir: $REGENDOC_TMPDIR collected 2 items @@ -855,7 +901,7 @@ and run it: $ pytest -s test_module.py =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-5.x.y, py-1.x.y, pluggy-0.x.y + platform linux -- Python 3.x.y, pytest-6.x.y, py-1.x.y, pluggy-0.x.y cachedir: $PYTHON_PREFIX/.pytest_cache rootdir: $REGENDOC_TMPDIR collected 3 items @@ -908,9 +954,9 @@ information. Sometimes a test session might get stuck and there might be no easy way to figure out which test got stuck, for example if pytest was run in quiet mode (``-q``) or you don't have access to the console -output. This is particularly a problem if the problem helps only sporadically, the famous "flaky" kind of tests. +output. This is particularly a problem if the problem happens only sporadically, the famous "flaky" kind of tests. -``pytest`` sets a ``PYTEST_CURRENT_TEST`` environment variable when running tests, which can be inspected +``pytest`` sets the :envvar:`PYTEST_CURRENT_TEST` environment variable when running tests, which can be inspected by process monitoring utilities or libraries like `psutil `_ to discover which test got stuck if necessary: @@ -924,8 +970,8 @@ test got stuck if necessary: print(f'pytest process {pid} running: {environ["PYTEST_CURRENT_TEST"]}') During the test session pytest will set ``PYTEST_CURRENT_TEST`` to the current test -:ref:`nodeid ` and the current stage, which can be ``setup``, ``call`` -and ``teardown``. +:ref:`nodeid ` and the current stage, which can be ``setup``, ``call``, +or ``teardown``. For example, when running a single test function named ``test_foo`` from ``foo_module.py``, ``PYTEST_CURRENT_TEST`` will be set to: diff --git a/doc/en/faq.rst b/doc/en/faq.rst deleted file mode 100644 index 42a2a847bcd..00000000000 --- a/doc/en/faq.rst +++ /dev/null @@ -1,158 +0,0 @@ -Some Issues and Questions -================================== - -.. note:: - - This FAQ is here only mostly for historic reasons. Checkout - `pytest Q&A at Stackoverflow `_ - for many questions and answers related to pytest and/or use - :ref:`contact channels` to get help. - -On naming, nosetests, licensing and magic ------------------------------------------------- - -How does pytest relate to nose and unittest? -+++++++++++++++++++++++++++++++++++++++++++++++++ - -``pytest`` and nose_ share basic philosophy when it comes -to running and writing Python tests. In fact, you can run many tests -written for nose with ``pytest``. nose_ was originally created -as a clone of ``pytest`` when ``pytest`` was in the ``0.8`` release -cycle. Note that starting with pytest-2.0 support for running unittest -test suites is majorly improved. - -how does pytest relate to twisted's trial? -++++++++++++++++++++++++++++++++++++++++++++++ - -Since some time ``pytest`` has builtin support for supporting tests -written using trial. It does not itself start a reactor, however, -and does not handle Deferreds returned from a test in pytest style. -If you are using trial's unittest.TestCase chances are that you can -just run your tests even if you return Deferreds. In addition, -there also is a dedicated `pytest-twisted -`_ plugin which allows you to -return deferreds from pytest-style tests, allowing the use of -:ref:`fixtures` and other features. - -how does pytest work with Django? -++++++++++++++++++++++++++++++++++++++++++++++ - -In 2012, some work is going into the `pytest-django plugin `_. It substitutes the usage of Django's -``manage.py test`` and allows the use of all pytest features_ most of which -are not available from Django directly. - -.. _features: features.html - - -What's this "magic" with pytest? (historic notes) -++++++++++++++++++++++++++++++++++++++++++++++++++++++++ - -Around 2007 (version ``0.8``) some people thought that ``pytest`` -was using too much "magic". It had been part of the `pylib`_ which -contains a lot of unrelated python library code. Around 2010 there -was a major cleanup refactoring, which removed unused or deprecated code -and resulted in the new ``pytest`` PyPI package which strictly contains -only test-related code. This release also brought a complete pluginification -such that the core is around 300 lines of code and everything else is -implemented in plugins. Thus ``pytest`` today is a small, universally runnable -and customizable testing framework for Python. Note, however, that -``pytest`` uses metaprogramming techniques and reading its source is -thus likely not something for Python beginners. - -A second "magic" issue was the assert statement debugging feature. -Nowadays, ``pytest`` explicitly rewrites assert statements in test modules -in order to provide more useful :ref:`assert feedback `. -This completely avoids previous issues of confusing assertion-reporting. -It also means, that you can use Python's ``-O`` optimization without losing -assertions in test modules. - -You can also turn off all assertion interaction using the -``--assert=plain`` option. - -.. _`py namespaces`: index.html -.. _`py/__init__.py`: http://bitbucket.org/hpk42/py-trunk/src/trunk/py/__init__.py - - -Why can I use both ``pytest`` and ``py.test`` commands? -+++++++++++++++++++++++++++++++++++++++++++++++++++++++ - -pytest used to be part of the py package, which provided several developer -utilities, all starting with ``py.``, thus providing nice TAB-completion. -If you install ``pip install pycmd`` you get these tools from a separate -package. Once ``pytest`` became a separate package, the ``py.test`` name was -retained due to avoid a naming conflict with another tool. This conflict was -eventually resolved, and the ``pytest`` command was therefore introduced. In -future versions of pytest, we may deprecate and later remove the ``py.test`` -command to avoid perpetuating the confusion. - -pytest fixtures, parametrized tests -------------------------------------------------------- - -.. _funcargs: funcargs.html - -Is using pytest fixtures versus xUnit setup a style question? -+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ - -For simple applications and for people experienced with nose_ or -unittest-style test setup using `xUnit style setup`_ probably -feels natural. For larger test suites, parametrized testing -or setup of complex test resources using fixtures_ may feel more natural. -Moreover, fixtures are ideal for writing advanced test support -code (like e.g. the monkeypatch_, the tmpdir_ or capture_ fixtures) -because the support code can register setup/teardown functions -in a managed class/module/function scope. - -.. _monkeypatch: monkeypatch.html -.. _tmpdir: tmpdir.html -.. _capture: capture.html -.. _fixtures: fixture.html - -.. _`why pytest_pyfuncarg__ methods?`: - -.. _`Convention over Configuration`: http://en.wikipedia.org/wiki/Convention_over_Configuration - -Can I yield multiple values from a fixture function? -++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ - -There are two conceptual reasons why yielding from a factory function -is not possible: - -* If multiple factories yielded values there would - be no natural place to determine the combination - policy - in real-world examples some combinations - often should not run. - -* Calling factories for obtaining test function arguments - is part of setting up and running a test. At that - point it is not possible to add new test calls to - the test collection anymore. - -However, with pytest-2.3 you can use the :ref:`@pytest.fixture` decorator -and specify ``params`` so that all tests depending on the factory-created -resource will run multiple times with different parameters. - -You can also use the ``pytest_generate_tests`` hook to -implement the `parametrization scheme of your choice`_. See also -:ref:`paramexamples` for more examples. - -.. _`parametrization scheme of your choice`: http://tetamap.wordpress.com/2009/05/13/parametrizing-python-tests-generalized/ - -pytest interaction with other packages ---------------------------------------------------- - -Issues with pytest, multiprocess and setuptools? -+++++++++++++++++++++++++++++++++++++++++++++++++++++++++ - -On Windows the multiprocess package will instantiate sub processes -by pickling and thus implicitly re-import a lot of local modules. -Unfortunately, setuptools-0.6.11 does not ``if __name__=='__main__'`` -protect its generated command line script. This leads to infinite -recursion when running a test that instantiates Processes. - -As of mid-2013, there shouldn't be a problem anymore when you -use the standard setuptools (note that distribute has been merged -back into setuptools which is now shipped directly with virtualenv). - -.. _nose: https://nose.readthedocs.io/en/latest/ -.. _pylib: https://py.readthedocs.io/en/latest/ -.. _`xUnit style setup`: xunit_setup.html diff --git a/doc/en/fixture.rst b/doc/en/fixture.rst index fe69109a749..963fc32e6b0 100644 --- a/doc/en/fixture.rst +++ b/doc/en/fixture.rst @@ -121,7 +121,7 @@ Fixtures as Function arguments Test functions can receive fixture objects by naming them as an input argument. For each argument name, a fixture function with that name provides the fixture object. Fixture functions are registered by marking them with -:py:func:`@pytest.fixture <_pytest.python.fixture>`. Let's look at a simple +:py:func:`@pytest.fixture `. Let's look at a simple self-contained test module containing a fixture and a test function using it: @@ -144,14 +144,14 @@ using it: assert 0 # for demo purposes Here, the ``test_ehlo`` needs the ``smtp_connection`` fixture value. pytest -will discover and call the :py:func:`@pytest.fixture <_pytest.python.fixture>` +will discover and call the :py:func:`@pytest.fixture ` marked ``smtp_connection`` fixture function. Running the test looks like this: .. code-block:: pytest $ pytest test_smtpsimple.py =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-5.x.y, py-1.x.y, pluggy-0.x.y + platform linux -- Python 3.x.y, pytest-6.x.y, py-1.x.y, pluggy-0.x.y cachedir: $PYTHON_PREFIX/.pytest_cache rootdir: $REGENDOC_TMPDIR collected 1 item @@ -179,7 +179,7 @@ In the failure traceback we see that the test function was called with a function. The test function fails on our deliberate ``assert 0``. Here is the exact protocol used by ``pytest`` to call the test function this way: -1. pytest :ref:`finds ` the ``test_ehlo`` because +1. pytest :ref:`finds ` the test ``test_ehlo`` because of the ``test_`` prefix. The test function needs a function argument named ``smtp_connection``. A matching fixture function is discovered by looking for a fixture-marked function named ``smtp_connection``. @@ -244,15 +244,15 @@ and `pytest-datafiles `__. .. _smtpshared: -Scope: sharing a fixture instance across tests in a class, module or session ----------------------------------------------------------------------------- +Scope: sharing fixtures across classes, modules, packages or session +-------------------------------------------------------------------- .. regendoc:wipe Fixtures requiring network access depend on connectivity and are usually time-expensive to create. Extending the previous example, we can add a ``scope="module"`` parameter to the -:py:func:`@pytest.fixture <_pytest.python.fixture>` invocation +:py:func:`@pytest.fixture ` invocation to cause the decorated ``smtp_connection`` fixture function to only be invoked once per test *module* (the default is to invoke once per test *function*). Multiple test functions in a test module will thus @@ -303,7 +303,7 @@ inspect what is going on and can now run the tests: $ pytest test_module.py =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-5.x.y, py-1.x.y, pluggy-0.x.y + platform linux -- Python 3.x.y, pytest-6.x.y, py-1.x.y, pluggy-0.x.y cachedir: $PYTHON_PREFIX/.pytest_cache rootdir: $REGENDOC_TMPDIR collected 2 items @@ -356,29 +356,23 @@ instance, you can simply declare it: # all tests needing it ... -Finally, the ``class`` scope will invoke the fixture once per test *class*. - -.. note:: - - Pytest will only cache one instance of a fixture at a time. - This means that when using a parametrized fixture, pytest may invoke a fixture more than once in the given scope. - - -``package`` scope (experimental) -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +Fixture scopes +^^^^^^^^^^^^^^ +Fixtures are created when first requested by a test, and are destroyed based on their ``scope``: -In pytest 3.7 the ``package`` scope has been introduced. Package-scoped fixtures -are finalized when the last test of a *package* finishes. +* ``function``: the default scope, the fixture is destroyed at the end of the test. +* ``class``: the fixture is destroyed during teardown of the last test in the class. +* ``module``: the fixture is destroyed during teardown of the last test in the module. +* ``package``: the fixture is destroyed during teardown of the last test in the package. +* ``session``: the fixture is destroyed at the end of the test session. -.. warning:: - This functionality is considered **experimental** and may be removed in future - versions if hidden corner-cases or serious problems with this functionality - are discovered after it gets more usage in the wild. - - Use this new feature sparingly and please make sure to report any issues you find. +.. note:: + Pytest only caches one instance of a fixture at a time, which + means that when using a parametrized fixture, pytest may invoke a fixture more than once in + the given scope. .. _dynamic scope: @@ -415,7 +409,7 @@ Order: Higher-scoped fixtures are instantiated first -Within a function request for features, fixture of higher-scopes (such as ``session``) are instantiated first than +Within a function request for fixtures, those of higher-scopes (such as ``session``) are instantiated before lower-scoped fixtures (such as ``function`` or ``class``). The relative order of fixtures of same scope follows the declared order in the test function and honours dependencies between fixtures. Autouse fixtures will be instantiated before explicitly used fixtures. @@ -598,7 +592,7 @@ will not be executed. Fixtures can introspect the requesting test context ------------------------------------------------------------- -Fixture functions can accept the :py:class:`request ` object +Fixture functions can accept the :py:class:`request <_pytest.fixtures.FixtureRequest>` object to introspect the "requesting" test function, class or module context. Further extending the previous ``smtp_connection`` fixture example, let's read an optional server URL from the test module which uses our fixture: @@ -665,6 +659,37 @@ Running it: voila! The ``smtp_connection`` fixture function picked up our mail server name from the module namespace. +.. _`using-markers`: + +Using markers to pass data to fixtures +------------------------------------------------------------- + +Using the :py:class:`request <_pytest.fixtures.FixtureRequest>` object, a fixture can also access +markers which are applied to a test function. This can be useful to pass data +into a fixture from a test: + +.. code-block:: python + + import pytest + + + @pytest.fixture + def fixt(request): + marker = request.node.get_closest_marker("fixt_data") + if marker is None: + # Handle missing marker in some way... + data = None + else: + data = marker.args[0] + + # Do something with the data + return data + + + @pytest.mark.fixt_data(42) + def test_fixt(fixt): + assert fixt == 42 + .. _`fixture-factory`: Factories as fixtures @@ -750,7 +775,7 @@ through the special :py:class:`request ` object: smtp_connection.close() The main change is the declaration of ``params`` with -:py:func:`@pytest.fixture <_pytest.python.fixture>`, a list of values +:py:func:`@pytest.fixture `, a list of values for each of which the fixture function will execute and can access a value via ``request.param``. No test function code needs to change. So let's just do another run: @@ -828,7 +853,7 @@ be used with ``-k`` to select specific cases to run, and they will also identify the specific case when one is failing. Running pytest with ``--collect-only`` will show the generated IDs. -Numbers, strings, booleans and None will have their usual string +Numbers, strings, booleans and ``None`` will have their usual string representation used in the test ID. For other objects, pytest will make a string based on the argument name. It is possible to customise the string used in a test ID for a certain fixture value by using the @@ -867,7 +892,7 @@ the string used in a test ID for a certain fixture value by using the The above shows how ``ids`` can be either a list of strings to use or a function which will be called with the fixture value and then has to return a string to use. In the latter case if the function -return ``None`` then pytest's auto-generated ID will be used. +returns ``None`` then pytest's auto-generated ID will be used. Running the above tests results in the following test IDs being used: @@ -875,10 +900,11 @@ Running the above tests results in the following test IDs being used: $ pytest --collect-only =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-5.x.y, py-1.x.y, pluggy-0.x.y + platform linux -- Python 3.x.y, pytest-6.x.y, py-1.x.y, pluggy-0.x.y cachedir: $PYTHON_PREFIX/.pytest_cache rootdir: $REGENDOC_TMPDIR collected 10 items + @@ -893,7 +919,7 @@ Running the above tests results in the following test IDs being used: - ========================== no tests ran in 0.12s =========================== + ======================= 10 tests collected in 0.12s ======================== .. _`fixture-parametrize-marks`: @@ -921,18 +947,18 @@ Example: Running this test will *skip* the invocation of ``data_set`` with value ``2``: -.. code-block:: pytest +.. code-block:: $ pytest test_fixture_marks.py -v =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-5.x.y, py-1.x.y, pluggy-0.x.y -- $PYTHON_PREFIX/bin/python + platform linux -- Python 3.x.y, pytest-6.x.y, py-1.x.y, pluggy-0.x.y -- $PYTHON_PREFIX/bin/python cachedir: $PYTHON_PREFIX/.pytest_cache rootdir: $REGENDOC_TMPDIR collecting ... collected 3 items test_fixture_marks.py::test_data[0] PASSED [ 33%] test_fixture_marks.py::test_data[1] PASSED [ 66%] - test_fixture_marks.py::test_data[2] SKIPPED [100%] + test_fixture_marks.py::test_data[2] SKIPPED (unconditional skip) [100%] ======================= 2 passed, 1 skipped in 0.12s ======================= @@ -975,7 +1001,7 @@ Here we declare an ``app`` fixture which receives the previously defined $ pytest -v test_appsetup.py =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-5.x.y, py-1.x.y, pluggy-0.x.y -- $PYTHON_PREFIX/bin/python + platform linux -- Python 3.x.y, pytest-6.x.y, py-1.x.y, pluggy-0.x.y -- $PYTHON_PREFIX/bin/python cachedir: $PYTHON_PREFIX/.pytest_cache rootdir: $REGENDOC_TMPDIR collecting ... collected 2 items @@ -1055,7 +1081,7 @@ Let's run the tests in verbose mode and with looking at the print-output: $ pytest -v -s test_module.py =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-5.x.y, py-1.x.y, pluggy-0.x.y -- $PYTHON_PREFIX/bin/python + platform linux -- Python 3.x.y, pytest-6.x.y, py-1.x.y, pluggy-0.x.y -- $PYTHON_PREFIX/bin/python cachedir: $PYTHON_PREFIX/.pytest_cache rootdir: $REGENDOC_TMPDIR collecting ... collected 8 items @@ -1110,8 +1136,8 @@ and teared down after every test that used it. .. _`usefixtures`: -Using fixtures from classes, modules or projects ----------------------------------------------------------------------- +Use fixtures in classes and modules with ``usefixtures`` +-------------------------------------------------------- .. regendoc:wipe @@ -1181,15 +1207,12 @@ You can specify multiple fixtures like this: def test(): ... -and you may specify fixture usage at the test module level, using -a generic feature of the mark mechanism: +and you may specify fixture usage at the test module level using :globalvar:`pytestmark`: .. code-block:: python pytestmark = pytest.mark.usefixtures("cleandir") -Note that the assigned variable *must* be called ``pytestmark``, assigning e.g. -``foomark`` will not activate the fixtures. It is also possible to put fixtures required by all tests in your project into an ini-file: @@ -1508,3 +1531,37 @@ Given the tests file structure is: In the example above, a parametrized fixture is overridden with a non-parametrized version, and a non-parametrized fixture is overridden with a parametrized version for certain test module. The same applies for the test folder level obviously. + + +Using fixtures from other projects +---------------------------------- + +Usually projects that provide pytest support will use :ref:`entry points `, +so just installing those projects into an environment will make those fixtures available for use. + +In case you want to use fixtures from a project that does not use entry points, you can +define :globalvar:`pytest_plugins` in your top ``conftest.py`` file to register that module +as a plugin. + +Suppose you have some fixtures in ``mylibrary.fixtures`` and you want to reuse them into your +``app/tests`` directory. + +All you need to do is to define :globalvar:`pytest_plugins` in ``app/tests/conftest.py`` +pointing to that module. + +.. code-block:: python + + pytest_plugins = "mylibrary.fixtures" + +This effectively registers ``mylibrary.fixtures`` as a plugin, making all its fixtures and +hooks available to tests in ``app/tests``. + +.. note:: + + Sometimes users will *import* fixtures from other projects for use, however this is not + recommended: importing fixtures into a module will register them in pytest + as *defined* in that module. + + This has minor consequences, such as appearing multiple times in ``pytest --help``, + but it is not **recommended** because this behavior might change/stop working + in future versions. diff --git a/doc/en/flaky.rst b/doc/en/flaky.rst index 0f0eecab0c8..b6fc1520c0b 100644 --- a/doc/en/flaky.rst +++ b/doc/en/flaky.rst @@ -28,7 +28,7 @@ Flaky tests sometimes appear when a test suite is run in parallel (such as use o Overly strict assertion ~~~~~~~~~~~~~~~~~~~~~~~ -Overly strict assertions can cause problems with floating point comparison as well as timing issues. `pytest.approx `_ is useful here. +Overly strict assertions can cause problems with floating point comparison as well as timing issues. `pytest.approx `_ is useful here. Pytest features @@ -43,7 +43,8 @@ Xfail strict PYTEST_CURRENT_TEST ~~~~~~~~~~~~~~~~~~~ -:ref:`pytest current test env` may be useful for figuring out "which test got stuck". +:envvar:`PYTEST_CURRENT_TEST` may be useful for figuring out "which test got stuck". +See :ref:`pytest current test env` for more details. Plugins diff --git a/doc/en/funcarg_compare.rst b/doc/en/funcarg_compare.rst index af70301654d..0c4913edff8 100644 --- a/doc/en/funcarg_compare.rst +++ b/doc/en/funcarg_compare.rst @@ -7,7 +7,7 @@ pytest-2.3: reasoning for fixture/funcarg evolution **Target audience**: Reading this document requires basic knowledge of python testing, xUnit setup methods and the (previous) basic pytest -funcarg mechanism, see https://docs.pytest.org/en/latest/historical-notes.html#funcargs-and-pytest-funcarg. +funcarg mechanism, see https://docs.pytest.org/en/stable/historical-notes.html#funcargs-and-pytest-funcarg. If you are new to pytest, then you can simply ignore this section and read the other sections. @@ -51,7 +51,7 @@ There are several limitations and difficulties with this approach: performs parametrization at the places where the resource is used. Moreover, you need to modify the factory to use an ``extrakey`` parameter containing ``request.param`` to the - :py:func:`~python.Request.cached_setup` call. + ``Request.cached_setup`` call. 3. Multiple parametrized session-scoped resources will be active at the same time, making it hard for them to affect global state @@ -113,7 +113,7 @@ This new way of parametrizing funcarg factories should in many cases allow to re-use already written factories because effectively ``request.param`` was already used when test functions/classes were parametrized via -:py:func:`~_pytest.python.Metafunc.parametrize(indirect=True)` calls. +:py:func:`metafunc.parametrize(indirect=True) <_pytest.python.Metafunc.parametrize>` calls. Of course it's perfectly fine to combine parametrization and scoping: @@ -170,7 +170,7 @@ several problems: 1. in distributed testing the master process would setup test resources that are never needed because it only co-ordinates the test run - activities of the slave processes. + activities of the worker processes. 2. if you only perform a collection (with "--collect-only") resource-setup will still be executed. diff --git a/doc/en/getting-started.rst b/doc/en/getting-started.rst index 06279a87a9f..09410585dc7 100644 --- a/doc/en/getting-started.rst +++ b/doc/en/getting-started.rst @@ -1,7 +1,7 @@ Installation and Getting Started =================================== -**Pythons**: Python 3.5, 3.6, 3.7, PyPy3 +**Pythons**: Python 3.6, 3.7, 3.8, 3.9, PyPy3 **Platforms**: Linux and Windows @@ -28,7 +28,7 @@ Install ``pytest`` .. code-block:: bash $ pytest --version - This is pytest version 5.x.y, imported from $PYTHON_PREFIX/lib/python3.8/site-packages/pytest/__init__.py + pytest 6.2.1 .. _`simpletest`: @@ -53,7 +53,7 @@ That’s it. You can now execute the test function: $ pytest =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-5.x.y, py-1.x.y, pluggy-0.x.y + platform linux -- Python 3.x.y, pytest-6.x.y, py-1.x.y, pluggy-0.x.y cachedir: $PYTHON_PREFIX/.pytest_cache rootdir: $REGENDOC_TMPDIR collected 1 item @@ -73,7 +73,7 @@ That’s it. You can now execute the test function: FAILED test_sample.py::test_answer - assert 4 == 5 ============================ 1 failed in 0.12s ============================= -This test returns a failure report because ``func(3)`` does not return ``5``. +The ``[100%]`` refers to the overall progress of running all test cases. After it finishes, pytest then shows a failure report because ``func(3)`` does not return ``5``. .. note:: @@ -112,9 +112,15 @@ Execute the test function with “quiet” reporting mode: . [100%] 1 passed in 0.12s +.. note:: + + The ``-q/--quiet`` flag keeps the output brief in this and following examples. + Group multiple tests in a class -------------------------------------------------------------- +.. regendoc:wipe + Once you develop multiple tests, you may want to group them into a class. pytest makes it easy to create a class containing more than one test: .. code-block:: python @@ -153,10 +159,61 @@ Once you develop multiple tests, you may want to group them into a class. pytest The first test passed and the second failed. You can easily see the intermediate values in the assertion to help you understand the reason for the failure. +Grouping tests in classes can be beneficial for the following reasons: + + * Test organization + * Sharing fixtures for tests only in that particular class + * Applying marks at the class level and having them implicitly apply to all tests + +Something to be aware of when grouping tests inside classes is that each test has a unique instance of the class. +Having each test share the same class instance would be very detrimental to test isolation and would promote poor test practices. +This is outlined below: + +.. regendoc:wipe + +.. code-block:: python + + # content of test_class_demo.py + class TestClassDemoInstance: + def test_one(self): + assert 0 + + def test_two(self): + assert 0 + + +.. code-block:: pytest + + $ pytest -k TestClassDemoInstance -q + FF [100%] + ================================= FAILURES ================================= + ______________________ TestClassDemoInstance.test_one ______________________ + + self = + + def test_one(self): + > assert 0 + E assert 0 + + test_class_demo.py:3: AssertionError + ______________________ TestClassDemoInstance.test_two ______________________ + + self = + + def test_two(self): + > assert 0 + E assert 0 + + test_class_demo.py:6: AssertionError + ========================= short test summary info ========================== + FAILED test_class_demo.py::TestClassDemoInstance::test_one - assert 0 + FAILED test_class_demo.py::TestClassDemoInstance::test_two - assert 0 + 2 failed in 0.12s + Request a unique temporary directory for functional tests -------------------------------------------------------------- -``pytest`` provides `Builtin fixtures/function arguments `_ to request arbitrary resources, like a unique temporary directory: +``pytest`` provides `Builtin fixtures/function arguments `_ to request arbitrary resources, like a unique temporary directory: .. code-block:: python diff --git a/doc/en/goodpractices.rst b/doc/en/goodpractices.rst index 16b41eda4d8..4b3c0af10a6 100644 --- a/doc/en/goodpractices.rst +++ b/doc/en/goodpractices.rst @@ -1,4 +1,4 @@ -.. highlightlang:: python +.. highlight:: python .. _`goodpractices`: Good Integration Practices @@ -91,7 +91,8 @@ This has the following benefits: See :ref:`pytest vs python -m pytest` for more information about the difference between calling ``pytest`` and ``python -m pytest``. -Note that using this scheme your test files must have **unique names**, because +Note that this scheme has a drawback if you are using ``prepend`` :ref:`import mode ` +(which is the default): your test files must have **unique names**, because ``pytest`` will import them as *top-level* modules since there are no packages to derive a full package name from. In other words, the test files in the example above will be imported as ``test_app`` and ``test_view`` top-level modules by adding ``tests/`` to @@ -118,9 +119,12 @@ Now pytest will load the modules as ``tests.foo.test_view`` and ``tests.bar.test you to have modules with the same name. But now this introduces a subtle problem: in order to load the test modules from the ``tests`` directory, pytest prepends the root of the repository to ``sys.path``, which adds the side-effect that now ``mypkg`` is also importable. + This is problematic if you are using a tool like `tox`_ to test your package in a virtual environment, because you want to test the *installed* version of your package, not the local code from the repository. +.. _`src-layout`: + In this situation, it is **strongly** suggested to use a ``src`` layout where application root package resides in a sub-directory of your root: @@ -145,6 +149,15 @@ sub-directory of your root: This layout prevents a lot of common pitfalls and has many benefits, which are better explained in this excellent `blog post by Ionel Cristian Mărieș `_. +.. note:: + The new ``--import-mode=importlib`` (see :ref:`import-modes`) doesn't have + any of the drawbacks above because ``sys.path`` and ``sys.modules`` are not changed when importing + test modules, so users that run + into this issue are strongly encouraged to try it and report if the new option works well for them. + + The ``src`` directory layout is still strongly recommended however. + + Tests as part of application code ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -190,8 +203,8 @@ Note that this layout also works in conjunction with the ``src`` layout mentione .. note:: - If ``pytest`` finds an "a/b/test_module.py" test file while - recursing into the filesystem it determines the import name + In ``prepend`` and ``append`` import-modes, if pytest finds a ``"a/b/test_module.py"`` + test file while recursing into the filesystem it determines the import name as follows: * determine ``basedir``: this is the first "upward" (towards the root) @@ -212,6 +225,10 @@ Note that this layout also works in conjunction with the ``src`` layout mentione from each other and thus deriving a canonical import name helps to avoid surprises such as a test module getting imported twice. + With ``--import-mode=importlib`` things are less convoluted because + pytest doesn't need to change ``sys.path`` or ``sys.modules``, making things + much less surprising. + .. _`virtualenv`: https://pypi.org/project/virtualenv/ .. _`buildout`: http://www.buildout.org/ diff --git a/doc/en/historical-notes.rst b/doc/en/historical-notes.rst index ba96d32ab87..4f8722c1c16 100644 --- a/doc/en/historical-notes.rst +++ b/doc/en/historical-notes.rst @@ -112,7 +112,7 @@ More details can be found in the `original PR `_, via Python Academy, February 1-3 2021, Leipzig (Germany) and remote. + + Also see `previous talks and blogposts `_. + .. _features: pytest: helps you write better programs @@ -28,7 +34,7 @@ To execute it: $ pytest =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-5.x.y, py-1.x.y, pluggy-0.x.y + platform linux -- Python 3.x.y, pytest-6.x.y, py-1.x.y, pluggy-0.x.y cachedir: $PYTHON_PREFIX/.pytest_cache rootdir: $REGENDOC_TMPDIR collected 1 item @@ -55,17 +61,17 @@ See :ref:`Getting Started ` for more examples. Features -------- -- Detailed info on failing :ref:`assert statements ` (no need to remember ``self.assert*`` names); +- Detailed info on failing :ref:`assert statements ` (no need to remember ``self.assert*`` names) -- :ref:`Auto-discovery ` of test modules and functions; +- :ref:`Auto-discovery ` of test modules and functions -- :ref:`Modular fixtures ` for managing small or parametrized long-lived test resources; +- :ref:`Modular fixtures ` for managing small or parametrized long-lived test resources -- Can run :ref:`unittest ` (including trial) and :ref:`nose ` test suites out of the box; +- Can run :ref:`unittest ` (including trial) and :ref:`nose ` test suites out of the box -- Python 3.5+ and PyPy 3; +- Python 3.6+ and PyPy 3 -- Rich plugin architecture, with over 315+ `external plugins `_ and thriving community; +- Rich plugin architecture, with over 315+ `external plugins `_ and thriving community Documentation diff --git a/doc/en/logging.rst b/doc/en/logging.rst index e6f91cdf781..52713854efb 100644 --- a/doc/en/logging.rst +++ b/doc/en/logging.rst @@ -250,6 +250,9 @@ made in ``3.4`` after community feedback: * Log levels are no longer changed unless explicitly requested by the :confval:`log_level` configuration or ``--log-level`` command-line options. This allows users to configure logger objects themselves. + Setting :confval:`log_level` will set the level that is captured globally so if a specific test requires + a lower level than this, use the ``caplog.set_level()`` functionality otherwise that test will be prone to + failure. * :ref:`Live Logs ` is now disabled by default and can be enabled setting the :confval:`log_cli` configuration option to ``true``. When enabled, the verbosity is increased so logging for each test is visible. diff --git a/doc/en/mark.rst b/doc/en/mark.rst index 3899dab88b1..7370342a965 100644 --- a/doc/en/mark.rst +++ b/doc/en/mark.rst @@ -4,14 +4,19 @@ Marking test functions with attributes ====================================== By using the ``pytest.mark`` helper you can easily set -metadata on your test functions. There are -some builtin markers, for example: +metadata on your test functions. You can find the full list of builtin markers +in the :ref:`API Reference`. Or you can list all the markers, including +builtin and custom, using the CLI - :code:`pytest --markers`. +Here are some of the builtin markers: + +* :ref:`usefixtures ` - use fixtures on a test function or class +* :ref:`filterwarnings ` - filter certain warnings of a test function * :ref:`skip ` - always skip a test function * :ref:`skipif ` - skip a test function if a certain condition is met * :ref:`xfail ` - produce an "expected failure" outcome if a certain condition is met -* :ref:`parametrize ` to perform multiple calls +* :ref:`parametrize ` - perform multiple calls to the same test function. It's easy to create custom markers or to apply markers @@ -38,7 +43,17 @@ You can register custom marks in your ``pytest.ini`` file like this: slow: marks tests as slow (deselect with '-m "not slow"') serial -Note that everything after the ``:`` is an optional description. +or in your ``pyproject.toml`` file like this: + +.. code-block:: toml + + [tool.pytest.ini_options] + markers = [ + "slow: marks tests as slow (deselect with '-m \"not slow\"')", + "serial", + ] + +Note that everything past the ``:`` after the mark name is an optional description. Alternatively, you can register new markers programmatically in a :ref:`pytest_configure ` hook: @@ -61,7 +76,7 @@ Raising errors on unknown marks Unregistered marks applied with the ``@pytest.mark.name_of_the_mark`` decorator will always emit a warning in order to avoid silently doing something -surprising due to mis-typed names. As described in the previous section, you can disable +surprising due to mistyped names. As described in the previous section, you can disable the warning for custom marks by registering them in your ``pytest.ini`` file or using a custom ``pytest_configure`` hook. diff --git a/doc/en/monkeypatch.rst b/doc/en/monkeypatch.rst index 1d1bd68c03a..9480f008f7c 100644 --- a/doc/en/monkeypatch.rst +++ b/doc/en/monkeypatch.rst @@ -33,25 +33,25 @@ Consider the following scenarios: 1. Modifying the behavior of a function or the property of a class for a test e.g. there is an API call or database connection you will not make for a test but you know -what the expected output should be. Use :py:meth:`monkeypatch.setattr` to patch the +what the expected output should be. Use :py:meth:`monkeypatch.setattr ` to patch the function or property with your desired testing behavior. This can include your own functions. -Use :py:meth:`monkeypatch.delattr` to remove the function or property for the test. +Use :py:meth:`monkeypatch.delattr ` to remove the function or property for the test. 2. Modifying the values of dictionaries e.g. you have a global configuration that -you want to modify for certain test cases. Use :py:meth:`monkeypatch.setitem` to patch the -dictionary for the test. :py:meth:`monkeypatch.delitem` can be used to remove items. +you want to modify for certain test cases. Use :py:meth:`monkeypatch.setitem ` to patch the +dictionary for the test. :py:meth:`monkeypatch.delitem ` can be used to remove items. 3. Modifying environment variables for a test e.g. to test program behavior if an environment variable is missing, or to set multiple values to a known variable. -:py:meth:`monkeypatch.setenv` and :py:meth:`monkeypatch.delenv` can be used for +:py:meth:`monkeypatch.setenv ` and :py:meth:`monkeypatch.delenv ` can be used for these patches. 4. Use ``monkeypatch.setenv("PATH", value, prepend=os.pathsep)`` to modify ``$PATH``, and -:py:meth:`monkeypatch.chdir` to change the context of the current working directory +:py:meth:`monkeypatch.chdir ` to change the context of the current working directory during a test. -5. Use :py:meth:`monkeypatch.syspath_prepend` to modify ``sys.path`` which will also -call :py:meth:`pkg_resources.fixup_namespace_packages` and :py:meth:`importlib.invalidate_caches`. +5. Use :py:meth:`monkeypatch.syspath_prepend ` to modify ``sys.path`` which will also +call ``pkg_resources.fixup_namespace_packages`` and :py:func:`importlib.invalidate_caches`. See the `monkeypatch blog post`_ for some introduction material and a discussion of its motivation. @@ -66,10 +66,10 @@ testing, you do not want your test to depend on the running user. ``monkeypatch` can be used to patch functions dependent on the user to always return a specific value. -In this example, :py:meth:`monkeypatch.setattr` is used to patch ``Path.home`` +In this example, :py:meth:`monkeypatch.setattr ` is used to patch ``Path.home`` so that the known testing path ``Path("/abc")`` is always used when the test is run. This removes any dependency on the running user for testing purposes. -:py:meth:`monkeypatch.setattr` must be called before the function which will use +:py:meth:`monkeypatch.setattr ` must be called before the function which will use the patched function is called. After the test function finishes the ``Path.home`` modification will be undone. @@ -102,7 +102,7 @@ After the test function finishes the ``Path.home`` modification will be undone. Monkeypatching returned objects: building mock classes ------------------------------------------------------ -:py:meth:`monkeypatch.setattr` can be used in conjunction with classes to mock returned +:py:meth:`monkeypatch.setattr ` can be used in conjunction with classes to mock returned objects from functions instead of values. Imagine a simple function to take an API url and return the json response. @@ -268,7 +268,7 @@ to do this using the ``setenv`` and ``delenv`` method. Our example code to test: def get_os_user_lower(): """Simple retrieval function. - Returns lowercase USER or raises EnvironmentError.""" + Returns lowercase USER or raises OSError.""" username = os.getenv("USER") if username is None: @@ -293,7 +293,7 @@ both paths can be safely tested without impacting the running environment: def test_raise_exception(monkeypatch): - """Remove the USER env var and assert EnvironmentError is raised.""" + """Remove the USER env var and assert OSError is raised.""" monkeypatch.delenv("USER", raising=False) with pytest.raises(OSError): @@ -330,7 +330,7 @@ This behavior can be moved into ``fixture`` structures and shared across tests: Monkeypatching dictionaries --------------------------- -:py:meth:`monkeypatch.setitem` can be used to safely set the values of dictionaries +:py:meth:`monkeypatch.setitem ` can be used to safely set the values of dictionaries to specific values during tests. Take this simplified connection string example: .. code-block:: python @@ -367,7 +367,7 @@ For testing purposes we can patch the ``DEFAULT_CONFIG`` dictionary to specific result = app.create_connection_string() assert result == expected -You can use the :py:meth:`monkeypatch.delitem` to remove values. +You can use the :py:meth:`monkeypatch.delitem ` to remove values. .. code-block:: python diff --git a/doc/en/nose.rst b/doc/en/nose.rst index 2939d91e5a9..e16d764692c 100644 --- a/doc/en/nose.rst +++ b/doc/en/nose.rst @@ -26,7 +26,6 @@ Supported nose Idioms * setup and teardown at module/class/method level * SkipTest exceptions and markers * setup/teardown decorators -* ``yield``-based tests and their setup (considered deprecated as of pytest 3.0) * ``__test__`` attribute on modules/classes/functions * general usage of nose utilities @@ -65,10 +64,17 @@ Unsupported idioms / known issues - no nose-configuration is recognized. -- ``yield``-based methods don't support ``setup`` properly because - the ``setup`` method is always called in the same class instance. - There are no plans to fix this currently because ``yield``-tests - are deprecated in pytest 3.0, with ``pytest.mark.parametrize`` - being the recommended alternative. +- ``yield``-based methods are unsupported as of pytest 4.1.0. They are + fundamentally incompatible with pytest because they don't support fixtures + properly since collection and test execution are separated. + +Migrating from nose to pytest +------------------------------ + +`nose2pytest `_ is a Python script +and pytest plugin to help convert Nose-based tests into pytest-based tests. +Specifically, the script transforms nose.tools.assert_* function calls into +raw assert statements, while preserving format of original arguments +as much as possible. .. _nose: https://nose.readthedocs.io/en/latest/ diff --git a/doc/en/parametrize.rst b/doc/en/parametrize.rst index 29223e28e6b..9e531ddd45d 100644 --- a/doc/en/parametrize.rst +++ b/doc/en/parametrize.rst @@ -56,7 +56,7 @@ them in turn: $ pytest =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-5.x.y, py-1.x.y, pluggy-0.x.y + platform linux -- Python 3.x.y, pytest-6.x.y, py-1.x.y, pluggy-0.x.y cachedir: $PYTHON_PREFIX/.pytest_cache rootdir: $REGENDOC_TMPDIR collected 3 items @@ -79,11 +79,21 @@ them in turn: FAILED test_expectation.py::test_eval[6*9-42] - AssertionError: assert 54... ======================= 1 failed, 2 passed in 0.12s ======================== +.. note:: + + Parameter values are passed as-is to tests (no copy whatsoever). + + For example, if you pass a list or a dict as a parameter value, and + the test case code mutates it, the mutations will be reflected in subsequent + test case calls. + .. note:: pytest by default escapes any non-ascii characters used in unicode strings for the parametrization because it has several downsides. - If however you would like to use unicode strings in parametrization and see them in the terminal as is (non-escaped), use this option in your ``pytest.ini``: + If however you would like to use unicode strings in parametrization + and see them in the terminal as is (non-escaped), use this option + in your ``pytest.ini``: .. code-block:: ini @@ -91,7 +101,8 @@ them in turn: disable_test_id_escaping_and_forfeit_all_rights_to_community_support = True Keep in mind however that this might cause unwanted side effects and - even bugs depending on the OS used and plugins currently installed, so use it at your own risk. + even bugs depending on the OS used and plugins currently installed, + so use it at your own risk. As designed in this example, only one pair of input/output values fails @@ -123,7 +134,7 @@ Let's run this: $ pytest =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-5.x.y, py-1.x.y, pluggy-0.x.y + platform linux -- Python 3.x.y, pytest-6.x.y, py-1.x.y, pluggy-0.x.y cachedir: $PYTHON_PREFIX/.pytest_cache rootdir: $REGENDOC_TMPDIR collected 3 items @@ -133,7 +144,7 @@ Let's run this: ======================= 2 passed, 1 xfailed in 0.12s ======================= The one parameter set which caused a failure previously now -shows up as an "xfailed (expected to fail)" test. +shows up as an "xfailed" (expected to fail) test. In case the values provided to ``parametrize`` result in an empty list - for example, if they're dynamically generated by some function - the behaviour of diff --git a/doc/en/plugins.rst b/doc/en/plugins.rst index c3a93141971..855b597392b 100644 --- a/doc/en/plugins.rst +++ b/doc/en/plugins.rst @@ -70,7 +70,7 @@ You may also discover more plugins through a `pytest- pypi.org search`_. Requiring/Loading plugins in a test module or conftest file ----------------------------------------------------------- -You can require plugins in a test module or a conftest file like this: +You can require plugins in a test module or a conftest file using :globalvar:`pytest_plugins`: .. code-block:: python @@ -80,6 +80,7 @@ When the test module or conftest plugin is loaded the specified plugins will be loaded as well. .. note:: + Requiring plugins using a ``pytest_plugins`` variable in non-root ``conftest.py`` files is deprecated. See :ref:`full explanation ` diff --git a/doc/en/py27-py34-deprecation.rst b/doc/en/py27-py34-deprecation.rst index f2d6b540dbc..f23f2062b79 100644 --- a/doc/en/py27-py34-deprecation.rst +++ b/doc/en/py27-py34-deprecation.rst @@ -9,7 +9,7 @@ In case of Python 2 and 3, the difference between the languages makes it even mo because many new Python 3 features cannot be used in a Python 2/3 compatible code base. Python 2.7 EOL has been reached `in 2020 `__, with -the last release planned for mid-April, 2020. +the last release made in April, 2020. Python 3.4 EOL has been reached `in 2019 `__, with the last release made in March, 2019. diff --git a/doc/en/pythonpath.rst b/doc/en/pythonpath.rst index f2c86fab967..b8f4de9d95b 100644 --- a/doc/en/pythonpath.rst +++ b/doc/en/pythonpath.rst @@ -3,11 +3,65 @@ pytest import mechanisms and ``sys.path``/``PYTHONPATH`` ======================================================== -Here's a list of scenarios where pytest may need to change ``sys.path`` in order -to import test modules or ``conftest.py`` files. +.. _`import-modes`: + +Import modes +------------ + +pytest as a testing framework needs to import test modules and ``conftest.py`` files for execution. + +Importing files in Python (at least until recently) is a non-trivial processes, often requiring +changing `sys.path `__. Some aspects of the +import process can be controlled through the ``--import-mode`` command-line flag, which can assume +these values: + +* ``prepend`` (default): the directory path containing each module will be inserted into the *beginning* + of ``sys.path`` if not already there, and then imported with the `__import__ `__ builtin. + + This requires test module names to be unique when the test directory tree is not arranged in + packages, because the modules will put in ``sys.modules`` after importing. + + 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 ``sys.path`` if not already + there, and imported with ``__import__``. + + 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: + + :: + + testing/__init__.py + testing/test_pkg_under_test.py + pkg_under_test/ + + the tests will run against the installed version + of ``pkg_under_test`` when ``--import-mode=append`` is used whereas + with ``prepend`` they would pick up the local version. This kind of confusion is why + we advocate for using :ref:`src ` layouts. + + 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 ``sys.modules`` after importing. + +* ``importlib``: new in pytest-6.0, this mode uses `importlib `__ to import test modules. This gives full control over the import process, and doesn't require + changing ``sys.path`` or ``sys.modules`` at all. + + For this reason this doesn't require test module names to be unique at all, but also makes test + modules non-importable by each other. This was made possible in previous modes, for tests not residing + in Python packages, because of the side-effects of changing ``sys.path`` and ``sys.modules`` + mentioned above. Users which require this should turn their tests into proper packages instead. + + We intend to make ``importlib`` the default in future releases. + +``prepend`` and ``append`` import modes scenarios +------------------------------------------------- + +Here's a list of scenarios when using ``prepend`` or ``append`` import modes where pytest needs to +change ``sys.path`` in order to import test modules or ``conftest.py`` files, and the issues users +might encounter because of that. Test modules / ``conftest.py`` files inside packages ----------------------------------------------------- +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Consider this file and directory layout:: @@ -28,8 +82,6 @@ When executing: pytest root/ - - pytest will find ``foo/bar/tests/test_foo.py`` and realize it is part of a package given that there's an ``__init__.py`` file in the same folder. It will then search upwards until it can find the last folder which still contains an ``__init__.py`` file in order to find the package *root* (in @@ -44,7 +96,7 @@ and allow test modules to have duplicated names. This is also discussed in detai :ref:`test discovery`. Standalone test modules / ``conftest.py`` files ------------------------------------------------ +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Consider this file and directory layout:: diff --git a/doc/en/reference.rst b/doc/en/reference.rst index 731037b0d3b..8aa95ca6448 100644 --- a/doc/en/reference.rst +++ b/doc/en/reference.rst @@ -15,41 +15,41 @@ Functions pytest.approx ~~~~~~~~~~~~~ -.. autofunction:: _pytest.python_api.approx +.. autofunction:: pytest.approx pytest.fail ~~~~~~~~~~~ **Tutorial**: :ref:`skipping` -.. autofunction:: _pytest.outcomes.fail +.. autofunction:: pytest.fail pytest.skip ~~~~~~~~~~~ -.. autofunction:: _pytest.outcomes.skip(msg, [allow_module_level=False]) +.. autofunction:: pytest.skip(msg, [allow_module_level=False]) .. _`pytest.importorskip ref`: pytest.importorskip ~~~~~~~~~~~~~~~~~~~ -.. autofunction:: _pytest.outcomes.importorskip +.. autofunction:: pytest.importorskip pytest.xfail ~~~~~~~~~~~~ -.. autofunction:: _pytest.outcomes.xfail +.. autofunction:: pytest.xfail pytest.exit ~~~~~~~~~~~ -.. autofunction:: _pytest.outcomes.exit +.. autofunction:: pytest.exit pytest.main ~~~~~~~~~~~ -.. autofunction:: _pytest.config.main +.. autofunction:: pytest.main pytest.param ~~~~~~~~~~~~ @@ -138,7 +138,7 @@ pytest.mark.parametrize **Tutorial**: :doc:`parametrize`. -.. automethod:: _pytest.python.Metafunc.parametrize +This mark has the same signature as :py:meth:`_pytest.python.Metafunc.parametrize`; see there. .. _`pytest.mark.skip ref`: @@ -180,14 +180,17 @@ pytest.mark.usefixtures Mark a test function as using the given fixture names. -.. warning:: +.. py:function:: pytest.mark.usefixtures(*names) - This mark has no effect when applied - to a **fixture** function. + :param args: The names of the fixture to use, as strings. -.. py:function:: pytest.mark.usefixtures(*names) +.. note:: + + When using `usefixtures` in hooks, it can only load fixtures when applied to a test function before test setup + (for example in the `pytest_collection_modifyitems` hook). + + Also note that this mark has no effect when applied to **fixtures**. - :param args: the names of the fixture to use, as strings .. _`pytest.mark.xfail ref`: @@ -204,9 +207,12 @@ Marks a test function as *expected to fail*. :type condition: bool or str :param condition: Condition for marking the test function as xfail (``True/False`` or a - :ref:`condition string `). - :keyword str reason: Reason why the test function is marked as xfail. - :keyword Exception raises: Exception subclass expected to be raised by the test function; other exceptions will fail the test. + :ref:`condition string `). If a bool, you also have + to specify ``reason`` (see :ref:`condition string `). + :keyword str reason: + Reason why the test function is marked as xfail. + :keyword Type[Exception] raises: + Exception subclass expected to be raised by the test function; other exceptions will fail the test. :keyword bool run: If the test function should actually be executed. If ``False``, the function will always xfail and will not be executed (useful if a function is segfaulting). @@ -220,7 +226,7 @@ Marks a test function as *expected to fail*. a new release of a library fixes a known bug). -custom marks +Custom marks ~~~~~~~~~~~~ Marks are created dynamically using the factory object ``pytest.mark`` and applied as a decorator. @@ -234,7 +240,7 @@ For example: ... Will create and attach a :class:`Mark <_pytest.mark.structures.Mark>` object to the collected -:class:`Item <_pytest.nodes.Item>`, which can then be accessed by fixtures or hooks with +:class:`Item `, which can then be accessed by fixtures or hooks with :meth:`Node.iter_markers <_pytest.nodes.Node.iter_markers>`. The ``mark`` object will have the following attributes: .. code-block:: python @@ -242,6 +248,16 @@ Will create and attach a :class:`Mark <_pytest.mark.structures.Mark>` object to mark.args == (10, "slow") mark.kwargs == {"method": "thread"} +Example for using multiple custom markers: + +.. code-block:: python + + @pytest.mark.timeout(10, "slow", method="thread") + @pytest.mark.slow + def test_function(): + ... + +When :meth:`Node.iter_markers <_pytest.nodes.Node.iter_markers>` or :meth:`Node.iter_markers <_pytest.nodes.Node.iter_markers_with_node>` is used with multiple markers, the marker closest to the function will be iterated over first. The above example will result in ``@pytest.mark.slow`` followed by ``@pytest.mark.timeout(...)``. .. _`fixtures-api`: @@ -284,7 +300,7 @@ For more details, consult the full :ref:`fixtures docs `. :decorator: -.. fixture:: config.cache +.. fixture:: cache config.cache ~~~~~~~~~~~~ @@ -298,11 +314,10 @@ request ``pytestconfig`` into your fixture and get it with ``pytestconfig.cache` Under the hood, the cache plugin uses the simple ``dumps``/``loads`` API of the :py:mod:`json` stdlib module. -.. currentmodule:: _pytest.cacheprovider +``config.cache`` is an instance of :class:`pytest.Cache`: -.. automethod:: Cache.get -.. automethod:: Cache.set -.. automethod:: Cache.makedir +.. autoclass:: pytest.Cache() + :members: .. fixture:: capsys @@ -312,12 +327,10 @@ capsys **Tutorial**: :doc:`capture`. -.. currentmodule:: _pytest.capture - -.. autofunction:: capsys() +.. autofunction:: _pytest.capture.capsys() :no-auto-options: - Returns an instance of :py:class:`CaptureFixture`. + Returns an instance of :class:`CaptureFixture[str] `. Example: @@ -328,7 +341,7 @@ capsys captured = capsys.readouterr() assert captured.out == "hello\n" -.. autoclass:: CaptureFixture() +.. autoclass:: pytest.CaptureFixture() :members: @@ -339,10 +352,10 @@ capsysbinary **Tutorial**: :doc:`capture`. -.. autofunction:: capsysbinary() +.. autofunction:: _pytest.capture.capsysbinary() :no-auto-options: - Returns an instance of :py:class:`CaptureFixture`. + Returns an instance of :class:`CaptureFixture[bytes] `. Example: @@ -361,10 +374,10 @@ capfd **Tutorial**: :doc:`capture`. -.. autofunction:: capfd() +.. autofunction:: _pytest.capture.capfd() :no-auto-options: - Returns an instance of :py:class:`CaptureFixture`. + Returns an instance of :class:`CaptureFixture[str] `. Example: @@ -383,10 +396,10 @@ capfdbinary **Tutorial**: :doc:`capture`. -.. autofunction:: capfdbinary() +.. autofunction:: _pytest.capture.capfdbinary() :no-auto-options: - Returns an instance of :py:class:`CaptureFixture`. + Returns an instance of :class:`CaptureFixture[bytes] `. Example: @@ -427,7 +440,7 @@ request The ``request`` fixture is a special fixture providing information of the requesting test function. -.. autoclass:: _pytest.fixtures.FixtureRequest() +.. autoclass:: pytest.FixtureRequest() :members: @@ -469,9 +482,9 @@ caplog .. autofunction:: _pytest.logging.caplog() :no-auto-options: - This returns a :class:`_pytest.logging.LogCaptureFixture` instance. + Returns a :class:`pytest.LogCaptureFixture` instance. -.. autoclass:: _pytest.logging.LogCaptureFixture +.. autoclass:: pytest.LogCaptureFixture() :members: @@ -480,30 +493,30 @@ caplog monkeypatch ~~~~~~~~~~~ -.. currentmodule:: _pytest.monkeypatch - **Tutorial**: :doc:`monkeypatch`. .. autofunction:: _pytest.monkeypatch.monkeypatch() :no-auto-options: - This returns a :class:`MonkeyPatch` instance. + Returns a :class:`~pytest.MonkeyPatch` instance. -.. autoclass:: _pytest.monkeypatch.MonkeyPatch +.. autoclass:: pytest.MonkeyPatch :members: -.. fixture:: testdir +.. fixture:: pytester -testdir -~~~~~~~ +pytester +~~~~~~~~ -.. currentmodule:: _pytest.pytester +.. versionadded:: 6.2 -This fixture provides a :class:`Testdir` instance useful for black-box testing of test files, making it ideal to -test plugins. +Provides a :class:`~pytest.Pytester` instance that can be used to run and test pytest itself. -To use it, include in your top-most ``conftest.py`` file: +It provides an empty directory where pytest can be executed in isolation, and contains facilities +to write tests, configuration files, and match against expected output. + +To use it, include in your topmost ``conftest.py`` file: .. code-block:: python @@ -511,13 +524,30 @@ To use it, include in your top-most ``conftest.py`` file: -.. autoclass:: Testdir() +.. autoclass:: pytest.Pytester() + :members: + +.. autoclass:: _pytest.pytester.RunResult() :members: -.. autoclass:: RunResult() +.. autoclass:: _pytest.pytester.LineMatcher() :members: + :special-members: __str__ -.. autoclass:: LineMatcher() +.. autoclass:: _pytest.pytester.HookRecorder() + :members: + +.. fixture:: testdir + +testdir +~~~~~~~ + +Identical to :fixture:`pytester`, but provides an instance whose methods return +legacy ``py.path.local`` objects instead when applicable. + +New code should avoid using :fixture:`testdir` in favor of :fixture:`pytester`. + +.. autoclass:: pytest.Testdir() :members: @@ -528,19 +558,14 @@ recwarn **Tutorial**: :ref:`assertwarnings` -.. currentmodule:: _pytest.recwarn - -.. autofunction:: recwarn() +.. autofunction:: _pytest.recwarn.recwarn() :no-auto-options: -.. autoclass:: _pytest.recwarn.WarningsRecorder() +.. autoclass:: pytest.WarningsRecorder() :members: Each recorded warning is an instance of :class:`warnings.WarningMessage`. -.. note:: - :class:`RecordedWarning` was changed from a plain class to a namedtuple in pytest 3.1 - .. note:: ``DeprecationWarning`` and ``PendingDeprecationWarning`` are treated differently; see :ref:`ensuring_function_triggers`. @@ -553,13 +578,11 @@ tmp_path **Tutorial**: :doc:`tmpdir` -.. currentmodule:: _pytest.tmpdir - -.. autofunction:: tmp_path() +.. autofunction:: _pytest.tmpdir.tmp_path() :no-auto-options: -.. fixture:: tmp_path_factory +.. fixture:: _pytest.tmpdir.tmp_path_factory tmp_path_factory ~~~~~~~~~~~~~~~~ @@ -568,12 +591,9 @@ tmp_path_factory .. _`tmp_path_factory factory api`: -``tmp_path_factory`` instances have the following methods: +``tmp_path_factory`` is an instance of :class:`~pytest.TempPathFactory`: -.. currentmodule:: _pytest.tmpdir - -.. automethod:: TempPathFactory.mktemp -.. automethod:: TempPathFactory.getbasetemp +.. autoclass:: pytest.TempPathFactory() .. fixture:: tmpdir @@ -583,9 +603,7 @@ tmpdir **Tutorial**: :doc:`tmpdir` -.. currentmodule:: _pytest.tmpdir - -.. autofunction:: tmpdir() +.. autofunction:: _pytest.tmpdir.tmpdir() :no-auto-options: @@ -598,12 +616,9 @@ tmpdir_factory .. _`tmpdir factory api`: -``tmpdir_factory`` instances have the following methods: +``tmp_path_factory`` is an instance of :class:`~pytest.TempdirFactory`: -.. currentmodule:: _pytest.tmpdir - -.. automethod:: TempdirFactory.mktemp -.. automethod:: TempdirFactory.getbasetemp +.. autoclass:: pytest.TempdirFactory() .. _`hook-reference`: @@ -643,31 +658,6 @@ Initialization hooks called for plugins and ``conftest.py`` files. .. autofunction:: pytest_plugin_registered -Test running hooks -~~~~~~~~~~~~~~~~~~ - -All runtest related hooks receive a :py:class:`pytest.Item <_pytest.main.Item>` object. - -.. autofunction:: pytest_runtestloop -.. autofunction:: pytest_runtest_protocol -.. autofunction:: pytest_runtest_logstart -.. autofunction:: pytest_runtest_logfinish -.. autofunction:: pytest_runtest_setup -.. autofunction:: pytest_runtest_call -.. autofunction:: pytest_runtest_teardown -.. autofunction:: pytest_runtest_makereport - -For deeper understanding you may look at the default implementation of -these hooks in :py:mod:`_pytest.runner` and maybe also -in :py:mod:`_pytest.pdb` which interacts with :py:mod:`_pytest.capture` -and its input/output capturing in order to immediately drop -into interactive debugging when a test failure occurs. - -The :py:mod:`_pytest.terminal` reported specifically uses -the reporting hook to print information about a test run. - -.. autofunction:: pytest_pyfunc_call - Collection hooks ~~~~~~~~~~~~~~~~ @@ -675,7 +665,6 @@ Collection hooks .. autofunction:: pytest_collection .. autofunction:: pytest_ignore_collect -.. autofunction:: pytest_collect_directory .. autofunction:: pytest_collect_file .. autofunction:: pytest_pycollect_makemodule @@ -691,8 +680,34 @@ items, delete or otherwise amend the test items: .. autofunction:: pytest_collection_modifyitems +.. note:: + If this hook is implemented in ``conftest.py`` files, it always receives all collected items, not only those + under the ``conftest.py`` where it is implemented. + .. autofunction:: pytest_collection_finish +Test running (runtest) hooks +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +All runtest related hooks receive a :py:class:`pytest.Item ` object. + +.. autofunction:: pytest_runtestloop +.. autofunction:: pytest_runtest_protocol +.. autofunction:: pytest_runtest_logstart +.. autofunction:: pytest_runtest_logfinish +.. autofunction:: pytest_runtest_setup +.. autofunction:: pytest_runtest_call +.. autofunction:: pytest_runtest_teardown +.. autofunction:: pytest_runtest_makereport + +For deeper understanding you may look at the default implementation of +these hooks in ``_pytest.runner`` and maybe also +in ``_pytest.pdb`` which interacts with ``_pytest.capture`` +and its input/output capturing in order to immediately drop +into interactive debugging when a test failure occurs. + +.. autofunction:: pytest_pyfunc_call + Reporting hooks ~~~~~~~~~~~~~~~ @@ -710,6 +725,7 @@ Session related reporting hooks: .. autofunction:: pytest_fixture_setup .. autofunction:: pytest_fixture_post_finalizer .. autofunction:: pytest_warning_captured +.. autofunction:: pytest_warning_recorded Central hook for reporting about test execution: @@ -749,16 +765,24 @@ CallInfo Class ~~~~~ -.. autoclass:: _pytest.python.Class() +.. autoclass:: pytest.Class() :members: :show-inheritance: Collector ~~~~~~~~~ -.. autoclass:: _pytest.nodes.Collector() +.. autoclass:: pytest.Collector() + :members: + :show-inheritance: + +CollectReport +~~~~~~~~~~~~~ + +.. autoclass:: _pytest.reports.CollectReport() :members: :show-inheritance: + :inherited-members: Config ~~~~~~ @@ -773,12 +797,19 @@ ExceptionInfo :members: -pytest.ExitCode -~~~~~~~~~~~~~~~ +ExitCode +~~~~~~~~ -.. autoclass:: _pytest.config.ExitCode +.. autoclass:: pytest.ExitCode :members: +File +~~~~ + +.. autoclass:: pytest.File() + :members: + :show-inheritance: + FixtureDef ~~~~~~~~~~ @@ -797,14 +828,21 @@ FSCollector Function ~~~~~~~~ -.. autoclass:: _pytest.python.Function() +.. autoclass:: pytest.Function() + :members: + :show-inheritance: + +FunctionDefinition +~~~~~~~~~~~~~~~~~~ + +.. autoclass:: _pytest.python.FunctionDefinition() :members: :show-inheritance: Item ~~~~ -.. autoclass:: _pytest.nodes.Item() +.. autoclass:: pytest.Item() :members: :show-inheritance: @@ -838,7 +876,7 @@ Metafunc Module ~~~~~~ -.. autoclass:: _pytest.python.Module() +.. autoclass:: pytest.Module() :members: :show-inheritance: @@ -854,12 +892,6 @@ Parser .. autoclass:: _pytest.config.argparsing.Parser() :members: -PluginManager -~~~~~~~~~~~~~ - -.. autoclass:: pluggy.PluginManager() - :members: - PytestPluginManager ~~~~~~~~~~~~~~~~~~~ @@ -867,36 +899,41 @@ PytestPluginManager .. autoclass:: _pytest.config.PytestPluginManager() :members: :undoc-members: + :inherited-members: :show-inheritance: Session ~~~~~~~ -.. autoclass:: _pytest.main.Session() +.. autoclass:: pytest.Session() :members: :show-inheritance: TestReport ~~~~~~~~~~ -.. autoclass:: _pytest.runner.TestReport() +.. autoclass:: _pytest.reports.TestReport() :members: + :show-inheritance: :inherited-members: _Result ~~~~~~~ +Result used within :ref:`hook wrappers `. + .. autoclass:: pluggy.callers._Result - :members: +.. automethod:: pluggy.callers._Result.get_result +.. automethod:: pluggy.callers._Result.force_result -Special Variables ------------------ +Global Variables +---------------- -pytest treats some global variables in a special manner when defined in a test module. +pytest treats some global variables in a special manner when defined in a test module or +``conftest.py`` files. -collect_ignore -~~~~~~~~~~~~~~ +.. globalvar:: collect_ignore **Tutorial**: :ref:`customizing-test-collection` @@ -908,8 +945,7 @@ Needs to be ``list[str]``. collect_ignore = ["setup.py"] -collect_ignore_glob -~~~~~~~~~~~~~~~~~~~ +.. globalvar:: collect_ignore_glob **Tutorial**: :ref:`customizing-test-collection` @@ -922,8 +958,7 @@ contain glob patterns. collect_ignore_glob = ["*_ignore.py"] -pytest_plugins -~~~~~~~~~~~~~~ +.. globalvar:: pytest_plugins **Tutorial**: :ref:`available installable plugins` @@ -939,13 +974,12 @@ Can be either a ``str`` or ``Sequence[str]``. pytest_plugins = ("myapp.testsupport.tools", "myapp.testsupport.regression") -pytestmark -~~~~~~~~~~ +.. globalvar:: pytestmark **Tutorial**: :ref:`scoped-marking` Can be declared at the **global** level in *test modules* to apply one or more :ref:`marks ` to all -test functions and methods. Can be either a single mark or a list of marks. +test functions and methods. Can be either a single mark or a list of marks (applied in left-to-right order). .. code-block:: python @@ -960,31 +994,32 @@ test functions and methods. Can be either a single mark or a list of marks. pytestmark = [pytest.mark.integration, pytest.mark.slow] -PYTEST_DONT_REWRITE (module docstring) -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -The text ``PYTEST_DONT_REWRITE`` can be add to any **module docstring** to disable -:ref:`assertion rewriting ` for that module. - Environment Variables --------------------- Environment variables that can be used to change pytest's behavior. -PYTEST_ADDOPTS -~~~~~~~~~~~~~~ +.. envvar:: PYTEST_ADDOPTS This contains a command-line (parsed by the py:mod:`shlex` module) that will be **prepended** to the command line given by the user, see :ref:`adding default options` for more information. -PYTEST_DEBUG -~~~~~~~~~~~~ +.. envvar:: PYTEST_CURRENT_TEST + +This is not meant to be set by users, but is set by pytest internally with the name of the current test so other +processes can inspect it, see :ref:`pytest current test env` for more information. + +.. envvar:: PYTEST_DEBUG When set, pytest will print tracing and debug information. -PYTEST_PLUGINS -~~~~~~~~~~~~~~ +.. envvar:: PYTEST_DISABLE_PLUGIN_AUTOLOAD + +When set, disables plugin auto-loading through setuptools entrypoints. Only explicitly specified plugins will be +loaded. + +.. envvar:: PYTEST_PLUGINS Contains comma-separated list of modules that should be loaded as plugins: @@ -992,25 +1027,71 @@ Contains comma-separated list of modules that should be loaded as plugins: export PYTEST_PLUGINS=mymodule.plugin,xdist -PYTEST_DISABLE_PLUGIN_AUTOLOAD -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +.. envvar:: PY_COLORS -When set, disables plugin auto-loading through setuptools entrypoints. Only explicitly specified plugins will be -loaded. +When set to ``1``, pytest will use color in terminal output. +When set to ``0``, pytest will not use color. +``PY_COLORS`` takes precedence over ``NO_COLOR`` and ``FORCE_COLOR``. -PYTEST_CURRENT_TEST -~~~~~~~~~~~~~~~~~~~ +.. envvar:: NO_COLOR -This is not meant to be set by users, but is set by pytest internally with the name of the current test so other -processes can inspect it, see :ref:`pytest current test env` for more information. +When set (regardless of value), pytest will not use color in terminal output. +``PY_COLORS`` takes precedence over ``NO_COLOR``, which takes precedence over ``FORCE_COLOR``. +See `no-color.org `__ for other libraries supporting this community standard. + +.. envvar:: FORCE_COLOR + +When set (regardless of value), pytest will use color in terminal output. +``PY_COLORS`` and ``NO_COLOR`` take precedence over ``FORCE_COLOR``. Exceptions ---------- -UsageError -~~~~~~~~~~ +.. autoclass:: pytest.UsageError() + :show-inheritance: + +.. _`warnings ref`: + +Warnings +-------- + +Custom warnings generated in some situations such as improper usage or deprecated features. + +.. autoclass:: pytest.PytestWarning + :show-inheritance: + +.. autoclass:: pytest.PytestAssertRewriteWarning + :show-inheritance: + +.. autoclass:: pytest.PytestCacheWarning + :show-inheritance: + +.. autoclass:: pytest.PytestCollectionWarning + :show-inheritance: -.. autoclass:: _pytest.config.UsageError() +.. autoclass:: pytest.PytestConfigWarning + :show-inheritance: + +.. autoclass:: pytest.PytestDeprecationWarning + :show-inheritance: + +.. autoclass:: pytest.PytestExperimentalApiWarning + :show-inheritance: + +.. autoclass:: pytest.PytestUnhandledCoroutineWarning + :show-inheritance: + +.. autoclass:: pytest.PytestUnknownMarkWarning + :show-inheritance: + +.. autoclass:: pytest.PytestUnraisableExceptionWarning + :show-inheritance: + +.. autoclass:: pytest.PytestUnhandledThreadExceptionWarning + :show-inheritance: + + +Consult the :ref:`internal-warnings` section in the documentation for more information. .. _`ini options ref`: @@ -1018,17 +1099,17 @@ UsageError Configuration Options --------------------- -Here is a list of builtin configuration options that may be written in a ``pytest.ini``, ``tox.ini`` or ``setup.cfg`` -file, usually located at the root of your repository. All options must be under a ``[pytest]`` section -(``[tool:pytest]`` for ``setup.cfg`` files). +Here is a list of builtin configuration options that may be written in a ``pytest.ini``, ``pyproject.toml``, ``tox.ini`` or ``setup.cfg`` +file, usually located at the root of your repository. To see each file format in details, see +:ref:`config file formats`. .. warning:: - Usage of ``setup.cfg`` is not recommended unless for very simple use cases. ``.cfg`` + Usage of ``setup.cfg`` is not recommended except for very simple use cases. ``.cfg`` files use a different parser than ``pytest.ini`` and ``tox.ini`` which might cause hard to track down problems. - When possible, it is recommended to use the latter files to hold your pytest configuration. + When possible, it is recommended to use the latter files, or ``pyproject.toml``, to hold your pytest configuration. -Configuration file options may be overwritten in the command-line by using ``-o/--override``, which can also be +Configuration options may be overwritten in the command-line by using ``-o/--override-ini``, which can also be passed multiple times. The expected format is ``name=value``. For example:: pytest -o console_output_style=classic -o cache_dir=/tmp/mycache @@ -1056,8 +1137,6 @@ passed multiple times. The expected format is ``name=value``. For example:: .. confval:: cache_dir - - Sets a directory where stores content of cache plugin. Default directory is ``.pytest_cache`` which is created in :ref:`rootdir `. Directory may be relative or absolute path. If setting relative path, then directory is created @@ -1186,12 +1265,13 @@ passed multiple times. The expected format is ``name=value``. For example:: .. confval:: junit_family .. versionadded:: 4.2 + .. versionchanged:: 6.1 + Default changed to ``xunit2``. Configures the format of the generated JUnit XML file. The possible options are: - * ``xunit1`` (or ``legacy``): produces old style output, compatible with the xunit 1.0 format. **This is the default**. - * ``xunit2``: produces `xunit 2.0 style output `__, - which should be more compatible with latest Jenkins versions. + * ``xunit1`` (or ``legacy``): produces old style output, compatible with the xunit 1.0 format. + * ``xunit2``: produces `xunit 2.0 style output `__, which should be more compatible with latest Jenkins versions. **This is the default**. .. code-block:: ini @@ -1416,20 +1496,6 @@ passed multiple times. The expected format is ``name=value``. For example:: For more information, see :ref:`logging`. -.. confval:: log_print - - - - If set to ``False``, will disable displaying captured logging messages for failed tests. - - .. code-block:: ini - - [pytest] - log_print = False - - For more information, see :ref:`logging`. - - .. confval:: markers When the ``--strict-markers`` or ``--strict`` command-line arguments are used, @@ -1447,6 +1513,11 @@ passed multiple times. The expected format is ``name=value``. For example:: slow serial + .. note:: + The use of ``--strict-markers`` is highly preferred. ``--strict`` was kept for + backward compatibility only and may be confusing for others as it only applies to + markers and not to other options. + .. confval:: minversion Specifies a minimal pytest version required for running tests. @@ -1470,7 +1541,8 @@ passed multiple times. The expected format is ``name=value``. For example:: [seq] matches any character in seq [!seq] matches any char not in seq - Default patterns are ``'.*', 'build', 'dist', 'CVS', '_darcs', '{arch}', '*.egg', 'venv'``. + Default patterns are ``'*.egg'``, ``'.*'``, ``'_darcs'``, ``'build'``, + ``'CVS'``, ``'dist'``, ``'node_modules'``, ``'venv'``, ``'{arch}'``. Setting a ``norecursedirs`` replaces the default. Here is an example of how to avoid certain directories: @@ -1555,6 +1627,19 @@ passed multiple times. The expected format is ``name=value``. For example:: See :ref:`change naming conventions` for more detailed examples. +.. confval:: required_plugins + + A space separated list of plugins that must be present for pytest to run. + Plugins can be listed with or without version specifiers directly following + their name. Whitespace between different version specifiers is not allowed. + If any one of the plugins is not found, emit an error. + + .. code-block:: ini + + [pytest] + required_plugins = pytest-django>=3.0.0,<4.0.0 pytest-html pytest-xdist>=1.0.0 + + .. confval:: testpaths @@ -1598,3 +1683,297 @@ passed multiple times. The expected format is ``name=value``. For example:: [pytest] xfail_strict = True + + +.. _`command-line-flags`: + +Command-line Flags +------------------ + +All the command-line flags can be obtained by running ``pytest --help``:: + + $ pytest --help + usage: pytest [options] [file_or_dir] [file_or_dir] [...] + + positional arguments: + file_or_dir + + general: + -k EXPRESSION only run tests which match the given substring + expression. An expression is a python evaluatable + expression where all names are substring-matched + against test names and their parent classes. + Example: -k 'test_method or test_other' matches all + test functions and classes whose name contains + 'test_method' or 'test_other', while -k 'not + test_method' matches those that don't contain + 'test_method' in their names. -k 'not test_method + and not test_other' will eliminate the matches. + Additionally keywords are matched to classes and + functions containing extra names in their + 'extra_keyword_matches' set, as well as functions + which have names assigned directly to them. The + matching is case-insensitive. + -m MARKEXPR only run tests matching given mark expression. + For example: -m 'mark1 and not mark2'. + --markers show markers (builtin, plugin and per-project ones). + -x, --exitfirst exit instantly on first error or failed test. + --fixtures, --funcargs + show available fixtures, sorted by plugin appearance + (fixtures with leading '_' are only shown with '-v') + --fixtures-per-test show fixtures per test + --pdb start the interactive Python debugger on errors or + KeyboardInterrupt. + --pdbcls=modulename:classname + start a custom interactive Python debugger on + errors. For example: + --pdbcls=IPython.terminal.debugger:TerminalPdb + --trace Immediately break when running each test. + --capture=method per-test capturing method: one of fd|sys|no|tee-sys. + -s shortcut for --capture=no. + --runxfail report the results of xfail tests as if they were + not marked + --lf, --last-failed rerun only the tests that failed at the last run (or + all if none failed) + --ff, --failed-first run all tests, but run the last failures first. + This may re-order tests and thus lead to repeated + fixture setup/teardown. + --nf, --new-first run tests from new files first, then the rest of the + tests sorted by file mtime + --cache-show=[CACHESHOW] + show cache contents, don't perform collection or + tests. Optional argument: glob (default: '*'). + --cache-clear remove all cache contents at start of test run. + --lfnf={all,none}, --last-failed-no-failures={all,none} + which tests to run with no previously (known) + failures. + --sw, --stepwise exit on test failure and continue from last failing + test next time + --sw-skip, --stepwise-skip + ignore the first failing test but stop on the next + failing test + + reporting: + --durations=N show N slowest setup/test durations (N=0 for all). + --durations-min=N Minimal duration in seconds for inclusion in slowest + list. Default 0.005 + -v, --verbose increase verbosity. + --no-header disable header + --no-summary disable summary + -q, --quiet decrease verbosity. + --verbosity=VERBOSE set verbosity. Default is 0. + -r chars show extra test summary info as specified by chars: + (f)ailed, (E)rror, (s)kipped, (x)failed, (X)passed, + (p)assed, (P)assed with output, (a)ll except passed + (p/P), or (A)ll. (w)arnings are enabled by default + (see --disable-warnings), 'N' can be used to reset + the list. (default: 'fE'). + --disable-warnings, --disable-pytest-warnings + disable warnings summary + -l, --showlocals show locals in tracebacks (disabled by default). + --tb=style traceback print mode + (auto/long/short/line/native/no). + --show-capture={no,stdout,stderr,log,all} + Controls how captured stdout/stderr/log is shown on + failed tests. Default is 'all'. + --full-trace don't cut any tracebacks (default is to cut). + --color=color color terminal output (yes/no/auto). + --code-highlight={yes,no} + Whether code should be highlighted (only if --color + is also enabled) + --pastebin=mode send failed|all info to bpaste.net pastebin service. + --junit-xml=path create junit-xml style report file at given path. + --junit-prefix=str prepend prefix to classnames in junit-xml output + + pytest-warnings: + -W PYTHONWARNINGS, --pythonwarnings=PYTHONWARNINGS + set which warnings to report, see -W option of + python itself. + --maxfail=num exit after first num failures or errors. + --strict-config any warnings encountered while parsing the `pytest` + section of the configuration file raise errors. + --strict-markers markers not registered in the `markers` section of + the configuration file raise errors. + --strict (deprecated) alias to --strict-markers. + -c file load configuration from `file` instead of trying to + locate one of the implicit configuration files. + --continue-on-collection-errors + Force test execution even if collection errors + occur. + --rootdir=ROOTDIR Define root directory for tests. Can be relative + path: 'root_dir', './root_dir', + 'root_dir/another_dir/'; absolute path: + '/home/user/root_dir'; path with variables: + '$HOME/root_dir'. + + collection: + --collect-only, --co only collect tests, don't execute them. + --pyargs try to interpret all arguments as python packages. + --ignore=path ignore path during collection (multi-allowed). + --ignore-glob=path ignore path pattern during collection (multi- + allowed). + --deselect=nodeid_prefix + deselect item (via node id prefix) during collection + (multi-allowed). + --confcutdir=dir only load conftest.py's relative to specified dir. + --noconftest Don't load any conftest.py files. + --keep-duplicates Keep duplicate tests. + --collect-in-virtualenv + Don't ignore tests in a local virtualenv directory + --import-mode={prepend,append,importlib} + prepend/append to sys.path when importing test + modules and conftest files, default is to prepend. + --doctest-modules run doctests in all .py modules + --doctest-report={none,cdiff,ndiff,udiff,only_first_failure} + choose another output format for diffs on doctest + failure + --doctest-glob=pat doctests file matching pattern, default: test*.txt + --doctest-ignore-import-errors + ignore doctest ImportErrors + --doctest-continue-on-failure + for a given doctest, continue to run after the first + failure + + test session debugging and configuration: + --basetemp=dir base temporary directory for this test run.(warning: + this directory is removed if it exists) + -V, --version display pytest version and information about + plugins.When given twice, also display information + about plugins. + -h, --help show help message and configuration info + -p name early-load given plugin module name or entry point + (multi-allowed). + To avoid loading of plugins, use the `no:` prefix, + e.g. `no:doctest`. + --trace-config trace considerations of conftest.py files. + --debug store internal tracing debug information in + 'pytestdebug.log'. + -o OVERRIDE_INI, --override-ini=OVERRIDE_INI + override ini option with "option=value" style, e.g. + `-o xfail_strict=True -o cache_dir=cache`. + --assert=MODE Control assertion debugging tools. + 'plain' performs no assertion debugging. + 'rewrite' (the default) rewrites assert statements + in test modules on import to provide assert + expression information. + --setup-only only setup fixtures, do not execute tests. + --setup-show show setup of fixtures while executing tests. + --setup-plan show what fixtures and tests would be executed but + don't execute anything. + + logging: + --log-level=LEVEL level of messages to catch/display. + Not set by default, so it depends on the root/parent + log handler's effective level, where it is "WARNING" + by default. + --log-format=LOG_FORMAT + log format as used by the logging module. + --log-date-format=LOG_DATE_FORMAT + log date format as used by the logging module. + --log-cli-level=LOG_CLI_LEVEL + cli logging level. + --log-cli-format=LOG_CLI_FORMAT + log format as used by the logging module. + --log-cli-date-format=LOG_CLI_DATE_FORMAT + log date format as used by the logging module. + --log-file=LOG_FILE path to a file when logging will be written to. + --log-file-level=LOG_FILE_LEVEL + log file logging level. + --log-file-format=LOG_FILE_FORMAT + log format as used by the logging module. + --log-file-date-format=LOG_FILE_DATE_FORMAT + log date format as used by the logging module. + --log-auto-indent=LOG_AUTO_INDENT + Auto-indent multiline messages passed to the logging + module. Accepts true|on, false|off or an integer. + + [pytest] ini-options in the first pytest.ini|tox.ini|setup.cfg file found: + + markers (linelist): markers for test functions + empty_parameter_set_mark (string): + default marker for empty parametersets + norecursedirs (args): directory patterns to avoid for recursion + testpaths (args): directories to search for tests when no files or + directories are given in the command line. + filterwarnings (linelist): + Each line specifies a pattern for + warnings.filterwarnings. Processed after + -W/--pythonwarnings. + usefixtures (args): list of default fixtures to be used with this + project + python_files (args): glob-style file patterns for Python test module + discovery + python_classes (args): + prefixes or glob names for Python test class + discovery + python_functions (args): + prefixes or glob names for Python test function and + method discovery + disable_test_id_escaping_and_forfeit_all_rights_to_community_support (bool): + disable string escape non-ascii characters, might + cause unwanted side effects(use at your own risk) + console_output_style (string): + console output: "classic", or with additional + progress information ("progress" (percentage) | + "count"). + xfail_strict (bool): default for the strict parameter of xfail markers + when not given explicitly (default: False) + enable_assertion_pass_hook (bool): + Enables the pytest_assertion_pass hook.Make sure to + delete any previously generated pyc cache files. + junit_suite_name (string): + Test suite name for JUnit report + junit_logging (string): + Write captured log messages to JUnit report: one of + no|log|system-out|system-err|out-err|all + junit_log_passing_tests (bool): + Capture log information for passing tests to JUnit + report: + junit_duration_report (string): + Duration time to report: one of total|call + junit_family (string): + Emit XML for schema: one of legacy|xunit1|xunit2 + doctest_optionflags (args): + option flags for doctests + doctest_encoding (string): + encoding used for doctest files + cache_dir (string): cache directory path. + log_level (string): default value for --log-level + log_format (string): default value for --log-format + log_date_format (string): + default value for --log-date-format + log_cli (bool): enable log display during test run (also known as + "live logging"). + log_cli_level (string): + default value for --log-cli-level + log_cli_format (string): + default value for --log-cli-format + log_cli_date_format (string): + default value for --log-cli-date-format + log_file (string): default value for --log-file + log_file_level (string): + default value for --log-file-level + log_file_format (string): + default value for --log-file-format + log_file_date_format (string): + default value for --log-file-date-format + log_auto_indent (string): + default value for --log-auto-indent + faulthandler_timeout (string): + Dump the traceback of all threads if a test takes + more than TIMEOUT seconds to finish. + addopts (args): extra command line options + minversion (string): minimally required pytest version + required_plugins (args): + plugins that must be present for pytest to run + + environment variables: + PYTEST_ADDOPTS extra command line options + PYTEST_PLUGINS comma-separated plugins to load during startup + PYTEST_DISABLE_PLUGIN_AUTOLOAD set to disable plugin auto-loading + PYTEST_DEBUG set to enable debug tracing of pytest's internals + + + to see available markers type: pytest --markers + to see available fixtures type: pytest --fixtures + (shown according to specified file_or_dir or current dir if not specified; fixtures with leading '_' are only shown with the '-v' option diff --git a/doc/en/requirements.txt b/doc/en/requirements.txt index be22b7db872..fa37acfb447 100644 --- a/doc/en/requirements.txt +++ b/doc/en/requirements.txt @@ -1,4 +1,5 @@ +pallets-sphinx-themes pygments-pytest>=1.1.0 -sphinx>=1.8.2,<2.1 -sphinxcontrib-trio sphinx-removed-in>=0.2.0 +sphinx>=3.1,<4 +sphinxcontrib-trio diff --git a/doc/en/skipping.rst b/doc/en/skipping.rst index 73ce4868976..282820545c3 100644 --- a/doc/en/skipping.rst +++ b/doc/en/skipping.rst @@ -14,7 +14,7 @@ otherwise pytest should skip running the test altogether. Common examples are sk windows-only tests on non-windows platforms, or skipping tests that depend on an external resource which is not available at the moment (for example a database). -A **xfail** means that you expect a test to fail for some reason. +An **xfail** means that you expect a test to fail for some reason. A common example is a test for a feature not yet implemented, or a bug not yet fixed. When a test passes despite being expected to fail (marked with ``pytest.mark.xfail``), it's an **xpass** and will be reported in the test summary. @@ -91,7 +91,7 @@ when run on an interpreter earlier than Python3.6: import sys - @pytest.mark.skipif(sys.version_info < (3, 6), reason="requires python3.6 or higher") + @pytest.mark.skipif(sys.version_info < (3, 7), reason="requires python3.7 or higher") def test_function(): ... @@ -152,8 +152,8 @@ You can use the ``skipif`` marker (as any other marker) on classes: If the condition is ``True``, this marker will produce a skip result for each of the test methods of that class. -If you want to skip all test functions of a module, you may use -the ``pytestmark`` name on the global level: +If you want to skip all test functions of a module, you may use the +:globalvar:`pytestmark` global: .. code-block:: python @@ -259,49 +259,35 @@ These two examples illustrate situations where you don't want to check for a con at the module level, which is when a condition would otherwise be evaluated for marks. This will make ``test_function`` ``XFAIL``. Note that no other code is executed after -the ``pytest.xfail`` call, differently from the marker. That's because it is implemented +the :func:`pytest.xfail` call, differently from the marker. That's because it is implemented internally by raising a known exception. **Reference**: :ref:`pytest.mark.xfail ref` -.. _`xfail strict tutorial`: - -``strict`` parameter -~~~~~~~~~~~~~~~~~~~~ - - +``condition`` parameter +~~~~~~~~~~~~~~~~~~~~~~~ -Both ``XFAIL`` and ``XPASS`` don't fail the test suite by default. -You can change this by setting the ``strict`` keyword-only parameter to ``True``: +If a test is only expected to fail under a certain condition, you can pass +that condition as the first parameter: .. code-block:: python - @pytest.mark.xfail(strict=True) + @pytest.mark.xfail(sys.platform == "win32", reason="bug in a 3rd party library") def test_function(): ... - -This will make ``XPASS`` ("unexpectedly passing") results from this test to fail the test suite. - -You can change the default value of the ``strict`` parameter using the -``xfail_strict`` ini option: - -.. code-block:: ini - - [pytest] - xfail_strict=true - +Note that you have to pass a reason as well (see the parameter description at +:ref:`pytest.mark.xfail ref`). ``reason`` parameter ~~~~~~~~~~~~~~~~~~~~ -As with skipif_ you can also mark your expectation of a failure -on a particular platform: +You can specify the motive of an expected failure with the ``reason`` parameter: .. code-block:: python - @pytest.mark.xfail(sys.version_info >= (3, 6), reason="python3.6 api changes") + @pytest.mark.xfail(reason="known parser issue") def test_function(): ... @@ -336,6 +322,31 @@ even executed, use the ``run`` parameter as ``False``: This is specially useful for xfailing tests that are crashing the interpreter and should be investigated later. +.. _`xfail strict tutorial`: + +``strict`` parameter +~~~~~~~~~~~~~~~~~~~~ + +Both ``XFAIL`` and ``XPASS`` don't fail the test suite by default. +You can change this by setting the ``strict`` keyword-only parameter to ``True``: + +.. code-block:: python + + @pytest.mark.xfail(strict=True) + def test_function(): + ... + + +This will make ``XPASS`` ("unexpectedly passing") results from this test to fail the test suite. + +You can change the default value of the ``strict`` parameter using the +``xfail_strict`` ini option: + +.. code-block:: ini + + [pytest] + xfail_strict=true + Ignoring xfail ~~~~~~~~~~~~~~ @@ -347,7 +358,7 @@ By specifying on the commandline: pytest --runxfail you can force the running and reporting of an ``xfail`` marked test -as if it weren't marked at all. This also causes ``pytest.xfail`` to produce no effect. +as if it weren't marked at all. This also causes :func:`pytest.xfail` to produce no effect. Examples ~~~~~~~~ @@ -362,7 +373,7 @@ Running it with the report-on-xfail option gives this output: example $ pytest -rx xfail_demo.py =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-5.x.y, py-1.x.y, pluggy-0.x.y + platform linux -- Python 3.x.y, pytest-6.x.y, py-1.x.y, pluggy-0.x.y cachedir: $PYTHON_PREFIX/.pytest_cache rootdir: $REGENDOC_TMPDIR/example collected 7 items diff --git a/doc/en/talks.rst b/doc/en/talks.rst index 26df77c295c..216ccb8dd8a 100644 --- a/doc/en/talks.rst +++ b/doc/en/talks.rst @@ -2,8 +2,6 @@ Talks and Tutorials ========================== -.. _`funcargs`: funcargs.html - Books --------------------------------------------- @@ -16,6 +14,16 @@ Books Talks and blog postings --------------------------------------------- +- Webinar: `pytest: Test Driven Development für Python (German) `_, Florian Bruhin, via mylearning.ch, 2020 + +- Webinar: `Simplify Your Tests with Fixtures `_, Oliver Bestwalter, via JetBrains, 2020 + +- Training: `Introduction to pytest - simple, rapid and fun testing with Python `_, Florian Bruhin, PyConDE 2019 + +- Abridged metaprogramming classics - this episode: pytest, Oliver Bestwalter, PyConDE 2019 (`repository `__, `recording `__) + +- Testing PySide/PyQt code easily using the pytest framework, Florian Bruhin, Qt World Summit 2019 (`slides `__, `recording `__) + - `pytest: recommendations, basic packages for testing in Python and Django, Andreu Vallbona, PyBCN June 2019 `_. - pytest: recommendations, basic packages for testing in Python and Django, Andreu Vallbona, PyconES 2017 (`slides in english `_, `video in spanish `_) @@ -47,8 +55,6 @@ Talks and blog postings - `pytest: helps you write better Django apps, Andreas Pelme, DjangoCon Europe 2014 `_. -- :ref:`fixtures` - - `Testing Django Applications with pytest, Andreas Pelme, EuroPython 2013 `_. diff --git a/doc/en/tmpdir.rst b/doc/en/tmpdir.rst index a4f7326fd92..adcba02cb15 100644 --- a/doc/en/tmpdir.rst +++ b/doc/en/tmpdir.rst @@ -15,13 +15,11 @@ You can use the ``tmp_path`` fixture which will provide a temporary directory unique to the test invocation, created in the `base temporary directory`_. -``tmp_path`` is a ``pathlib/pathlib2.Path`` object. Here is an example test usage: +``tmp_path`` is a ``pathlib.Path`` object. Here is an example test usage: .. code-block:: python # content of test_tmp_path.py - import os - CONTENT = "content" @@ -41,7 +39,7 @@ Running this would result in a passed test except for the last $ pytest test_tmp_path.py =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-5.x.y, py-1.x.y, pluggy-0.x.y + platform linux -- Python 3.x.y, pytest-6.x.y, py-1.x.y, pluggy-0.x.y cachedir: $PYTHON_PREFIX/.pytest_cache rootdir: $REGENDOC_TMPDIR collected 1 item @@ -63,7 +61,7 @@ Running this would result in a passed test except for the last > assert 0 E assert 0 - test_tmp_path.py:13: AssertionError + test_tmp_path.py:11: AssertionError ========================= short test summary info ========================== FAILED test_tmp_path.py::test_create_file - assert 0 ============================ 1 failed in 0.12s ============================= @@ -97,9 +95,6 @@ and more. Here is an example test usage: .. code-block:: python # content of test_tmpdir.py - import os - - def test_create_file(tmpdir): p = tmpdir.mkdir("sub").join("hello.txt") p.write("content") @@ -114,7 +109,7 @@ Running this would result in a passed test except for the last $ pytest test_tmpdir.py =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-5.x.y, py-1.x.y, pluggy-0.x.y + platform linux -- Python 3.x.y, pytest-6.x.y, py-1.x.y, pluggy-0.x.y cachedir: $PYTHON_PREFIX/.pytest_cache rootdir: $REGENDOC_TMPDIR collected 1 item @@ -134,7 +129,7 @@ Running this would result in a passed test except for the last > assert 0 E assert 0 - test_tmpdir.py:9: AssertionError + test_tmpdir.py:6: AssertionError ========================= short test summary info ========================== FAILED test_tmpdir.py::test_create_file - assert 0 ============================ 1 failed in 0.12s ============================= @@ -192,8 +187,13 @@ You can override the default temporary directory setting like this: pytest --basetemp=mydir -When distributing tests on the local machine, ``pytest`` takes care to -configure a basetemp directory for the sub processes such that all temporary +.. warning:: + + The contents of ``mydir`` will be completely removed, so make sure to use a directory + for that purpose only. + +When distributing tests on the local machine using ``pytest-xdist``, care is taken to +automatically configure a basetemp directory for the sub processes such that all temporary data lands below a single per-test run basetemp directory. .. _`py.path.local`: https://py.readthedocs.io/en/latest/path.html diff --git a/doc/en/unittest.rst b/doc/en/unittest.rst index c30b171104e..130e7705b8d 100644 --- a/doc/en/unittest.rst +++ b/doc/en/unittest.rst @@ -137,7 +137,7 @@ the ``self.db`` values in the traceback: $ pytest test_unittest_db.py =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-5.x.y, py-1.x.y, pluggy-0.x.y + platform linux -- Python 3.x.y, pytest-6.x.y, py-1.x.y, pluggy-0.x.y cachedir: $PYTHON_PREFIX/.pytest_cache rootdir: $REGENDOC_TMPDIR collected 2 items diff --git a/doc/en/usage.rst b/doc/en/usage.rst index 02457d015a7..fbd3333dabc 100644 --- a/doc/en/usage.rst +++ b/doc/en/usage.rst @@ -33,7 +33,7 @@ Running ``pytest`` can result in six different exit codes: :Exit code 4: pytest command line usage error :Exit code 5: No tests were collected -They are represented by the :class:`_pytest.config.ExitCode` enum. The exit codes being a part of the public API can be imported and accessed directly using: +They are represented by the :class:`pytest.ExitCode` enum. The exit codes being a part of the public API can be imported and accessed directly using: .. code-block:: python @@ -57,6 +57,8 @@ Getting help on version, option names, environment variables pytest -h | --help # show help on command line and config file options +The full command-line flags can be found in the :ref:`reference `. + .. _maxfail: Stopping after the first (or N) failures @@ -216,7 +218,7 @@ Example: $ pytest -ra =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-5.x.y, py-1.x.y, pluggy-0.x.y + platform linux -- Python 3.x.y, pytest-6.x.y, py-1.x.y, pluggy-0.x.y cachedir: $PYTHON_PREFIX/.pytest_cache rootdir: $REGENDOC_TMPDIR collected 6 items @@ -241,7 +243,7 @@ Example: test_example.py:14: AssertionError ========================= short test summary info ========================== - SKIPPED [1] $REGENDOC_TMPDIR/test_example.py:22: skipping this test + SKIPPED [1] test_example.py:22: skipping this test XFAIL test_example.py::test_xfail reason: xfailing this test XPASS test_example.py::test_xpass always xfail @@ -274,7 +276,7 @@ More than one character can be used, so for example to only see failed and skipp $ pytest -rfs =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-5.x.y, py-1.x.y, pluggy-0.x.y + platform linux -- Python 3.x.y, pytest-6.x.y, py-1.x.y, pluggy-0.x.y cachedir: $PYTHON_PREFIX/.pytest_cache rootdir: $REGENDOC_TMPDIR collected 6 items @@ -300,7 +302,7 @@ More than one character can be used, so for example to only see failed and skipp test_example.py:14: AssertionError ========================= short test summary info ========================== FAILED test_example.py::test_fail - assert 0 - SKIPPED [1] $REGENDOC_TMPDIR/test_example.py:22: skipping this test + SKIPPED [1] test_example.py:22: skipping this test == 1 failed, 1 passed, 1 skipped, 1 xfailed, 1 xpassed, 1 error in 0.12s === Using ``p`` lists the passing tests, whilst ``P`` adds an extra section "PASSES" with those tests that passed but had @@ -310,7 +312,7 @@ captured output: $ pytest -rpP =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-5.x.y, py-1.x.y, pluggy-0.x.y + platform linux -- Python 3.x.y, pytest-6.x.y, py-1.x.y, pluggy-0.x.y cachedir: $PYTHON_PREFIX/.pytest_cache rootdir: $REGENDOC_TMPDIR collected 6 items @@ -426,14 +428,15 @@ Pytest supports the use of ``breakpoint()`` with the following behaviours: Profiling test execution duration ------------------------------------- +.. versionchanged:: 6.0 -To get a list of the slowest 10 test durations: +To get a list of the slowest 10 test durations over 1.0s long: .. code-block:: bash - pytest --durations=10 + pytest --durations=10 --durations-min=1.0 -By default, pytest will not show test durations that are too small (<0.01s) unless ``-vv`` is passed on the command-line. +By default, pytest will not show test durations that are too small (<0.005s) unless ``-vv`` is passed on the command-line. .. _faulthandler: @@ -467,6 +470,38 @@ seconds to finish (not available on Windows). the command-line using ``-o faulthandler_timeout=X``. +.. _unraisable: + +Warning about unraisable exceptions and unhandled thread exceptions +------------------------------------------------------------------- + +.. versionadded:: 6.2 + +.. note:: + + These features only work on Python>=3.8. + +Unhandled exceptions are exceptions that are raised in a situation in which +they cannot propagate to a caller. The most common case is an exception raised +in a :meth:`__del__ ` implementation. + +Unhandled thread exceptions are exceptions raised in a :class:`~threading.Thread` +but not handled, causing the thread to terminate uncleanly. + +Both types of exceptions are normally considered bugs, but may go unnoticed +because they don't cause the program itself to crash. Pytest detects these +conditions and issues a warning that is visible in the test run summary. + +The plugins are automatically enabled for pytest runs, unless the +``-p no:unraisableexception`` (for unraisable exceptions) and +``-p no:threadexception`` (for thread exceptions) options are given on the +command-line. + +The warnings may be silenced selectivly using the :ref:`pytest.mark.filterwarnings ref` +mark. The warning categories are :class:`pytest.PytestUnraisableExceptionWarning` and +:class:`pytest.PytestUnhandledThreadExceptionWarning`. + + Creating JUnitXML format files ---------------------------------------------------- @@ -698,7 +733,7 @@ by the `PyPy-test`_ web page to show test results over several revisions. If you use this option, consider using the new `pytest-reportlog `__ plugin instead. - See `the deprecation docs `__ + See `the deprecation docs `__ for more information. diff --git a/doc/en/warnings.rst b/doc/en/warnings.rst index 17b244b7eee..5bbbcacbea0 100644 --- a/doc/en/warnings.rst +++ b/doc/en/warnings.rst @@ -28,7 +28,7 @@ Running pytest now produces this output: $ pytest test_show_warnings.py =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-5.x.y, py-1.x.y, pluggy-0.x.y + platform linux -- Python 3.x.y, pytest-6.x.y, py-1.x.y, pluggy-0.x.y cachedir: $PYTHON_PREFIX/.pytest_cache rootdir: $REGENDOC_TMPDIR collected 1 item @@ -40,7 +40,7 @@ Running pytest now produces this output: $REGENDOC_TMPDIR/test_show_warnings.py:5: UserWarning: api v1, should use functions from v2 warnings.warn(UserWarning("api v1, should use functions from v2")) - -- Docs: https://docs.pytest.org/en/latest/warnings.html + -- Docs: https://docs.pytest.org/en/stable/warnings.html ======================= 1 passed, 1 warning in 0.12s ======================= The ``-W`` flag can be passed to control which warnings will be displayed or even turn @@ -68,16 +68,30 @@ them into errors: FAILED test_show_warnings.py::test_one - UserWarning: api v1, should use ... 1 failed in 0.12s -The same option can be set in the ``pytest.ini`` file using the ``filterwarnings`` ini option. -For example, the configuration below will ignore all user warnings, but will transform +The same option can be set in the ``pytest.ini`` or ``pyproject.toml`` file using the +``filterwarnings`` ini option. For example, the configuration below will ignore all +user warnings and specific deprecation warnings matching a regex, but will transform all other warnings into errors. .. code-block:: ini + # pytest.ini [pytest] filterwarnings = error ignore::UserWarning + ignore:function ham\(\) is deprecated:DeprecationWarning + +.. code-block:: toml + + # pyproject.toml + [tool.pytest.ini_options] + filterwarnings = [ + "error", + "ignore::UserWarning", + # note the use of single quote below to denote "raw" strings in TOML + 'ignore:function ham\(\) is deprecated:DeprecationWarning', + ] When a warning matches more than one option in the list, the action for the last matching option @@ -117,7 +131,7 @@ Filters applied using a mark take precedence over filters passed on the command by the ``filterwarnings`` ini option. You may apply a filter to all tests of a class by using the ``filterwarnings`` mark as a class -decorator or to all tests in a module by setting the ``pytestmark`` variable: +decorator or to all tests in a module by setting the :globalvar:`pytestmark` variable: .. code-block:: python @@ -251,7 +265,7 @@ Asserting warnings with the warns function -You can check that code raises a particular warning using ``pytest.warns``, +You can check that code raises a particular warning using func:`pytest.warns`, which works in a similar manner to :ref:`raises `: .. code-block:: python @@ -279,7 +293,7 @@ argument ``match`` to assert that the exception matches a text or regex:: ... Failed: DID NOT WARN. No warnings of type ...UserWarning... was emitted... -You can also call ``pytest.warns`` on a function or code string: +You can also call func:`pytest.warns` on a function or code string: .. code-block:: python @@ -314,10 +328,10 @@ Alternatively, you can examine raised warnings in detail using the Recording warnings ------------------ -You can record raised warnings either using ``pytest.warns`` or with +You can record raised warnings either using func:`pytest.warns` or with the ``recwarn`` fixture. -To record with ``pytest.warns`` without asserting anything about the warnings, +To record with func:`pytest.warns` without asserting anything about the warnings, pass ``None`` as the expected warning type: .. code-block:: python @@ -346,14 +360,14 @@ The ``recwarn`` fixture will record warnings for the whole function: assert w.filename assert w.lineno -Both ``recwarn`` and ``pytest.warns`` return the same interface for recorded +Both ``recwarn`` and func:`pytest.warns` return the same interface for recorded warnings: a WarningsRecorder instance. To view the recorded warnings, you can iterate over this instance, call ``len`` on it to get the number of recorded warnings, or index into it to get a particular recorded warning. .. currentmodule:: _pytest.warnings -Full API: :class:`WarningsRecorder`. +Full API: :class:`~_pytest.recwarn.WarningsRecorder`. .. _custom_failure_messages: @@ -373,7 +387,7 @@ are met. pytest.fail("Expected a warning!") If no warnings are issued when calling ``f``, then ``not record`` will -evaluate to ``True``. You can then call ``pytest.fail`` with a +evaluate to ``True``. You can then call :func:`pytest.fail` with a custom error message. .. _internal-warnings: @@ -381,8 +395,6 @@ custom error message. Internal pytest warnings ------------------------ - - pytest may generate its own warnings in some situations, such as improper usage or deprecated features. For example, pytest will emit a warning if it encounters a class that matches :confval:`python_classes` but also @@ -407,7 +419,7 @@ defines an ``__init__`` constructor, as this prevents the class from being insta $REGENDOC_TMPDIR/test_pytest_warnings.py:1: PytestCollectionWarning: cannot collect test class 'Test' because it has a __init__ constructor (from: test_pytest_warnings.py) class Test: - -- Docs: https://docs.pytest.org/en/latest/warnings.html + -- Docs: https://docs.pytest.org/en/stable/warnings.html 1 warning in 0.12s These warnings might be filtered using the same builtin mechanisms used to filter other types of warnings. @@ -415,22 +427,4 @@ These warnings might be filtered using the same builtin mechanisms used to filte Please read our :ref:`backwards-compatibility` to learn how we proceed about deprecating and eventually removing features. -The following warning types are used by pytest and are part of the public API: - -.. autoclass:: pytest.PytestWarning - -.. autoclass:: pytest.PytestAssertRewriteWarning - -.. autoclass:: pytest.PytestCacheWarning - -.. autoclass:: pytest.PytestCollectionWarning - -.. autoclass:: pytest.PytestConfigWarning - -.. autoclass:: pytest.PytestDeprecationWarning - -.. autoclass:: pytest.PytestExperimentalApiWarning - -.. autoclass:: pytest.PytestUnhandledCoroutineWarning - -.. autoclass:: pytest.PytestUnknownMarkWarning +The full list of warnings is listed in :ref:`the reference documentation `. diff --git a/doc/en/writing_plugins.rst b/doc/en/writing_plugins.rst index 7040f25630b..908366d5290 100644 --- a/doc/en/writing_plugins.rst +++ b/doc/en/writing_plugins.rst @@ -33,26 +33,34 @@ Plugin discovery order at tool startup ``pytest`` loads plugin modules at tool startup in the following way: -* by loading all builtin plugins +1. by scanning the command line for the ``-p no:name`` option + and *blocking* that plugin from being loaded (even builtin plugins can + be blocked this way). This happens before normal command-line parsing. -* by loading all plugins registered through `setuptools entry points`_. +2. by loading all builtin plugins. -* by pre-scanning the command line for the ``-p name`` option - and loading the specified plugin before actual command line parsing. +3. by scanning the command line for the ``-p name`` option + and loading the specified plugin. This happens before normal command-line parsing. -* by loading all :file:`conftest.py` files as inferred by the command line - invocation: +4. by loading all plugins registered through `setuptools entry points`_. - - if no test paths are specified use current dir as a test path - - if exists, load ``conftest.py`` and ``test*/conftest.py`` relative - to the directory part of the first test path. +5. by loading all plugins specified through the :envvar:`PYTEST_PLUGINS` environment variable. - Note that pytest does not find ``conftest.py`` files in deeper nested - sub directories at tool startup. It is usually a good idea to keep - your ``conftest.py`` file in the top level test or project root directory. +6. by loading all :file:`conftest.py` files as inferred by the command line + invocation: -* by recursively loading all plugins specified by the - ``pytest_plugins`` variable in ``conftest.py`` files + - if no test paths are specified, use the current dir as a test path + - if exists, load ``conftest.py`` and ``test*/conftest.py`` relative + to the directory part of the first test path. After the ``conftest.py`` + file is loaded, load all plugins specified in its + :globalvar:`pytest_plugins` variable if present. + + Note that pytest does not find ``conftest.py`` files in deeper nested + sub directories at tool startup. It is usually a good idea to keep + your ``conftest.py`` file in the top level test or project root directory. + +7. by recursively loading all plugins specified by the + :globalvar:`pytest_plugins` variable in ``conftest.py`` files. .. _`pytest/plugin`: http://bitbucket.org/pytest-dev/pytest/src/tip/pytest/plugin/ @@ -99,6 +107,10 @@ Here is how you might run it:: See also: :ref:`pythonpath`. +.. note:: + Some hooks should be implemented only in plugins or conftest.py files situated at the + tests root directory due to how pytest discovers plugins during startup, + see the documentation of each hook for details. Writing your own plugin ----------------------- @@ -227,7 +239,7 @@ import ``helper.py`` normally. The contents of Requiring/Loading plugins in a test module or conftest file ----------------------------------------------------------- -You can require plugins in a test module or a ``conftest.py`` file like this: +You can require plugins in a test module or a ``conftest.py`` file using :globalvar:`pytest_plugins`: .. code-block:: python @@ -241,31 +253,31 @@ application modules: pytest_plugins = "myapp.testsupport.myplugin" -``pytest_plugins`` variables are processed recursively, so note that in the example above -if ``myapp.testsupport.myplugin`` also declares ``pytest_plugins``, the contents +:globalvar:`pytest_plugins` are processed recursively, so note that in the example above +if ``myapp.testsupport.myplugin`` also declares :globalvar:`pytest_plugins`, the contents of the variable will also be loaded as plugins, and so on. .. _`requiring plugins in non-root conftests`: .. note:: - Requiring plugins using a ``pytest_plugins`` variable in non-root + Requiring plugins using :globalvar:`pytest_plugins` variable in non-root ``conftest.py`` files is deprecated. This is important because ``conftest.py`` files implement per-directory hook implementations, but once a plugin is imported, it will affect the entire directory tree. In order to avoid confusion, defining - ``pytest_plugins`` in any ``conftest.py`` file which is not located in the + :globalvar:`pytest_plugins` in any ``conftest.py`` file which is not located in the tests root directory is deprecated, and will raise a warning. This mechanism makes it easy to share fixtures within applications or even external applications without the need to create external plugins using the ``setuptools``'s entry point technique. -Plugins imported by ``pytest_plugins`` will also automatically be marked +Plugins imported by :globalvar:`pytest_plugins` will also automatically be marked for assertion rewriting (see :func:`pytest.register_assert_rewrite`). However for this to have any effect the module must not be imported already; if it was already imported at the time the -``pytest_plugins`` statement is processed, a warning will result and +:globalvar:`pytest_plugins` statement is processed, a warning will result and assertions inside the plugin will not be rewritten. To fix this you can either call :func:`pytest.register_assert_rewrite` yourself before the module is imported, or you can arrange the code to delay the @@ -404,7 +416,7 @@ return a result object, with which we can assert the tests' outcomes. result.assert_outcomes(passed=4) -additionally it is possible to copy examples for an example folder before running pytest on it +Additionally it is possible to copy examples for an example folder before running pytest on it. .. code-block:: ini @@ -430,25 +442,14 @@ additionally it is possible to copy examples for an example folder before runnin $ pytest =========================== test session starts ============================ - platform linux -- Python 3.x.y, pytest-5.x.y, py-1.x.y, pluggy-0.x.y + platform linux -- Python 3.x.y, pytest-6.x.y, py-1.x.y, pluggy-0.x.y cachedir: $PYTHON_PREFIX/.pytest_cache - rootdir: $REGENDOC_TMPDIR, inifile: pytest.ini + rootdir: $REGENDOC_TMPDIR, configfile: pytest.ini collected 2 items test_example.py .. [100%] - ============================= warnings summary ============================= - test_example.py::test_plugin - $REGENDOC_TMPDIR/test_example.py:4: PytestExperimentalApiWarning: testdir.copy_example is an experimental api that may change over time - testdir.copy_example("test_example.py") - - test_example.py::test_plugin - $PYTHON_PREFIX/lib/python3.8/site-packages/_pytest/terminal.py:287: PytestDeprecationWarning: TerminalReporter.writer attribute is deprecated, use TerminalReporter._tw instead at your own risk. - See https://docs.pytest.org/en/latest/deprecations.html#terminalreporter-writer for more information. - warnings.warn( - - -- Docs: https://docs.pytest.org/en/latest/warnings.html - ====================== 2 passed, 2 warnings in 0.12s ======================= + ============================ 2 passed in 0.12s ============================= For more information about the result object that ``runpytest()`` returns, and the methods that it provides please check out the :py:class:`RunResult @@ -513,6 +514,7 @@ call only executes until the first of N registered functions returns a non-None result which is then taken as result of the overall hook call. The remaining hook functions will not be called in this case. +.. _`hookwrapper`: hookwrapper: executing around other hooks ------------------------------------------------- @@ -557,8 +559,10 @@ perform tracing or other side effects around the actual hook implementations. If the result of the underlying hook is a mutable object, they may modify that result but it's probably better to avoid it. -For more information, consult the `pluggy documentation `_. +For more information, consult the +:ref:`pluggy documentation about hookwrappers `. +.. _plugin-hookorder: Hook function ordering / call example ------------------------------------- @@ -616,6 +620,11 @@ among each other. Declaring new hooks ------------------------ +.. note:: + + This is a quick overview on how to add new hooks and how they work in general, but a more complete + overview can be found in `the pluggy documentation `__. + .. currentmodule:: _pytest.hookspec Plugins and ``conftest.py`` files may declare new hooks that can then be @@ -629,7 +638,7 @@ Hooks are usually declared as do-nothing functions that contain only documentation describing when the hook will be called and what return values are expected. The names of the functions must start with `pytest_` otherwise pytest won't recognize them. -Here's an example. Let's assume this code is in the ``hooks.py`` module. +Here's an example. Let's assume this code is in the ``sample_hook.py`` module. .. code-block:: python @@ -645,10 +654,10 @@ class or module can then be passed to the ``pluginmanager`` using the ``pytest_a .. code-block:: python def pytest_addhooks(pluginmanager): - """ This example assumes the hooks are grouped in the 'hooks' module. """ - from my_app.tests import hooks + """ This example assumes the hooks are grouped in the 'sample_hook' module. """ + from my_app.tests import sample_hook - pluginmanager.add_hookspecs(hooks) + pluginmanager.add_hookspecs(sample_hook) For a real world example, see `newhooks.py`_ from `xdist `_. diff --git a/doc/en/xunit_setup.rst b/doc/en/xunit_setup.rst index 83545223ae3..8b3366f62ae 100644 --- a/doc/en/xunit_setup.rst +++ b/doc/en/xunit_setup.rst @@ -12,7 +12,7 @@ fixtures (setup and teardown test state) on a per-module/class/function basis. .. note:: While these setup/teardown methods are simple and familiar to those - coming from a ``unittest`` or nose ``background``, you may also consider + coming from a ``unittest`` or ``nose`` background, you may also consider using pytest's more powerful :ref:`fixture mechanism ` which leverages the concept of dependency injection, allowing for a more modular and more scalable approach for managing test state, diff --git a/extra/get_issues.py b/extra/get_issues.py index 9407aeded7d..4aaa3c3ec31 100644 --- a/extra/get_issues.py +++ b/extra/get_issues.py @@ -1,6 +1,6 @@ import json +from pathlib import Path -import py import requests issues_url = "https://api.github.com/repos/pytest-dev/pytest/issues" @@ -31,12 +31,12 @@ def get_issues(): def main(args): - cachefile = py.path.local(args.cache) + cachefile = Path(args.cache) if not cachefile.exists() or args.refresh: issues = get_issues() - cachefile.write(json.dumps(issues)) + cachefile.write_text(json.dumps(issues), "utf-8") else: - issues = json.loads(cachefile.read()) + issues = json.loads(cachefile.read_text("utf-8")) open_issues = [x for x in issues if x["state"] == "open"] @@ -45,7 +45,7 @@ def main(args): def _get_kind(issue): - labels = [l["name"] for l in issue["labels"]] + labels = [label["name"] for label in issue["labels"]] for key in ("bug", "enhancement", "proposal"): if key in labels: return key diff --git a/pyproject.toml b/pyproject.toml index aa57762e75d..dd4be6c22d5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,12 +1,59 @@ [build-system] requires = [ # sync with setup.py until we discard non-pep-517/518 - "setuptools>=40.0", - "setuptools-scm", + "setuptools>=42.0", + "setuptools-scm[toml]>=3.4", "wheel", ] build-backend = "setuptools.build_meta" +[tool.setuptools_scm] +write_to = "src/_pytest/_version.py" + +[tool.pytest.ini_options] +minversion = "2.0" +addopts = "-rfEX -p pytester --strict-markers" +python_files = ["test_*.py", "*_test.py", "testing/python/*.py"] +python_classes = ["Test", "Acceptance"] +python_functions = ["test"] +# NOTE: "doc" is not included here, but gets tested explicitly via "doctesting". +testpaths = ["testing"] +norecursedirs = ["testing/example_scripts"] +xfail_strict = true +filterwarnings = [ + "error", + "default:Using or importing the ABCs:DeprecationWarning:unittest2.*", + # produced by older pyparsing<=2.2.0. + "default:Using or importing the ABCs:DeprecationWarning:pyparsing.*", + "default:the imp module is deprecated in favour of importlib:DeprecationWarning:nose.*", + # produced by python3.6/site.py itself (3.6.7 on Travis, could not trigger it with 3.6.8)." + "ignore:.*U.*mode is deprecated:DeprecationWarning:(?!(pytest|_pytest))", + # produced by pytest-xdist + "ignore:.*type argument to addoption.*:DeprecationWarning", + # produced on execnet (pytest-xdist) + "ignore:.*inspect.getargspec.*deprecated, use inspect.signature.*:DeprecationWarning", + # pytest's own futurewarnings + "ignore::pytest.PytestExperimentalApiWarning", + # Do not cause SyntaxError for invalid escape sequences in py37. + # Those are caught/handled by pyupgrade, and not easy to filter with the + # module being the filename (with .py removed). + "default:invalid escape sequence:DeprecationWarning", + # ignore use of unregistered marks, because we use many to test the implementation + "ignore::_pytest.warning_types.PytestUnknownMarkWarning", +] +pytester_example_dir = "testing/example_scripts" +markers = [ + # dummy markers for testing + "foo", + "bar", + "baz", + # conftest.py reorders tests moving slow ones to the end of the list + "slow", + # experimental mark for all tests using pexpect + "uses_pexpect", +] + + [tool.towncrier] package = "pytest" package_dir = "src" @@ -56,4 +103,4 @@ template = "changelog/_template.rst" showcontent = true [tool.black] -target-version = ['py35'] +target-version = ['py36'] diff --git a/scripts/release-on-comment.py b/scripts/release-on-comment.py index bd4986eaa8f..44431a4fc3f 100644 --- a/scripts/release-on-comment.py +++ b/scripts/release-on-comment.py @@ -2,20 +2,21 @@ This script is part of the pytest release process which is triggered by comments in issues. -This script is started by the `prepare_release.yml` workflow, which is triggered by two comment -related events: +This script is started by the `release-on-comment.yml` workflow, which always executes on +`master` and is triggered by two comment related events: * https://help.github.com/en/actions/reference/events-that-trigger-workflows#issue-comment-event-issue_comment * https://help.github.com/en/actions/reference/events-that-trigger-workflows#issues-event-issues This script receives the payload and a secrets on the command line. -The payload must contain a comment with a phrase matching this regular expression: +The payload must contain a comment with a phrase matching this pseudo-regular expression: - @pytestbot please prepare release from + @pytestbot please prepare (major )? release from Then the appropriate version will be obtained based on the given branch name: +* a major release from master if "major" appears in the phrase in that position * a feature or bug fix release from master (based if there are features in the current changelog folder) * a bug fix from a maintenance branch @@ -29,10 +30,12 @@ import json import os import re -import sys +import traceback from pathlib import Path +from subprocess import CalledProcessError from subprocess import check_call from subprocess import check_output +from subprocess import run from textwrap import dedent from typing import Dict from typing import Optional @@ -54,6 +57,8 @@ class InvalidFeatureRelease(Exception): Once all builds pass and it has been **approved** by one or more maintainers, the build can be released by pushing a tag `{version}` to this repository. + +Closes #{issue_number}. """ @@ -74,15 +79,15 @@ def get_comment_data(payload: Dict) -> str: def validate_and_get_issue_comment_payload( issue_payload_path: Optional[Path], -) -> Tuple[str, str]: +) -> Tuple[str, str, bool]: payload = json.loads(issue_payload_path.read_text(encoding="UTF-8")) body = get_comment_data(payload)["body"] - m = re.match(r"@pytestbot please prepare release from ([\w\-_\.]+)", body) + m = re.match(r"@pytestbot please prepare (major )?release from ([\w\-_\.]+)", body) if m: - base_branch = m.group(1) + is_major, base_branch = m.group(1) is not None, m.group(2) else: - base_branch = None - return payload, base_branch + is_major, base_branch = False, None + return payload, base_branch, is_major def print_and_exit(msg) -> None: @@ -91,7 +96,9 @@ def print_and_exit(msg) -> None: def trigger_release(payload_path: Path, token: str) -> None: - payload, base_branch = validate_and_get_issue_comment_payload(payload_path) + payload, base_branch, is_major = validate_and_get_issue_comment_payload( + payload_path + ) if base_branch is None: url = get_comment_data(payload)["html_url"] print_and_exit( @@ -106,34 +113,62 @@ def trigger_release(payload_path: Path, token: str) -> None: issue = repo.issue(issue_number) check_call(["git", "checkout", f"origin/{base_branch}"]) - print("DEBUG:", check_output(["git", "rev-parse", "HEAD"])) try: - version = find_next_version(base_branch) + version = find_next_version(base_branch, is_major) except InvalidFeatureRelease as e: issue.create_comment(str(e)) print_and_exit(f"{Fore.RED}{e}") + error_contents = "" try: print(f"Version: {Fore.CYAN}{version}") release_branch = f"release-{version}" - check_call(["git", "config", "user.name", "pytest bot"]) - check_call(["git", "config", "user.email", "pytestbot@gmail.com"]) + run( + ["git", "config", "user.name", "pytest bot"], + text=True, + check=True, + capture_output=True, + ) + run( + ["git", "config", "user.email", "pytestbot@gmail.com"], + text=True, + check=True, + capture_output=True, + ) - check_call(["git", "checkout", "-b", release_branch, f"origin/{base_branch}"]) + run( + ["git", "checkout", "-b", release_branch, f"origin/{base_branch}"], + text=True, + check=True, + capture_output=True, + ) print(f"Branch {Fore.CYAN}{release_branch}{Fore.RESET} created.") - check_call([sys.executable, "scripts/release.py", version]) + # important to use tox here because we have changed branches, so dependencies + # might have changed as well + cmdline = ["tox", "-e", "release", "--", version, "--skip-check-links"] + print("Running", " ".join(cmdline)) + run( + cmdline, text=True, check=True, capture_output=True, + ) oauth_url = f"https://{token}:x-oauth-basic@github.com/{SLUG}.git" - check_call(["git", "push", oauth_url, f"HEAD:{release_branch}", "--force"]) + run( + ["git", "push", oauth_url, f"HEAD:{release_branch}", "--force"], + text=True, + check=True, + capture_output=True, + ) print(f"Branch {Fore.CYAN}{release_branch}{Fore.RESET} pushed.") body = PR_BODY.format( - comment_url=get_comment_data(payload)["html_url"], version=version + comment_url=get_comment_data(payload)["html_url"], + version=version, + issue_number=issue_number, ) pr = repo.create_pull( f"Prepare release {version}", @@ -148,26 +183,34 @@ def trigger_release(payload_path: Path, token: str) -> None: ) print(f"Notified in original comment {Fore.CYAN}{comment.url}{Fore.RESET}.") - print(f"{Fore.GREEN}Success.") - except Exception as e: + except CalledProcessError as e: + error_contents = f"CalledProcessError\noutput:\n{e.output}\nstderr:\n{e.stderr}" + except Exception: + error_contents = f"Exception:\n{traceback.format_exc()}" + + if error_contents: link = f"https://github.com/{SLUG}/actions/runs/{os.environ['GITHUB_RUN_ID']}" - issue.create_comment( - dedent( - f""" - Sorry, the request to prepare release `{version}` from {base_branch} failed with: - - ``` - {e} - ``` - - See: {link}. - """ - ) + msg = ERROR_COMMENT.format( + version=version, base_branch=base_branch, contents=error_contents, link=link ) - print_and_exit(f"{Fore.RED}{e}") + issue.create_comment(msg) + print_and_exit(f"{Fore.RED}{error_contents}") + else: + print(f"{Fore.GREEN}Success.") + + +ERROR_COMMENT = """\ +The request to prepare release `{version}` from {base_branch} failed with: + +``` +{contents} +``` + +See: {link}. +""" -def find_next_version(base_branch: str) -> str: +def find_next_version(base_branch: str, is_major: bool) -> str: output = check_output(["git", "tag"], encoding="UTF-8") valid_versions = [] for v in output.splitlines(): @@ -188,13 +231,15 @@ def find_next_version(base_branch: str) -> str: msg = dedent( f""" Found features or breaking changes in `{base_branch}`, and feature releases can only be - created from `master`.": + created from `master`: """ ) msg += "\n".join(f"* `{x.name}`" for x in sorted(features + breaking)) raise InvalidFeatureRelease(msg) - if is_feature_release: + if is_major: + return f"{last_version[0]+1}.0.0" + elif is_feature_release: return f"{last_version[0]}.{last_version[1] + 1}.0" else: return f"{last_version[0]}.{last_version[1]}.{last_version[2] + 1}" diff --git a/scripts/release.minor.rst b/scripts/release.minor.rst index f71f9b1b64c..76e447f0c6d 100644 --- a/scripts/release.minor.rst +++ b/scripts/release.minor.rst @@ -3,23 +3,20 @@ pytest-{version} The pytest team is proud to announce the {version} release! -pytest is a mature Python testing tool with more than a 2000 tests -against itself, passing on many different interpreters and platforms. +This release contains new features, improvements, bug fixes, and breaking changes, so users +are encouraged to take a look at the CHANGELOG carefully: -This release contains a number of bug fixes and improvements, so users are encouraged -to take a look at the CHANGELOG: - - https://docs.pytest.org/en/latest/changelog.html + https://docs.pytest.org/en/stable/changelog.html For complete documentation, please visit: - https://docs.pytest.org/en/latest/ + https://docs.pytest.org/en/stable/ As usual, you can upgrade from PyPI via: pip install -U pytest -Thanks to all who contributed to this release, among them: +Thanks to all of the contributors to this release: {contributors} diff --git a/scripts/release.patch.rst b/scripts/release.patch.rst index b1ad2dbd775..59fbe50ce0e 100644 --- a/scripts/release.patch.rst +++ b/scripts/release.patch.rst @@ -7,9 +7,9 @@ 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/latest/changelog.html. +The full changelog is available at https://docs.pytest.org/en/stable/changelog.html. -Thanks to all who contributed to this release, among them: +Thanks to all of the contributors to this release: {contributors} diff --git a/scripts/release.py b/scripts/release.py index 466051d7e43..798e42e1fe0 100644 --- a/scripts/release.py +++ b/scripts/release.py @@ -1,7 +1,6 @@ -""" -Invoke development tasks. -""" +"""Invoke development tasks.""" import argparse +import os from pathlib import Path from subprocess import call from subprocess import check_call @@ -18,9 +17,7 @@ def announce(version): stdout = stdout.decode("utf-8") last_version = stdout.strip() - stdout = check_output( - ["git", "log", "{}..HEAD".format(last_version), "--format=%aN"] - ) + stdout = check_output(["git", "log", f"{last_version}..HEAD", "--format=%aN"]) stdout = stdout.decode("utf-8") contributors = set(stdout.splitlines()) @@ -32,14 +29,10 @@ def announce(version): Path(__file__).parent.joinpath(template_name).read_text(encoding="UTF-8") ) - contributors_text = ( - "\n".join("* {}".format(name) for name in sorted(contributors)) + "\n" - ) + contributors_text = "\n".join(f"* {name}" for name in sorted(contributors)) + "\n" text = template_text.format(version=version, contributors=contributors_text) - target = Path(__file__).parent.joinpath( - "../doc/en/announce/release-{}.rst".format(version) - ) + target = Path(__file__).parent.joinpath(f"../doc/en/announce/release-{version}.rst") target.write_text(text, encoding="UTF-8") print(f"{Fore.CYAN}[generate.announce] {Fore.RESET}Generated {target.name}") @@ -48,7 +41,7 @@ def announce(version): lines = index_path.read_text(encoding="UTF-8").splitlines() indent = " " for index, line in enumerate(lines): - if line.startswith("{}release-".format(indent)): + if line.startswith(f"{indent}release-"): new_line = indent + target.stem if line != new_line: lines.insert(index, new_line) @@ -65,10 +58,13 @@ def announce(version): check_call(["git", "add", str(target)]) -def regen(): +def regen(version): """Call regendoc tool to update examples and pytest output in the docs.""" print(f"{Fore.CYAN}[generate.regen] {Fore.RESET}Updating docs") - check_call(["tox", "-e", "regen"]) + check_call( + ["tox", "-e", "regen"], + env={**os.environ, "SETUPTOOLS_SCM_PRETEND_VERSION": version}, + ) def fix_formatting(): @@ -88,13 +84,13 @@ def check_links(): def pre_release(version, *, skip_check_links): """Generates new docs, release announcements and creates a local tag.""" announce(version) - regen() + regen(version) changelog(version, write_out=True) fix_formatting() if not skip_check_links: check_links() - msg = "Preparing release version {}".format(version) + msg = f"Prepare release version {version}" check_call(["git", "commit", "-a", "-m", msg]) print() diff --git a/scripts/towncrier-draft-to-file.py b/scripts/towncrier-draft-to-file.py new file mode 100644 index 00000000000..81507b40b75 --- /dev/null +++ b/scripts/towncrier-draft-to-file.py @@ -0,0 +1,15 @@ +import sys +from subprocess import call + + +def main(): + """ + Platform agnostic wrapper script for towncrier. + Fixes the issue (#7251) where windows users are unable to natively run tox -e docs to build pytest docs. + """ + with open("doc/en/_changelog_towncrier_draft.rst", "w") as draft_file: + return call(("towncrier", "--draft"), stdout=draft_file) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/setup.cfg b/setup.cfg index 708951da489..09c07d5bb6c 100644 --- a/setup.cfg +++ b/setup.cfg @@ -2,34 +2,34 @@ name = pytest description = pytest: simple powerful testing with Python long_description = file: README.rst +long_description_content_type = text/x-rst url = https://docs.pytest.org/en/latest/ -project_urls = - Source=https://github.com/pytest-dev/pytest - Tracker=https://github.com/pytest-dev/pytest/issues - author = Holger Krekel, Bruno Oliveira, Ronny Pfannschmidt, Floris Bruynooghe, Brianna Laugher, Florian Bruhin and others - -license = MIT license -keywords = test, unittest +license = MIT +license_file = LICENSE +platforms = unix, linux, osx, cygwin, win32 classifiers = Development Status :: 6 - Mature Intended Audience :: Developers License :: OSI Approved :: MIT License - Operating System :: POSIX - Operating System :: Microsoft :: Windows Operating System :: MacOS :: MacOS X - Topic :: Software Development :: Testing - Topic :: Software Development :: Libraries - Topic :: Utilities + Operating System :: Microsoft :: Windows + Operating System :: POSIX + Programming Language :: Python :: 3 Programming Language :: Python :: 3 :: Only - Programming Language :: Python :: 3.5 Programming Language :: Python :: 3.6 Programming Language :: Python :: 3.7 Programming Language :: Python :: 3.8 -platforms = unix, linux, osx, cygwin, win32 + Programming Language :: Python :: 3.9 + Topic :: Software Development :: Libraries + Topic :: Software Development :: Testing + Topic :: Utilities +keywords = test, unittest +project_urls = + Source=https://github.com/pytest-dev/pytest + Tracker=https://github.com/pytest-dev/pytest/issues [options] -zip_safe = no packages = _pytest _pytest._code @@ -38,13 +38,41 @@ packages = _pytest.config _pytest.mark pytest - -python_requires = >=3.5 +install_requires = + attrs>=19.2.0 + iniconfig + packaging + pluggy>=0.12,<1.0.0a1 + py>=1.8.2 + toml + atomicwrites>=1.0;sys_platform=="win32" + colorama;sys_platform=="win32" + importlib-metadata>=0.12;python_version<"3.8" +python_requires = >=3.6 +package_dir = + =src +setup_requires = + setuptools>=>=42.0 + setuptools-scm>=3.4 +zip_safe = no [options.entry_points] console_scripts = - pytest=pytest:main - py.test=pytest:main + pytest=pytest:console_main + py.test=pytest:console_main + +[options.extras_require] +testing = + argcomplete + hypothesis>=3.56 + mock + nose + requests + xmlschema + +[options.package_data] +_pytest = py.typed +pytest = py.typed [build_sphinx] source-dir = doc/en/ @@ -56,17 +84,21 @@ upload-dir = doc/en/build/html [check-manifest] ignore = - src/_pytest/_version.py + src/_pytest/_version.py [devpi:upload] formats = sdist.tgz,bdist_wheel [mypy] mypy_path = src +check_untyped_defs = True +disallow_any_generics = True ignore_missing_imports = True no_implicit_optional = True show_error_codes = True strict_equality = True warn_redundant_casts = True warn_return_any = True +warn_unreachable = True warn_unused_configs = True +no_implicit_reexport = True diff --git a/setup.py b/setup.py index 892b55aed64..7f1a1763ca9 100644 --- a/setup.py +++ b/setup.py @@ -1,42 +1,4 @@ from setuptools import setup -# TODO: if py gets upgrade to >=1.6, -# remove _width_of_current_line in terminal.py -INSTALL_REQUIRES = [ - "py>=1.5.0", - "packaging", - "attrs>=17.4.0", # should match oldattrs tox env. - "more-itertools>=4.0.0", - 'atomicwrites>=1.0;sys_platform=="win32"', - 'pathlib2>=2.2.0;python_version<"3.6"', - 'colorama;sys_platform=="win32"', - "pluggy>=0.12,<1.0", - 'importlib-metadata>=0.12;python_version<"3.8"', - "wcwidth", -] - - -def main(): - setup( - use_scm_version={"write_to": "src/_pytest/_version.py"}, - setup_requires=["setuptools-scm", "setuptools>=40.0"], - package_dir={"": "src"}, - extras_require={ - "testing": [ - "argcomplete", - "hypothesis>=3.56", - "mock", - "nose", - "requests", - "xmlschema", - ], - "checkqa-mypy": [ - "mypy==v0.761", # keep this in sync with .pre-commit-config.yaml. - ], - }, - install_requires=INSTALL_REQUIRES, - ) - - if __name__ == "__main__": - main() + setup() diff --git a/src/_pytest/_argcomplete.py b/src/_pytest/_argcomplete.py index 7ca216ecf96..41d9d9407c7 100644 --- a/src/_pytest/_argcomplete.py +++ b/src/_pytest/_argcomplete.py @@ -1,7 +1,8 @@ -"""allow bash-completion for argparse with argcomplete if installed -needs argcomplete>=0.5.6 for python 3.2/3.3 (older versions fail +"""Allow bash-completion for argparse with argcomplete if installed. + +Needs argcomplete>=0.5.6 for python 3.2/3.3 (older versions fail to find the magic string, so _ARGCOMPLETE env. var is never set, and -this does not need special code. +this does not need special code). Function try_argcomplete(parser) should be called directly before the call to ArgumentParser.parse_args(). @@ -10,8 +11,7 @@ arguments specification, in order to get "dirname/" after "dirn" instead of the default "dirname ": - optparser.add_argument(Config._file_or_dir, nargs='*' - ).completer=filescompleter + optparser.add_argument(Config._file_or_dir, nargs='*').completer=filescompleter Other, application specific, completers should go in the file doing the add_argument calls as they need to be specified as .completer @@ -20,35 +20,43 @@ SPEEDUP ======= + The generic argcomplete script for bash-completion -(/etc/bash_completion.d/python-argcomplete.sh ) +(/etc/bash_completion.d/python-argcomplete.sh) uses a python program to determine startup script generated by pip. You can speed up completion somewhat by changing this script to include # PYTHON_ARGCOMPLETE_OK -so the the python-argcomplete-check-easy-install-script does not +so the python-argcomplete-check-easy-install-script does not need to be called to find the entry point of the code and see if that is -marked with PYTHON_ARGCOMPLETE_OK +marked with PYTHON_ARGCOMPLETE_OK. INSTALL/DEBUGGING ================= + To include this support in another application that has setup.py generated scripts: -- add the line: + +- Add the line: # PYTHON_ARGCOMPLETE_OK - near the top of the main python entry point -- include in the file calling parse_args(): + near the top of the main python entry point. + +- Include in the file calling parse_args(): from _argcomplete import try_argcomplete, filescompleter - , call try_argcomplete just before parse_args(), and optionally add - filescompleter to the positional arguments' add_argument() + Call try_argcomplete just before parse_args(), and optionally add + filescompleter to the positional arguments' add_argument(). + If things do not work right away: -- switch on argcomplete debugging with (also helpful when doing custom + +- Switch on argcomplete debugging with (also helpful when doing custom completers): export _ARC_DEBUG=1 -- run: + +- Run: python-argcomplete-check-easy-install-script $(which appname) echo $? - will echo 0 if the magic line has been found, 1 if not -- sometimes it helps to find early on errors using: + will echo 0 if the magic line has been found, 1 if not. + +- Sometimes it helps to find early on errors using: _ARGCOMPLETE=1 _ARC_DEBUG=1 appname which should throw a KeyError: 'COMPLINE' (which is properly set by the global argcomplete script). @@ -63,13 +71,13 @@ class FastFilesCompleter: - "Fast file completer class" + """Fast file completer class.""" def __init__(self, directories: bool = True) -> None: self.directories = directories def __call__(self, prefix: str, **kwargs: Any) -> List[str]: - """only called on non option completions""" + # Only called on non option completions. if os.path.sep in prefix[1:]: prefix_dir = len(os.path.dirname(prefix) + os.path.sep) else: @@ -77,7 +85,7 @@ def __call__(self, prefix: str, **kwargs: Any) -> List[str]: completion = [] globbed = [] if "*" not in prefix and "?" not in prefix: - # we are on unix, otherwise no bash + # We are on unix, otherwise no bash. if not prefix or prefix[-1] == os.path.sep: globbed.extend(glob(prefix + ".*")) prefix += "*" @@ -85,7 +93,7 @@ def __call__(self, prefix: str, **kwargs: Any) -> List[str]: for x in sorted(globbed): if os.path.isdir(x): x += "/" - # append stripping the prefix (like bash, not like compgen) + # Append stripping the prefix (like bash, not like compgen). completion.append(x[prefix_dir:]) return completion @@ -95,7 +103,7 @@ def __call__(self, prefix: str, **kwargs: Any) -> List[str]: import argcomplete.completers except ImportError: sys.exit(-1) - filescompleter = FastFilesCompleter() # type: Optional[FastFilesCompleter] + filescompleter: Optional[FastFilesCompleter] = FastFilesCompleter() def try_argcomplete(parser: argparse.ArgumentParser) -> None: argcomplete.autocomplete(parser, always_complete_options=False) diff --git a/src/_pytest/_code/__init__.py b/src/_pytest/_code/__init__.py index 370e41dc9f3..511d0dde661 100644 --- a/src/_pytest/_code/__init__.py +++ b/src/_pytest/_code/__init__.py @@ -1,10 +1,22 @@ -""" python inspection/code generation API """ -from .code import Code # noqa -from .code import ExceptionInfo # noqa -from .code import filter_traceback # noqa -from .code import Frame # noqa -from .code import getrawcode # noqa -from .code import Traceback # noqa -from .source import compile_ as compile # noqa -from .source import getfslineno # noqa -from .source import Source # noqa +"""Python inspection/code generation API.""" +from .code import Code +from .code import ExceptionInfo +from .code import filter_traceback +from .code import Frame +from .code import getfslineno +from .code import Traceback +from .code import TracebackEntry +from .source import getrawcode +from .source import Source + +__all__ = [ + "Code", + "ExceptionInfo", + "filter_traceback", + "Frame", + "getfslineno", + "getrawcode", + "Traceback", + "TracebackEntry", + "Source", +] diff --git a/src/_pytest/_code/code.py b/src/_pytest/_code/code.py index e7a01530ab4..423069330a5 100644 --- a/src/_pytest/_code/code.py +++ b/src/_pytest/_code/code.py @@ -5,6 +5,7 @@ from inspect import CO_VARARGS from inspect import CO_VARKEYWORDS from io import StringIO +from pathlib import Path from traceback import format_exception_only from types import CodeType from types import FrameType @@ -15,11 +16,15 @@ from typing import Generic from typing import Iterable from typing import List +from typing import Mapping from typing import Optional +from typing import overload from typing import Pattern from typing import Sequence from typing import Set from typing import Tuple +from typing import Type +from typing import TYPE_CHECKING from typing import TypeVar from typing import Union from weakref import ref @@ -29,34 +34,34 @@ import py import _pytest +from _pytest._code.source import findsource +from _pytest._code.source import getrawcode +from _pytest._code.source import getstatementrange_ast +from _pytest._code.source import Source from _pytest._io import TerminalWriter from _pytest._io.saferepr import safeformat from _pytest._io.saferepr import saferepr -from _pytest.compat import overload -from _pytest.compat import TYPE_CHECKING +from _pytest.compat import final +from _pytest.compat import get_real_func if TYPE_CHECKING: - from typing import Type from typing_extensions import Literal - from weakref import ReferenceType # noqa: F401 + from weakref import ReferenceType - from _pytest._code import Source - - _TracebackStyle = Literal["long", "short", "line", "no", "native"] + _TracebackStyle = Literal["long", "short", "line", "no", "native", "value", "auto"] class Code: - """ wrapper around Python code objects """ - - def __init__(self, rawcode) -> None: - if not hasattr(rawcode, "co_filename"): - rawcode = getrawcode(rawcode) - if not isinstance(rawcode, CodeType): - raise TypeError("not a code object: {!r}".format(rawcode)) - self.filename = rawcode.co_filename - self.firstlineno = rawcode.co_firstlineno - 1 - self.name = rawcode.co_name - self.raw = rawcode + """Wrapper around Python code objects.""" + + __slots__ = ("raw",) + + def __init__(self, obj: CodeType) -> None: + self.raw = obj + + @classmethod + def from_function(cls, obj: object) -> "Code": + return cls(getrawcode(obj)) def __eq__(self, other): return self.raw == other.raw @@ -64,14 +69,18 @@ def __eq__(self, other): # Ignore type because of https://github.com/python/mypy/issues/4266. __hash__ = None # type: ignore - def __ne__(self, other): - return not self == other + @property + def firstlineno(self) -> int: + return self.raw.co_firstlineno - 1 + + @property + def name(self) -> str: + return self.raw.co_name @property def path(self) -> Union[py.path.local, str]: - """ return a path object pointing to source code (or a str in case - of OSError / non-existing file). - """ + """Return a path object pointing to source code, or an ``str`` in + case of ``OSError`` / non-existing file.""" if not self.raw.co_filename: return "" try: @@ -87,28 +96,22 @@ def path(self) -> Union[py.path.local, str]: @property def fullsource(self) -> Optional["Source"]: - """ return a _pytest._code.Source object for the full source file of the code - """ - from _pytest._code import source - - full, _ = source.findsource(self.raw) + """Return a _pytest._code.Source object for the full source file of the code.""" + full, _ = findsource(self.raw) return full def source(self) -> "Source": - """ return a _pytest._code.Source object for the code object's source only - """ + """Return a _pytest._code.Source object for the code object's source only.""" # return source only for that part of code - import _pytest._code - - return _pytest._code.Source(self.raw) + return Source(self.raw) def getargs(self, var: bool = False) -> Tuple[str, ...]: - """ return a tuple with the argument names for the code object + """Return a tuple with the argument names for the code object. - if 'var' is set True also return the names of the variable and - keyword arguments when present + If 'var' is set True also return the names of the variable and + keyword arguments when present. """ - # handfull shortcut for getting args + # Handy shortcut for getting args. raw = self.raw argcount = raw.co_argcount if var: @@ -121,55 +124,54 @@ class Frame: """Wrapper around a Python frame holding f_locals and f_globals in which expressions can be evaluated.""" + __slots__ = ("raw",) + def __init__(self, frame: FrameType) -> None: - self.lineno = frame.f_lineno - 1 - self.f_globals = frame.f_globals - self.f_locals = frame.f_locals self.raw = frame - self.code = Code(frame.f_code) @property - def statement(self) -> "Source": - """ statement this frame is at """ - import _pytest._code + def lineno(self) -> int: + return self.raw.f_lineno - 1 + + @property + def f_globals(self) -> Dict[str, Any]: + return self.raw.f_globals + + @property + def f_locals(self) -> Dict[str, Any]: + return self.raw.f_locals + + @property + def code(self) -> Code: + return Code(self.raw.f_code) + @property + def statement(self) -> "Source": + """Statement this frame is at.""" if self.code.fullsource is None: - return _pytest._code.Source("") + return Source("") return self.code.fullsource.getstatement(self.lineno) def eval(self, code, **vars): - """ evaluate 'code' in the frame + """Evaluate 'code' in the frame. - 'vars' are optional additional local variables + 'vars' are optional additional local variables. - returns the result of the evaluation + Returns the result of the evaluation. """ f_locals = self.f_locals.copy() f_locals.update(vars) return eval(code, self.f_globals, f_locals) - def exec_(self, code, **vars) -> None: - """ exec 'code' in the frame - - 'vars' are optional; additional local variables - """ - f_locals = self.f_locals.copy() - f_locals.update(vars) - exec(code, self.f_globals, f_locals) - def repr(self, object: object) -> str: - """ return a 'safe' (non-recursive, one-line) string repr for 'object' - """ + """Return a 'safe' (non-recursive, one-line) string repr for 'object'.""" return saferepr(object) - def is_true(self, object): - return object - def getargs(self, var: bool = False): - """ return a list of tuples (name, value) for all arguments + """Return a list of tuples (name, value) for all arguments. - if 'var' is set True also include the variable and keyword - arguments when present + If 'var' is set True, also include the variable and keyword arguments + when present. """ retval = [] for arg in self.code.getargs(var): @@ -181,15 +183,22 @@ def getargs(self, var: bool = False): class TracebackEntry: - """ a single entry in a traceback """ + """A single entry in a Traceback.""" - _repr_style = None # type: Optional[Literal["short", "long"]] - exprinfo = None + __slots__ = ("_rawentry", "_excinfo", "_repr_style") - def __init__(self, rawentry: TracebackType, excinfo=None) -> None: - self._excinfo = excinfo + def __init__( + self, + rawentry: TracebackType, + excinfo: Optional["ReferenceType[ExceptionInfo[BaseException]]"] = None, + ) -> None: self._rawentry = rawentry - self.lineno = rawentry.tb_lineno - 1 + self._excinfo = excinfo + self._repr_style: Optional['Literal["short", "long"]'] = None + + @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") @@ -208,30 +217,28 @@ def __repr__(self) -> str: @property def statement(self) -> "Source": - """ _pytest._code.Source object for the current statement """ + """_pytest._code.Source object for the current statement.""" source = self.frame.code.fullsource assert source is not None return source.getstatement(self.lineno) @property - def path(self): - """ path to the source code """ + def path(self) -> Union[py.path.local, str]: + """Path to the source code.""" return self.frame.code.path @property def locals(self) -> Dict[str, Any]: - """ locals of underlying frame """ + """Locals of underlying frame.""" return self.frame.f_locals def getfirstlinesource(self) -> int: return self.frame.code.firstlineno def getsource(self, astcache=None) -> Optional["Source"]: - """ return failing source code. """ + """Return failing source code.""" # we use the passed in astcache to not reparse asttrees # within exception info printing - from _pytest._code.source import getstatementrange_ast - source = self.frame.code.fullsource if source is None: return None @@ -254,59 +261,71 @@ def getsource(self, astcache=None) -> Optional["Source"]: source = property(getsource) - def ishidden(self): - """ return True if the current frame has a var __tracebackhide__ - resolving to True. + def ishidden(self) -> bool: + """Return True if the current frame has a var __tracebackhide__ + resolving to True. - If __tracebackhide__ is a callable, it gets called with the - ExceptionInfo instance and can decide whether to hide the traceback. + If __tracebackhide__ is a callable, it gets called with the + ExceptionInfo instance and can decide whether to hide the traceback. - mostly for internal use + Mostly for internal use. """ - f = self.frame - tbh = f.f_locals.get( - "__tracebackhide__", f.f_globals.get("__tracebackhide__", False) + tbh: Union[bool, Callable[[Optional[ExceptionInfo[BaseException]]], bool]] = ( + False ) + for maybe_ns_dct in (self.frame.f_locals, self.frame.f_globals): + # in normal cases, f_locals and f_globals are dictionaries + # however via `exec(...)` / `eval(...)` they can be other types + # (even incorrect types!). + # as such, we suppress all exceptions while accessing __tracebackhide__ + try: + tbh = maybe_ns_dct["__tracebackhide__"] + except Exception: + pass + else: + break if tbh and callable(tbh): return tbh(None if self._excinfo is None else self._excinfo()) return tbh def __str__(self) -> str: - try: - fn = str(self.path) - except py.error.Error: - fn = "???" name = self.frame.code.name try: line = str(self.statement).lstrip() except KeyboardInterrupt: raise - except: # noqa + except BaseException: line = "???" - return " File %r:%d in %s\n %s\n" % (fn, self.lineno + 1, name, line) + # This output does not quite match Python's repr for traceback entries, + # but changing it to do so would break certain plugins. See + # https://github.com/pytest-dev/pytest/pull/7535/ for details. + return " File %r:%d in %s\n %s\n" % ( + str(self.path), + self.lineno + 1, + name, + line, + ) @property def name(self) -> str: - """ co_name of underlying code """ + """co_name of underlying code.""" return self.frame.code.raw.co_name class Traceback(List[TracebackEntry]): - """ Traceback objects encapsulate and offer higher level - access to Traceback entries. - """ + """Traceback objects encapsulate and offer higher level access to Traceback entries.""" def __init__( self, tb: Union[TracebackType, Iterable[TracebackEntry]], - excinfo: Optional["ReferenceType[ExceptionInfo]"] = None, + excinfo: Optional["ReferenceType[ExceptionInfo[BaseException]]"] = None, ) -> None: - """ initialize from given python traceback object and ExceptionInfo """ + """Initialize from given python traceback object and ExceptionInfo.""" self._excinfo = excinfo if isinstance(tb, TracebackType): def f(cur: TracebackType) -> Iterable[TracebackEntry]: - cur_ = cur # type: Optional[TracebackType] + cur_: Optional[TracebackType] = cur while cur_ is not None: yield TracebackEntry(cur_, excinfo=excinfo) cur_ = cur_.tb_next @@ -320,16 +339,16 @@ def cut( path=None, lineno: Optional[int] = None, firstlineno: Optional[int] = None, - excludepath=None, + excludepath: Optional[py.path.local] = None, ) -> "Traceback": - """ return a Traceback instance wrapping part of this Traceback + """Return a Traceback instance wrapping part of this Traceback. - by providing any combination of path, lineno and firstlineno, the - first frame to start the to-be-returned traceback is determined + By providing any combination of path, lineno and firstlineno, the + first frame to start the to-be-returned traceback is determined. - this allows cutting the first part of a Traceback instance e.g. - for formatting reasons (removing some uninteresting bits that deal - with handling of the exception/traceback) + This allows cutting the first part of a Traceback instance e.g. + for formatting reasons (removing some uninteresting bits that deal + with handling of the exception/traceback). """ for x in self: code = x.frame.code @@ -349,15 +368,13 @@ def cut( @overload def __getitem__(self, key: int) -> TracebackEntry: - raise NotImplementedError() + ... - @overload # noqa: F811 - def __getitem__(self, key: slice) -> "Traceback": # noqa: F811 - raise NotImplementedError() + @overload + def __getitem__(self, key: slice) -> "Traceback": + ... - def __getitem__( # noqa: F811 - self, key: Union[int, slice] - ) -> Union[TracebackEntry, "Traceback"]: + def __getitem__(self, key: Union[int, slice]) -> Union[TracebackEntry, "Traceback"]: if isinstance(key, slice): return self.__class__(super().__getitem__(key)) else: @@ -366,21 +383,19 @@ def __getitem__( # noqa: F811 def filter( self, fn: Callable[[TracebackEntry], bool] = lambda x: not x.ishidden() ) -> "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 + 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. - by default this removes all the TracebackEntries which are hidden - (see ishidden() above) + By default this removes all the TracebackEntries which are hidden + (see ishidden() above). """ 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. - """ + """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(): @@ -388,10 +403,9 @@ def getcrashentry(self) -> TracebackEntry: return self[-1] def recursionindex(self) -> Optional[int]: - """ return the index of the frame/TracebackEntry where recursion - originates if appropriate, None if no recursion occurred - """ - cache = {} # type: Dict[Tuple[Any, int, int], List[Dict[str, Any]]] + """Return the index of the frame/TracebackEntry where recursion originates if + appropriate, None if no recursion occurred.""" + cache: Dict[Tuple[Any, int, int], List[Dict[str, Any]]] = {} for i, entry in enumerate(self): # id for the code.raw is needed to work around # the strange metaprogramming in the decorator lib from pypi @@ -404,12 +418,10 @@ def recursionindex(self) -> Optional[int]: f = entry.frame loc = f.f_locals for otherloc in values: - if f.is_true( - f.eval( - co_equal, - __recursioncache_locals_1=loc, - __recursioncache_locals_2=otherloc, - ) + if f.eval( + co_equal, + __recursioncache_locals_1=loc, + __recursioncache_locals_2=otherloc, ): return i values.append(entry.frame.f_locals) @@ -421,37 +433,36 @@ def recursionindex(self) -> Optional[int]: ) -_E = TypeVar("_E", bound=BaseException) +_E = TypeVar("_E", bound=BaseException, covariant=True) +@final @attr.s(repr=False) class ExceptionInfo(Generic[_E]): - """ wraps sys.exc_info() objects and offers - help for navigating the traceback. - """ + """Wraps sys.exc_info() objects and offers help for navigating the traceback.""" _assert_start_repr = "AssertionError('assert " - _excinfo = attr.ib(type=Optional[Tuple["Type[_E]", "_E", TracebackType]]) + _excinfo = attr.ib(type=Optional[Tuple[Type["_E"], "_E", TracebackType]]) _striptext = attr.ib(type=str, default="") _traceback = attr.ib(type=Optional[Traceback], default=None) @classmethod def from_exc_info( cls, - exc_info: Tuple["Type[_E]", "_E", TracebackType], + exc_info: Tuple[Type[_E], _E, TracebackType], exprinfo: Optional[str] = None, ) -> "ExceptionInfo[_E]": - """returns an ExceptionInfo for an existing exc_info tuple. + """Return an ExceptionInfo for an existing exc_info tuple. .. warning:: Experimental API - - :param exprinfo: a text string helping to determine if we should - strip ``AssertionError`` from the output, defaults - to the exception message/``__str__()`` + :param exprinfo: + A text string helping to determine if we should strip + ``AssertionError`` from the output. Defaults to the exception + message/``__str__()``. """ _striptext = "" if exprinfo is None and isinstance(exc_info[1], AssertionError): @@ -467,16 +478,16 @@ def from_exc_info( def from_current( cls, exprinfo: Optional[str] = None ) -> "ExceptionInfo[BaseException]": - """returns an ExceptionInfo matching the current traceback + """Return an ExceptionInfo matching the current traceback. .. warning:: Experimental API - - :param exprinfo: a text string helping to determine if we should - strip ``AssertionError`` from the output, defaults - to the exception message/``__str__()`` + :param exprinfo: + A text string helping to determine if we should strip + ``AssertionError`` from the output. Defaults to the exception + message/``__str__()``. """ tup = sys.exc_info() assert tup[0] is not None, "no current exception" @@ -487,18 +498,17 @@ def from_current( @classmethod def for_later(cls) -> "ExceptionInfo[_E]": - """return an unfilled ExceptionInfo - """ + """Return an unfilled ExceptionInfo.""" return cls(None) - def fill_unfilled(self, exc_info: Tuple["Type[_E]", _E, TracebackType]) -> None: - """fill an unfilled ExceptionInfo created with for_later()""" + def fill_unfilled(self, exc_info: Tuple[Type[_E], _E, TracebackType]) -> None: + """Fill an unfilled ExceptionInfo created with ``for_later()``.""" assert self._excinfo is None, "ExceptionInfo was already filled" self._excinfo = exc_info @property - def type(self) -> "Type[_E]": - """the exception class""" + def type(self) -> Type[_E]: + """The exception class.""" assert ( self._excinfo is not None ), ".type can only be used after the context manager exits" @@ -506,7 +516,7 @@ def type(self) -> "Type[_E]": @property def value(self) -> _E: - """the exception value""" + """The exception value.""" assert ( self._excinfo is not None ), ".value can only be used after the context manager exits" @@ -514,7 +524,7 @@ def value(self) -> _E: @property def tb(self) -> TracebackType: - """the exception raw traceback""" + """The exception raw traceback.""" assert ( self._excinfo is not None ), ".tb can only be used after the context manager exits" @@ -522,7 +532,7 @@ def tb(self) -> TracebackType: @property def typename(self) -> str: - """the type name of the exception""" + """The type name of the exception.""" assert ( self._excinfo is not None ), ".typename can only be used after the context manager exits" @@ -530,7 +540,7 @@ def typename(self) -> str: @property def traceback(self) -> Traceback: - """the traceback""" + """The traceback.""" if self._traceback is None: self._traceback = Traceback(self.tb, excinfo=ref(self)) return self._traceback @@ -547,12 +557,12 @@ def __repr__(self) -> str: ) def exconly(self, tryshort: bool = False) -> str: - """ return the exception as a string + """Return the exception as a string. - when 'tryshort' resolves to True, and the exception is a - _pytest._code._AssertionError, only the actual exception part of - the exception representation is returned (so 'AssertionError: ' is - removed from the beginning) + When 'tryshort' resolves to True, and the exception is a + _pytest._code._AssertionError, only the actual exception part of + the exception representation is returned (so 'AssertionError: ' is + removed from the beginning). """ lines = format_exception_only(self.type, self.value) text = "".join(lines) @@ -563,9 +573,12 @@ def exconly(self, tryshort: bool = False) -> str: return text def errisinstance( - self, exc: Union["Type[BaseException]", Tuple["Type[BaseException]", ...]] + self, exc: Union[Type[BaseException], Tuple[Type[BaseException], ...]] ) -> bool: - """ return True if the exception is an instance of exc """ + """Return True if the exception is an instance of exc. + + Consider using ``isinstance(excinfo.value, exc)`` instead. + """ return isinstance(self.value, exc) def _getreprcrash(self) -> "ReprFileLocation": @@ -584,14 +597,14 @@ def getrepr( truncate_locals: bool = True, chain: bool = True, ) -> Union["ReprExceptionInfo", "ExceptionChainRepr"]: - """ - Return str()able representation of this exception info. + """Return str()able representation of this exception info. :param bool showlocals: Show locals per traceback entry. Ignored if ``style=="native"``. - :param str style: long|short|no|native traceback style + :param str style: + long|short|no|native|value traceback style. :param bool abspath: If paths should be changed to absolute or left unchanged. @@ -606,7 +619,8 @@ def getrepr( :param bool truncate_locals: With ``showlocals==True``, make sure locals can be safely represented as strings. - :param bool chain: if chained exceptions in Python 3 should be shown. + :param bool chain: + If chained exceptions in Python 3 should be shown. .. versionchanged:: 3.9 @@ -633,24 +647,24 @@ def getrepr( ) return fmt.repr_excinfo(self) - def match(self, regexp: "Union[str, Pattern]") -> "Literal[True]": - """ - Check whether the regular expression `regexp` matches the string + def match(self, regexp: Union[str, Pattern[str]]) -> "Literal[True]": + """Check whether the regular expression `regexp` matches the string representation of the exception using :func:`python:re.search`. - If it matches `True` is returned. - If it doesn't match an `AssertionError` is raised. + + If it matches `True` is returned, otherwise an `AssertionError` is raised. """ __tracebackhide__ = True - assert re.search( - regexp, str(self.value) - ), "Pattern {!r} does not match {!r}".format(regexp, str(self.value)) + msg = "Regex pattern {!r} does not match {!r}." + if regexp == str(self.value): + msg += " Did you mean to `re.escape()` the regex?" + assert re.search(regexp, str(self.value)), msg.format(regexp, str(self.value)) # Return True to allow for "assert excinfo.match()". return True @attr.s class FormattedExcinfo: - """ presenting information about failing Functions and Generators. """ + """Presenting information about failing Functions and Generators.""" # for traceback entries flow_marker = ">" @@ -666,17 +680,17 @@ class FormattedExcinfo: astcache = attr.ib(default=attr.Factory(dict), init=False, repr=False) def _getindent(self, source: "Source") -> int: - # figure out indent for given source + # Figure out indent for the given source. try: s = str(source.getstatement(len(source) - 1)) except KeyboardInterrupt: raise - except: # noqa + except BaseException: try: s = str(source[-1]) except KeyboardInterrupt: raise - except: # noqa + except BaseException: return 0 return 4 + (len(s) - len(s.lstrip())) @@ -696,17 +710,15 @@ def repr_args(self, entry: TracebackEntry) -> Optional["ReprFuncArgs"]: def get_source( self, - source: "Source", + source: Optional["Source"], line_index: int = -1, - excinfo: Optional[ExceptionInfo] = None, + excinfo: Optional[ExceptionInfo[BaseException]] = None, short: bool = False, ) -> List[str]: - """ return formatted and marked up source lines. """ - import _pytest._code - + """Return formatted and marked up source lines.""" lines = [] if source is None or line_index >= len(source.lines): - source = _pytest._code.Source("???") + source = Source("???") line_index = 0 if line_index < 0: line_index += len(source) @@ -725,11 +737,14 @@ def get_source( return lines def get_exconly( - self, excinfo: ExceptionInfo, indent: int = 4, markall: bool = False + self, + excinfo: ExceptionInfo[BaseException], + indent: int = 4, + markall: bool = False, ) -> List[str]: lines = [] indentstr = " " * indent - # get the real exception information out + # Get the real exception information out. exlines = excinfo.exconly(tryshort=True).split("\n") failindent = self.fail_marker + indentstr[1:] for line in exlines: @@ -738,7 +753,7 @@ def get_exconly( failindent = indentstr return lines - def repr_locals(self, locals: Dict[str, object]) -> Optional["ReprLocals"]: + def repr_locals(self, locals: Mapping[str, object]) -> Optional["ReprLocals"]: if self.showlocals: lines = [] keys = [loc for loc in locals if loc[0] != "@"] @@ -755,9 +770,8 @@ def repr_locals(self, locals: Dict[str, object]) -> Optional["ReprLocals"]: str_repr = saferepr(value) else: str_repr = safeformat(value) - # if len(str_repr) < 70 or not isinstance(value, - # (list, tuple, dict)): - lines.append("{:<10} = {}".format(name, str_repr)) + # if len(str_repr) < 70 or not isinstance(value, (list, tuple, dict)): + lines.append(f"{name:<10} = {str_repr}") # else: # self._line("%-10s =\\" % (name,)) # # XXX @@ -766,20 +780,19 @@ def repr_locals(self, locals: Dict[str, object]) -> Optional["ReprLocals"]: return None def repr_traceback_entry( - self, entry: TracebackEntry, excinfo: Optional[ExceptionInfo] = None + self, + entry: TracebackEntry, + excinfo: Optional[ExceptionInfo[BaseException]] = None, ) -> "ReprEntry": - import _pytest._code - - source = self._getentrysource(entry) - if source is None: - source = _pytest._code.Source("???") - line_index = 0 - else: - line_index = entry.lineno - entry.getfirstlinesource() - - lines = [] # type: List[str] + lines: List[str] = [] style = entry._repr_style if entry._repr_style is not None else self.style if style in ("short", "long"): + source = self._getentrysource(entry) + if source is None: + source = Source("???") + line_index = 0 + else: + line_index = entry.lineno - entry.getfirstlinesource() short = style == "short" reprargs = self.repr_args(entry) if not short else None s = self.get_source(source, line_index, excinfo, short=short) @@ -792,9 +805,14 @@ def repr_traceback_entry( reprfileloc = ReprFileLocation(path, entry.lineno + 1, message) localsrepr = self.repr_locals(entry.locals) return ReprEntry(lines, reprargs, localsrepr, reprfileloc, style) - if excinfo: - lines.extend(self.get_exconly(excinfo, indent=4)) - return ReprEntry(lines, None, None, None, style) + elif style == "value": + if excinfo: + lines.extend(str(excinfo.value).split("\n")) + return ReprEntry(lines, None, None, None, style) + else: + if excinfo: + lines.extend(self.get_exconly(excinfo, indent=4)) + return ReprEntry(lines, None, None, None, style) def _makepath(self, path): if not self.abspath: @@ -806,18 +824,23 @@ def _makepath(self, path): path = np return path - def repr_traceback(self, excinfo: ExceptionInfo) -> "ReprTraceback": + def repr_traceback(self, excinfo: ExceptionInfo[BaseException]) -> "ReprTraceback": traceback = excinfo.traceback if self.tbfilter: traceback = traceback.filter() - if excinfo.errisinstance(RecursionError): + if isinstance(excinfo.value, RecursionError): traceback, extraline = self._truncate_recursive_traceback(traceback) else: extraline = None last = traceback[-1] entries = [] + if self.style == "value": + reprentry = self.repr_traceback_entry(last, excinfo) + entries.append(reprentry) + 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) @@ -827,22 +850,23 @@ def repr_traceback(self, excinfo: ExceptionInfo) -> "ReprTraceback": def _truncate_recursive_traceback( self, traceback: Traceback ) -> Tuple[Traceback, Optional[str]]: - """ - Truncate the given recursive traceback trying to find the starting point - of the recursion. + """Truncate the given recursive traceback trying to find the starting + point of the recursion. - The detection is done by going through each traceback entry and finding the - point in which the locals of the frame are equal to the locals of a previous frame (see ``recursionindex()``. + The detection is done by going through each traceback entry and + finding the point in which the locals of the frame are equal to the + locals of a previous frame (see ``recursionindex()``). - Handle the situation where the recursion process might raise an exception (for example - comparing numpy arrays using equality raises a TypeError), in which case we do our best to - warn the user of the error and show a limited traceback. + Handle the situation where the recursion process might raise an + exception (for example comparing numpy arrays using equality raises a + TypeError), in which case we do our best to warn the user of the + error and show a limited traceback. """ try: recursionindex = traceback.recursionindex() except Exception as e: max_frames = 10 - extraline = ( + extraline: Optional[str] = ( "!!! Recursion error detected, but an error occurred locating the origin of recursion.\n" " The following exception happened when comparing locals in the stack frame:\n" " {exc_type}: {exc_msg}\n" @@ -852,7 +876,7 @@ def _truncate_recursive_traceback( exc_msg=str(e), max_frames=max_frames, total=len(traceback), - ) # type: Optional[str] + ) # Type ignored because adding two instaces of a List subtype # currently incorrectly has type List instead of the subtype. traceback = traceback[:max_frames] + traceback[-max_frames:] # type: ignore @@ -865,22 +889,26 @@ def _truncate_recursive_traceback( return traceback, extraline - def repr_excinfo(self, excinfo: ExceptionInfo) -> "ExceptionChainRepr": - repr_chain = ( - [] - ) # type: List[Tuple[ReprTraceback, Optional[ReprFileLocation], Optional[str]]] - e = excinfo.value - excinfo_ = excinfo # type: Optional[ExceptionInfo] + def repr_excinfo( + self, excinfo: ExceptionInfo[BaseException] + ) -> "ExceptionChainRepr": + repr_chain: List[ + Tuple[ReprTraceback, Optional[ReprFileLocation], Optional[str]] + ] = [] + e: Optional[BaseException] = excinfo.value + excinfo_: Optional[ExceptionInfo[BaseException]] = excinfo descr = None - seen = set() # type: Set[int] + seen: Set[int] = set() while e is not None and id(e) not in seen: seen.add(id(e)) if excinfo_: reprtraceback = self.repr_traceback(excinfo_) - reprcrash = excinfo_._getreprcrash() # type: Optional[ReprFileLocation] + reprcrash: Optional[ReprFileLocation] = ( + excinfo_._getreprcrash() if self.style != "value" else None + ) else: - # fallback to native repr if the exception doesn't have a traceback: - # ExceptionInfo objects require a full traceback to work + # Fallback to native repr if the exception doesn't have a traceback: + # ExceptionInfo objects require a full traceback to work. reprtraceback = ReprTracebackNative( traceback.format_exception(type(e), e, None) ) @@ -911,7 +939,7 @@ def repr_excinfo(self, excinfo: ExceptionInfo) -> "ExceptionChainRepr": return ExceptionChainRepr(repr_chain) -@attr.s +@attr.s(eq=False) class TerminalRepr: def __str__(self) -> str: # FYI this is called from pytest-xdist's serialization of exception @@ -928,10 +956,15 @@ def toterminal(self, tw: TerminalWriter) -> None: raise NotImplementedError() -@attr.s +# This class is abstract -- only subclasses are instantiated. +@attr.s(eq=False) class ExceptionRepr(TerminalRepr): - def __attrs_post_init__(self): - self.sections = [] # type: List[Tuple[str, str, str]] + # Provided by subclasses. + reprcrash: Optional["ReprFileLocation"] + reprtraceback: "ReprTraceback" + + def __attrs_post_init__(self) -> None: + self.sections: List[Tuple[str, str, str]] = [] def addsection(self, name: str, content: str, sep: str = "-") -> None: self.sections.append((name, content, sep)) @@ -942,7 +975,7 @@ def toterminal(self, tw: TerminalWriter) -> None: tw.line(content) -@attr.s +@attr.s(eq=False) class ExceptionChainRepr(ExceptionRepr): chain = attr.ib( type=Sequence[ @@ -950,10 +983,10 @@ class ExceptionChainRepr(ExceptionRepr): ] ) - def __attrs_post_init__(self): + def __attrs_post_init__(self) -> None: super().__attrs_post_init__() # reprcrash and reprtraceback of the outermost (the newest) exception - # in the chain + # in the chain. self.reprtraceback = self.chain[-1][0] self.reprcrash = self.chain[-1][1] @@ -966,7 +999,7 @@ def toterminal(self, tw: TerminalWriter) -> None: super().toterminal(tw) -@attr.s +@attr.s(eq=False) class ReprExceptionInfo(ExceptionRepr): reprtraceback = attr.ib(type="ReprTraceback") reprcrash = attr.ib(type="ReprFileLocation") @@ -976,7 +1009,7 @@ def toterminal(self, tw: TerminalWriter) -> None: super().toterminal(tw) -@attr.s +@attr.s(eq=False) class ReprTraceback(TerminalRepr): reprentries = attr.ib(type=Sequence[Union["ReprEntry", "ReprEntryNative"]]) extraline = attr.ib(type=Optional[str]) @@ -985,7 +1018,7 @@ class ReprTraceback(TerminalRepr): entrysep = "_ " def toterminal(self, tw: TerminalWriter) -> None: - # the entries might have different styles + # The entries might have different styles. for i, entry in enumerate(self.reprentries): if entry.style == "long": tw.line("") @@ -1010,16 +1043,16 @@ def __init__(self, tblines: Sequence[str]) -> None: self.extraline = None -@attr.s +@attr.s(eq=False) class ReprEntryNative(TerminalRepr): lines = attr.ib(type=Sequence[str]) - style = "native" # type: _TracebackStyle + style: "_TracebackStyle" = "native" def toterminal(self, tw: TerminalWriter) -> None: tw.write("".join(self.lines)) -@attr.s +@attr.s(eq=False) class ReprEntry(TerminalRepr): lines = attr.ib(type=Sequence[str]) reprfuncargs = attr.ib(type=Optional["ReprFuncArgs"]) @@ -1028,7 +1061,7 @@ class ReprEntry(TerminalRepr): style = attr.ib(type="_TracebackStyle") def _write_entry_lines(self, tw: TerminalWriter) -> None: - """Writes the source code portions of a list of traceback entries with syntax highlighting. + """Write the source code portions of a list of traceback entries with syntax highlighting. Usually entries are lines like these: @@ -1041,28 +1074,34 @@ def _write_entry_lines(self, tw: TerminalWriter) -> None: character, as doing so might break line continuations. """ - indent_size = 4 - - def is_fail(line): - return line.startswith("{} ".format(FormattedExcinfo.fail_marker)) - if not self.lines: return # separate indents and source lines that are not failures: we want to # highlight the code but not the indentation, which may contain markers # such as "> assert 0" - indents = [] - source_lines = [] - for line in self.lines: - if not is_fail(line): - indents.append(line[:indent_size]) - source_lines.append(line[indent_size:]) + fail_marker = f"{FormattedExcinfo.fail_marker} " + indent_size = len(fail_marker) + indents: List[str] = [] + source_lines: List[str] = [] + failure_lines: List[str] = [] + for index, line in enumerate(self.lines): + is_failure_line = line.startswith(fail_marker) + if is_failure_line: + # from this point on all lines are considered part of the failure + failure_lines.extend(self.lines[index:]) + break + else: + if self.style == "value": + source_lines.append(line) + else: + indents.append(line[:indent_size]) + source_lines.append(line[indent_size:]) tw._write_source(source_lines, indents) # failure lines are always completely red and bold - for line in (x for x in self.lines if is_fail(x)): + for line in failure_lines: tw.line(line, bold=True, red=True) def toterminal(self, tw: TerminalWriter) -> None: @@ -1093,24 +1132,24 @@ def __str__(self) -> str: ) -@attr.s +@attr.s(eq=False) class ReprFileLocation(TerminalRepr): path = attr.ib(type=str, converter=str) lineno = attr.ib(type=int) message = attr.ib(type=str) def toterminal(self, tw: TerminalWriter) -> None: - # filename and lineno output for each entry, - # using an output format that most editors understand + # Filename and lineno output for each entry, using an output format + # that most editors understand. msg = self.message i = msg.find("\n") if i != -1: msg = msg[:i] tw.write(self.path, bold=True, red=True) - tw.line(":{}: {}".format(self.lineno, msg)) + tw.line(f":{self.lineno}: {msg}") -@attr.s +@attr.s(eq=False) class ReprLocals(TerminalRepr): lines = attr.ib(type=Sequence[str]) @@ -1119,7 +1158,7 @@ def toterminal(self, tw: TerminalWriter, indent="") -> None: tw.line(indent + line) -@attr.s +@attr.s(eq=False) class ReprFuncArgs(TerminalRepr): args = attr.ib(type=Sequence[Tuple[str, object]]) @@ -1127,7 +1166,7 @@ def toterminal(self, tw: TerminalWriter) -> None: if self.args: linesofar = "" for name, value in self.args: - ns = "{} = {}".format(name, value) + ns = f"{name} = {value}" if len(ns) + len(linesofar) + 2 > tw.fullwidth: if linesofar: tw.line(linesofar) @@ -1142,49 +1181,79 @@ def toterminal(self, tw: TerminalWriter) -> None: tw.line("") -def getrawcode(obj, trycall: bool = True): - """ return code object for given function. """ +def getfslineno(obj: object) -> Tuple[Union[str, py.path.local], int]: + """Return source location (path, lineno) for the given object. + + If the source cannot be determined return ("", -1). + + The line number is 0-based. + """ + # xxx let decorators etc specify a sane ordering + # NOTE: this used to be done in _pytest.compat.getfslineno, initially added + # in 6ec13a2b9. It ("place_as") appears to be something very custom. + obj = get_real_func(obj) + if hasattr(obj, "place_as"): + obj = obj.place_as # type: ignore[attr-defined] + try: - return obj.__code__ - except AttributeError: - obj = getattr(obj, "f_code", obj) - obj = getattr(obj, "__code__", obj) - if trycall and not hasattr(obj, "co_firstlineno"): - if hasattr(obj, "__call__") and not inspect.isclass(obj): - x = getrawcode(obj.__call__, trycall=False) - if hasattr(x, "co_firstlineno"): - return x - return obj - - -# relative paths that we use to filter traceback entries from appearing to the user; -# see filter_traceback + code = Code.from_function(obj) + except TypeError: + try: + fn = inspect.getsourcefile(obj) or inspect.getfile(obj) # type: ignore[arg-type] + except TypeError: + return "", -1 + + fspath = fn and py.path.local(fn) or "" + lineno = -1 + if fspath: + try: + _, lineno = findsource(obj) + except OSError: + pass + return fspath, lineno + + return code.path, code.firstlineno + + +# Relative paths that we use to filter traceback entries from appearing to the user; +# see filter_traceback. # note: if we need to add more paths than what we have now we should probably use a list -# for better maintenance +# for better maintenance. -_PLUGGY_DIR = py.path.local(pluggy.__file__.rstrip("oc")) +_PLUGGY_DIR = Path(pluggy.__file__.rstrip("oc")) # pluggy is either a package or a single module depending on the version -if _PLUGGY_DIR.basename == "__init__.py": - _PLUGGY_DIR = _PLUGGY_DIR.dirpath() -_PYTEST_DIR = py.path.local(_pytest.__file__).dirpath() -_PY_DIR = py.path.local(py.__file__).dirpath() +if _PLUGGY_DIR.name == "__init__.py": + _PLUGGY_DIR = _PLUGGY_DIR.parent +_PYTEST_DIR = Path(_pytest.__file__).parent +_PY_DIR = Path(py.__file__).parent def filter_traceback(entry: TracebackEntry) -> bool: - """Return True if a TracebackEntry instance should be removed from tracebacks: + """Return True if a TracebackEntry instance should be included in tracebacks. + + We hide traceback entries of: + * dynamically generated code (no code to show up for it); * internal traceback from pytest or its internal libraries, py and pluggy. """ # entry.path might sometimes return a str object when the entry - # points to dynamically generated code - # see https://bitbucket.org/pytest-dev/py/issues/71 + # points to dynamically generated code. + # See https://bitbucket.org/pytest-dev/py/issues/71. raw_filename = entry.frame.code.raw.co_filename is_generated = "<" in raw_filename and ">" in raw_filename if is_generated: return False + # entry.path might point to a non-existing file, in which case it will - # also return a str object. see #1133 - p = py.path.local(entry.path) - return ( - not p.relto(_PLUGGY_DIR) and not p.relto(_PYTEST_DIR) and not p.relto(_PY_DIR) - ) + # also return a str object. See #1133. + p = Path(entry.path) + + parents = p.parents + if _PLUGGY_DIR in parents: + return False + if _PYTEST_DIR in parents: + return False + if _PY_DIR in parents: + return False + + return True diff --git a/src/_pytest/_code/source.py b/src/_pytest/_code/source.py index 28c11e5d5e3..6f54057c0a9 100644 --- a/src/_pytest/_code/source.py +++ b/src/_pytest/_code/source.py @@ -1,76 +1,59 @@ import ast import inspect -import linecache -import sys import textwrap import tokenize +import types import warnings from bisect import bisect_right -from types import CodeType -from types import FrameType -from typing import Any +from typing import Iterable from typing import Iterator from typing import List from typing import Optional -from typing import Sequence +from typing import overload from typing import Tuple from typing import Union -import py - -from _pytest.compat import get_real_func -from _pytest.compat import overload -from _pytest.compat import TYPE_CHECKING - -if TYPE_CHECKING: - from typing_extensions import Literal - class Source: - """ an immutable object holding a source code fragment, - possibly deindenting it. + """An immutable object holding a source code fragment. + + When using Source(...), the source lines are deindented. """ - _compilecounter = 0 - - def __init__(self, *parts, **kwargs) -> None: - self.lines = lines = [] # type: List[str] - de = kwargs.get("deindent", True) - for part in parts: - if not part: - partlines = [] # type: List[str] - elif isinstance(part, Source): - partlines = part.lines - elif isinstance(part, (tuple, list)): - partlines = [x.rstrip("\n") for x in part] - elif isinstance(part, str): - partlines = part.split("\n") - else: - partlines = getsource(part, deindent=de).lines - if de: - partlines = deindent(partlines) - lines.extend(partlines) - - def __eq__(self, other): - try: - return self.lines == other.lines - except AttributeError: - if isinstance(other, str): - return str(self) == other - return False + def __init__(self, obj: object = None) -> None: + if not obj: + self.lines: List[str] = [] + elif isinstance(obj, Source): + self.lines = obj.lines + elif isinstance(obj, (tuple, list)): + self.lines = deindent(x.rstrip("\n") for x in obj) + elif isinstance(obj, str): + self.lines = deindent(obj.split("\n")) + else: + try: + rawcode = getrawcode(obj) + src = inspect.getsource(rawcode) + except TypeError: + src = inspect.getsource(obj) # type: ignore[arg-type] + self.lines = deindent(src.split("\n")) + + def __eq__(self, other: object) -> bool: + if not isinstance(other, Source): + return NotImplemented + return self.lines == other.lines # Ignore type because of https://github.com/python/mypy/issues/4266. __hash__ = None # type: ignore @overload def __getitem__(self, key: int) -> str: - raise NotImplementedError() + ... - @overload # noqa: F811 - def __getitem__(self, key: slice) -> "Source": # noqa: F811 - raise NotImplementedError() + @overload + def __getitem__(self, key: slice) -> "Source": + ... - def __getitem__(self, key: Union[int, slice]) -> Union[str, "Source"]: # noqa: F811 + def __getitem__(self, key: Union[int, slice]) -> Union[str, "Source"]: if isinstance(key, int): return self.lines[key] else: @@ -87,9 +70,7 @@ def __len__(self) -> int: return len(self.lines) def strip(self) -> "Source": - """ return new source object with trailing - and leading blank lines removed. - """ + """Return new Source object with trailing and leading blank lines removed.""" start, end = 0, len(self) while start < end and not self.lines[start].strip(): start += 1 @@ -99,220 +80,36 @@ def strip(self) -> "Source": source.lines[:] = self.lines[start:end] return source - def putaround( - self, before: str = "", after: str = "", indent: str = " " * 4 - ) -> "Source": - """ return a copy of the source object with - 'before' and 'after' wrapped around it. - """ - beforesource = Source(before) - aftersource = Source(after) - newsource = Source() - lines = [(indent + line) for line in self.lines] - newsource.lines = beforesource.lines + lines + aftersource.lines - return newsource - def indent(self, indent: str = " " * 4) -> "Source": - """ return a copy of the source object with - all lines indented by the given indent-string. - """ + """Return a copy of the source object with all lines indented by the + given indent-string.""" newsource = Source() newsource.lines = [(indent + line) for line in self.lines] return newsource def getstatement(self, lineno: int) -> "Source": - """ return Source statement which contains the - given linenumber (counted from 0). - """ + """Return Source statement which contains the given linenumber + (counted from 0).""" start, end = self.getstatementrange(lineno) return self[start:end] def getstatementrange(self, lineno: int) -> Tuple[int, int]: - """ return (start, end) tuple which spans the minimal - statement region which containing the given lineno. - """ + """Return (start, end) tuple which spans the minimal statement region + which containing the given lineno.""" if not (0 <= lineno < len(self)): raise IndexError("lineno out of range") ast, start, end = getstatementrange_ast(lineno, self) return start, end def deindent(self) -> "Source": - """return a new source object deindented.""" + """Return a new Source object deindented.""" newsource = Source() newsource.lines[:] = deindent(self.lines) return newsource - def isparseable(self, deindent: bool = True) -> bool: - """ return True if source is parseable, heuristically - deindenting it by default. - """ - if deindent: - source = str(self.deindent()) - else: - source = str(self) - try: - ast.parse(source) - except (SyntaxError, ValueError, TypeError): - return False - else: - return True - def __str__(self) -> str: return "\n".join(self.lines) - @overload - def compile( - self, - filename: Optional[str] = ..., - mode: str = ..., - flag: "Literal[0]" = ..., - dont_inherit: int = ..., - _genframe: Optional[FrameType] = ..., - ) -> CodeType: - raise NotImplementedError() - - @overload # noqa: F811 - def compile( # noqa: F811 - self, - filename: Optional[str] = ..., - mode: str = ..., - flag: int = ..., - dont_inherit: int = ..., - _genframe: Optional[FrameType] = ..., - ) -> Union[CodeType, ast.AST]: - raise NotImplementedError() - - def compile( # noqa: F811 - self, - filename: Optional[str] = None, - mode: str = "exec", - flag: int = 0, - dont_inherit: int = 0, - _genframe: Optional[FrameType] = None, - ) -> Union[CodeType, ast.AST]: - """ return compiled code object. if filename is None - invent an artificial filename which displays - the source/line position of the caller frame. - """ - if not filename or py.path.local(filename).check(file=0): - if _genframe is None: - _genframe = sys._getframe(1) # the caller - fn, lineno = _genframe.f_code.co_filename, _genframe.f_lineno - base = "<%d-codegen " % self._compilecounter - self.__class__._compilecounter += 1 - if not filename: - filename = base + "%s:%d>" % (fn, lineno) - else: - filename = base + "%r %s:%d>" % (filename, fn, lineno) - source = "\n".join(self.lines) + "\n" - try: - co = compile(source, filename, mode, flag) - except SyntaxError as ex: - # re-represent syntax errors from parsing python strings - msglines = self.lines[: ex.lineno] - if ex.offset: - msglines.append(" " * ex.offset + "^") - msglines.append("(code was compiled probably from here: %s)" % filename) - newex = SyntaxError("\n".join(msglines)) - newex.offset = ex.offset - newex.lineno = ex.lineno - newex.text = ex.text - raise newex - else: - if flag & ast.PyCF_ONLY_AST: - assert isinstance(co, ast.AST) - return co - assert isinstance(co, CodeType) - lines = [(x + "\n") for x in self.lines] - # Type ignored because linecache.cache is private. - linecache.cache[filename] = (1, None, lines, filename) # type: ignore - return co - - -# -# public API shortcut functions -# - - -@overload -def compile_( - source: Union[str, bytes, ast.mod, ast.AST], - filename: Optional[str] = ..., - mode: str = ..., - flags: "Literal[0]" = ..., - dont_inherit: int = ..., -) -> CodeType: - raise NotImplementedError() - - -@overload # noqa: F811 -def compile_( # noqa: F811 - source: Union[str, bytes, ast.mod, ast.AST], - filename: Optional[str] = ..., - mode: str = ..., - flags: int = ..., - dont_inherit: int = ..., -) -> Union[CodeType, ast.AST]: - raise NotImplementedError() - - -def compile_( # noqa: F811 - source: Union[str, bytes, ast.mod, ast.AST], - filename: Optional[str] = None, - mode: str = "exec", - flags: int = 0, - dont_inherit: int = 0, -) -> Union[CodeType, ast.AST]: - """ compile the given source to a raw code object, - and maintain an internal cache which allows later - retrieval of the source code for the code object - and any recursively created code objects. - """ - if isinstance(source, ast.AST): - # XXX should Source support having AST? - assert filename is not None - co = compile(source, filename, mode, flags, dont_inherit) - assert isinstance(co, (CodeType, ast.AST)) - return co - _genframe = sys._getframe(1) # the caller - s = Source(source) - return s.compile(filename, mode, flags, _genframe=_genframe) - - -def getfslineno(obj: Any) -> Tuple[Union[str, py.path.local], int]: - """ Return source location (path, lineno) for the given object. - If the source cannot be determined return ("", -1). - - The line number is 0-based. - """ - from .code import Code - - # xxx let decorators etc specify a sane ordering - # NOTE: this used to be done in _pytest.compat.getfslineno, initially added - # in 6ec13a2b9. It ("place_as") appears to be something very custom. - obj = get_real_func(obj) - if hasattr(obj, "place_as"): - obj = obj.place_as - - try: - code = Code(obj) - except TypeError: - try: - fn = inspect.getsourcefile(obj) or inspect.getfile(obj) - except TypeError: - return "", -1 - - fspath = fn and py.path.local(fn) or "" - lineno = -1 - if fspath: - try: - _, lineno = findsource(obj) - except IOError: - pass - return fspath, lineno - else: - return code.path, code.firstlineno - # # helper functions @@ -329,35 +126,34 @@ def findsource(obj) -> Tuple[Optional[Source], int]: return source, lineno -def getsource(obj, **kwargs) -> Source: - from .code import getrawcode - - obj = getrawcode(obj) +def getrawcode(obj: object, trycall: bool = True) -> types.CodeType: + """Return code object for given function.""" try: - strsrc = inspect.getsource(obj) - except IndentationError: - strsrc = '"Buggy python version consider upgrading, cannot get source"' - assert isinstance(strsrc, str) - return Source(strsrc, **kwargs) + return obj.__code__ # type: ignore[attr-defined,no-any-return] + except AttributeError: + pass + if trycall: + call = getattr(obj, "__call__", None) + if call and not isinstance(obj, type): + return getrawcode(call, trycall=False) + raise TypeError(f"could not get code object for {obj!r}") -def deindent(lines: Sequence[str]) -> List[str]: +def deindent(lines: Iterable[str]) -> List[str]: return textwrap.dedent("\n".join(lines)).splitlines() def get_statement_startend2(lineno: int, node: ast.AST) -> Tuple[int, Optional[int]]: - import ast - - # flatten all statements and except handlers into one lineno-list - # AST's line numbers start indexing at 1 - values = [] # type: List[int] + # Flatten all statements and except handlers into one lineno-list. + # AST's line numbers start indexing at 1. + values: List[int] = [] for x in ast.walk(node): if isinstance(x, (ast.stmt, ast.ExceptHandler)): values.append(x.lineno - 1) for name in ("finalbody", "orelse"): - val = getattr(x, name, None) # type: Optional[List[ast.stmt]] + val: Optional[List[ast.stmt]] = getattr(x, name, None) if val: - # treat the finally/orelse part as its own statement + # Treat the finally/orelse part as its own statement. values.append(val[0].lineno - 1 - 1) values.sort() insert_index = bisect_right(values, lineno) @@ -378,13 +174,13 @@ def getstatementrange_ast( if astnode is None: content = str(source) # See #4260: - # don't produce duplicate warnings when compiling source to find ast + # Don't produce duplicate warnings when compiling source to find AST. with warnings.catch_warnings(): warnings.simplefilter("ignore") astnode = ast.parse(content, "source", "exec") start, end = get_statement_startend2(lineno, astnode) - # we need to correct the end: + # We need to correct the end: # - ast-parsing strips comments # - there might be empty lines # - we might have lesser indented code blocks at the end @@ -392,10 +188,10 @@ def getstatementrange_ast( end = len(source.lines) if end > start + 1: - # make sure we don't span differently indented code blocks - # by using the BlockFinder helper used which inspect.getsource() uses itself + # Make sure we don't span differently indented code blocks + # by using the BlockFinder helper used which inspect.getsource() uses itself. block_finder = inspect.BlockFinder() - # if we start with an indented line, put blockfinder to "started" mode + # If we start with an indented line, put blockfinder to "started" mode. block_finder.started = source.lines[start][0].isspace() it = ((x + "\n") for x in source.lines[start:end]) try: @@ -406,7 +202,7 @@ def getstatementrange_ast( except Exception: pass - # the end might still point to a comment or empty line, correct it + # The end might still point to a comment or empty line, correct it. while end: line = source.lines[end - 1].lstrip() if line.startswith("#") or not line: diff --git a/src/_pytest/_io/__init__.py b/src/_pytest/_io/__init__.py index f56579806cc..db001e918cb 100644 --- a/src/_pytest/_io/__init__.py +++ b/src/_pytest/_io/__init__.py @@ -1,39 +1,8 @@ -from typing import List -from typing import Sequence +from .terminalwriter import get_terminal_width +from .terminalwriter import TerminalWriter -from py.io import TerminalWriter as BaseTerminalWriter # noqa: F401 - -class TerminalWriter(BaseTerminalWriter): - def _write_source(self, lines: List[str], indents: Sequence[str] = ()) -> None: - """Write lines of source code possibly highlighted. - - Keeping this private for now because the API is clunky. We should discuss how - to evolve the terminal writer so we can have more precise color support, for example - being able to write part of a line in one color and the rest in another, and so on. - """ - if indents and len(indents) != len(lines): - raise ValueError( - "indents size ({}) should have same size as lines ({})".format( - len(indents), len(lines) - ) - ) - if not indents: - indents = [""] * len(lines) - source = "\n".join(lines) - new_lines = self._highlight(source).splitlines() - for indent, new_line in zip(indents, new_lines): - self.line(indent + new_line) - - def _highlight(self, source): - """Highlight the given source code according to the "code_highlight" option""" - if not self.hasmarkup: - return source - try: - from pygments.formatters.terminal import TerminalFormatter - from pygments.lexers.python import PythonLexer - from pygments import highlight - except ImportError: - return source - else: - return highlight(source, PythonLexer(), TerminalFormatter(bg="dark")) +__all__ = [ + "TerminalWriter", + "get_terminal_width", +] diff --git a/src/_pytest/_io/saferepr.py b/src/_pytest/_io/saferepr.py index 23af4d0bb70..5eb1e088905 100644 --- a/src/_pytest/_io/saferepr.py +++ b/src/_pytest/_io/saferepr.py @@ -1,9 +1,12 @@ import pprint import reprlib from typing import Any +from typing import Dict +from typing import IO +from typing import Optional -def _try_repr_or_str(obj): +def _try_repr_or_str(obj: object) -> str: try: return repr(obj) except (KeyboardInterrupt, SystemExit): @@ -12,7 +15,7 @@ def _try_repr_or_str(obj): return '{}("{}")'.format(type(obj).__name__, obj) -def _format_repr_exception(exc: BaseException, obj: Any) -> str: +def _format_repr_exception(exc: BaseException, obj: object) -> str: try: exc_info = _try_repr_or_str(exc) except (KeyboardInterrupt, SystemExit): @@ -20,7 +23,7 @@ def _format_repr_exception(exc: BaseException, obj: Any) -> str: except BaseException as exc: exc_info = "unpresentable exception ({})".format(_try_repr_or_str(exc)) return "<[{} raised in repr()] {} object at 0x{:x}>".format( - exc_info, obj.__class__.__name__, id(obj) + exc_info, type(obj).__name__, id(obj) ) @@ -33,16 +36,15 @@ def _ellipsize(s: str, maxsize: int) -> str: class SafeRepr(reprlib.Repr): - """subclass of repr.Repr that limits the resulting size of repr() - and includes information on exceptions raised during the call. - """ + """repr.Repr that limits the resulting size of repr() and includes + information on exceptions raised during the call.""" def __init__(self, maxsize: int) -> None: super().__init__() self.maxstring = maxsize self.maxsize = maxsize - def repr(self, x: Any) -> str: + def repr(self, x: object) -> str: try: s = super().repr(x) except (KeyboardInterrupt, SystemExit): @@ -51,7 +53,7 @@ def repr(self, x: Any) -> str: s = _format_repr_exception(exc, x) return _ellipsize(s, self.maxsize) - def repr_instance(self, x: Any, level: int) -> str: + def repr_instance(self, x: object, level: int) -> str: try: s = repr(x) except (KeyboardInterrupt, SystemExit): @@ -61,8 +63,9 @@ def repr_instance(self, x: Any, level: int) -> str: return _ellipsize(s, self.maxsize) -def safeformat(obj: Any) -> str: - """return a pretty printed string for the given object. +def safeformat(obj: object) -> str: + """Return a pretty printed string for the given object. + Failing __repr__ functions of user instances will be represented with a short exception info. """ @@ -72,12 +75,15 @@ def safeformat(obj: Any) -> str: return _format_repr_exception(exc, obj) -def saferepr(obj: Any, maxsize: int = 240) -> str: - """return a size-limited safe repr-string for the given object. +def saferepr(obj: object, maxsize: int = 240) -> str: + """Return a size-limited safe repr-string for the given object. + Failing __repr__ functions of user instances will be represented with a short exception info and 'saferepr' generally takes - care to never raise exceptions itself. This function is a wrapper - around the Repr/reprlib functionality of the standard 2.6 lib. + care to never raise exceptions itself. + + This function is a wrapper around the Repr/reprlib functionality of the + standard 2.6 lib. """ return SafeRepr(maxsize).repr(obj) @@ -85,19 +91,39 @@ def saferepr(obj: Any, maxsize: int = 240) -> str: class AlwaysDispatchingPrettyPrinter(pprint.PrettyPrinter): """PrettyPrinter that always dispatches (regardless of width).""" - def _format(self, object, stream, indent, allowance, context, level): - p = self._dispatch.get(type(object).__repr__, None) + def _format( + self, + object: object, + stream: IO[str], + indent: int, + allowance: int, + context: Dict[int, Any], + level: int, + ) -> None: + # Type ignored because _dispatch is private. + p = self._dispatch.get(type(object).__repr__, None) # type: ignore[attr-defined] objid = id(object) if objid in context or p is None: - return super()._format(object, stream, indent, allowance, context, level) + # Type ignored because _format is private. + super()._format( # type: ignore[misc] + object, stream, indent, allowance, context, level, + ) + return context[objid] = 1 p(self, object, stream, indent, allowance, context, level + 1) del context[objid] -def _pformat_dispatch(object, indent=1, width=80, depth=None, *, compact=False): +def _pformat_dispatch( + object: object, + indent: int = 1, + width: int = 80, + depth: Optional[int] = None, + *, + compact: bool = False, +) -> str: return AlwaysDispatchingPrettyPrinter( indent=indent, width=width, depth=depth, compact=compact ).pformat(object) diff --git a/src/_pytest/_io/terminalwriter.py b/src/_pytest/_io/terminalwriter.py new file mode 100644 index 00000000000..8edf4cd75fa --- /dev/null +++ b/src/_pytest/_io/terminalwriter.py @@ -0,0 +1,210 @@ +"""Helper functions for writing to terminals and files.""" +import os +import shutil +import sys +from typing import Optional +from typing import Sequence +from typing import TextIO + +from .wcwidth import wcswidth +from _pytest.compat import final + + +# This code was initially copied from py 1.8.1, file _io/terminalwriter.py. + + +def get_terminal_width() -> int: + width, _ = shutil.get_terminal_size(fallback=(80, 24)) + + # The Windows get_terminal_size may be bogus, let's sanify a bit. + if width < 40: + width = 80 + + return width + + +def should_do_markup(file: TextIO) -> bool: + if os.environ.get("PY_COLORS") == "1": + return True + if os.environ.get("PY_COLORS") == "0": + return False + if "NO_COLOR" in os.environ: + return False + if "FORCE_COLOR" in os.environ: + return True + return ( + hasattr(file, "isatty") and file.isatty() and os.environ.get("TERM") != "dumb" + ) + + +@final +class TerminalWriter: + _esctable = dict( + black=30, + red=31, + green=32, + yellow=33, + blue=34, + purple=35, + cyan=36, + white=37, + Black=40, + Red=41, + Green=42, + Yellow=43, + Blue=44, + Purple=45, + Cyan=46, + White=47, + bold=1, + light=2, + blink=5, + invert=7, + ) + + def __init__(self, file: Optional[TextIO] = None) -> None: + if file is None: + file = sys.stdout + if hasattr(file, "isatty") and file.isatty() and sys.platform == "win32": + try: + import colorama + except ImportError: + pass + else: + file = colorama.AnsiToWin32(file).stream + assert file is not None + self._file = file + self.hasmarkup = should_do_markup(file) + self._current_line = "" + self._terminal_width: Optional[int] = None + self.code_highlight = True + + @property + def fullwidth(self) -> int: + if self._terminal_width is not None: + return self._terminal_width + return get_terminal_width() + + @fullwidth.setter + def fullwidth(self, value: int) -> None: + self._terminal_width = value + + @property + def width_of_current_line(self) -> int: + """Return an estimate of the width so far in the current line.""" + return wcswidth(self._current_line) + + def markup(self, text: str, **markup: bool) -> str: + for name in markup: + if name not in self._esctable: + raise ValueError(f"unknown markup: {name!r}") + if self.hasmarkup: + esc = [self._esctable[name] for name, on in markup.items() if on] + if esc: + text = "".join("\x1b[%sm" % cod for cod in esc) + text + "\x1b[0m" + return text + + def sep( + self, + sepchar: str, + title: Optional[str] = None, + fullwidth: Optional[int] = None, + **markup: bool, + ) -> None: + if fullwidth is None: + fullwidth = self.fullwidth + # The goal is to have the line be as long as possible + # under the condition that len(line) <= fullwidth. + if sys.platform == "win32": + # If we print in the last column on windows we are on a + # new line but there is no way to verify/neutralize this + # (we may not know the exact line width). + # So let's be defensive to avoid empty lines in the output. + fullwidth -= 1 + if title is not None: + # we want 2 + 2*len(fill) + len(title) <= fullwidth + # i.e. 2 + 2*len(sepchar)*N + len(title) <= fullwidth + # 2*len(sepchar)*N <= fullwidth - len(title) - 2 + # N <= (fullwidth - len(title) - 2) // (2*len(sepchar)) + N = max((fullwidth - len(title) - 2) // (2 * len(sepchar)), 1) + fill = sepchar * N + line = f"{fill} {title} {fill}" + else: + # we want len(sepchar)*N <= fullwidth + # i.e. N <= fullwidth // len(sepchar) + line = sepchar * (fullwidth // len(sepchar)) + # In some situations there is room for an extra sepchar at the right, + # in particular if we consider that with a sepchar like "_ " the + # trailing space is not important at the end of the line. + if len(line) + len(sepchar.rstrip()) <= fullwidth: + line += sepchar.rstrip() + + self.line(line, **markup) + + def write(self, msg: str, *, flush: bool = False, **markup: bool) -> None: + if msg: + current_line = msg.rsplit("\n", 1)[-1] + if "\n" in msg: + self._current_line = current_line + else: + self._current_line += current_line + + msg = self.markup(msg, **markup) + + try: + self._file.write(msg) + except UnicodeEncodeError: + # Some environments don't support printing general Unicode + # strings, due to misconfiguration or otherwise; in that case, + # print the string escaped to ASCII. + # When the Unicode situation improves we should consider + # letting the error propagate instead of masking it (see #7475 + # for one brief attempt). + msg = msg.encode("unicode-escape").decode("ascii") + self._file.write(msg) + + if flush: + self.flush() + + def line(self, s: str = "", **markup: bool) -> None: + self.write(s, **markup) + self.write("\n") + + def flush(self) -> None: + self._file.flush() + + def _write_source(self, lines: Sequence[str], indents: Sequence[str] = ()) -> None: + """Write lines of source code possibly highlighted. + + Keeping this private for now because the API is clunky. We should discuss how + to evolve the terminal writer so we can have more precise color support, for example + being able to write part of a line in one color and the rest in another, and so on. + """ + if indents and len(indents) != len(lines): + raise ValueError( + "indents size ({}) should have same size as lines ({})".format( + len(indents), len(lines) + ) + ) + if not indents: + indents = [""] * len(lines) + source = "\n".join(lines) + new_lines = self._highlight(source).splitlines() + for indent, new_line in zip(indents, new_lines): + self.line(indent + new_line) + + def _highlight(self, source: str) -> str: + """Highlight the given source code if we have markup support.""" + if not self.hasmarkup or not self.code_highlight: + return source + try: + from pygments.formatters.terminal import TerminalFormatter + from pygments.lexers.python import PythonLexer + from pygments import highlight + except ImportError: + return source + else: + highlighted: str = highlight( + source, PythonLexer(), TerminalFormatter(bg="dark") + ) + return highlighted diff --git a/src/_pytest/_io/wcwidth.py b/src/_pytest/_io/wcwidth.py new file mode 100644 index 00000000000..e5c7bf4d868 --- /dev/null +++ b/src/_pytest/_io/wcwidth.py @@ -0,0 +1,55 @@ +import unicodedata +from functools import lru_cache + + +@lru_cache(100) +def wcwidth(c: str) -> int: + """Determine how many columns are needed to display a character in a terminal. + + Returns -1 if the character is not printable. + Returns 0, 1 or 2 for other characters. + """ + o = ord(c) + + # ASCII fast path. + if 0x20 <= o < 0x07F: + return 1 + + # Some Cf/Zp/Zl characters which should be zero-width. + if ( + o == 0x0000 + or 0x200B <= o <= 0x200F + or 0x2028 <= o <= 0x202E + or 0x2060 <= o <= 0x2063 + ): + return 0 + + category = unicodedata.category(c) + + # Control characters. + if category == "Cc": + return -1 + + # Combining characters with zero width. + if category in ("Me", "Mn"): + return 0 + + # Full/Wide east asian characters. + if unicodedata.east_asian_width(c) in ("F", "W"): + return 2 + + return 1 + + +def wcswidth(s: str) -> int: + """Determine how many columns are needed to display a string in a terminal. + + Returns -1 if the string contains non-printable characters. + """ + width = 0 + for c in unicodedata.normalize("NFC", s): + wc = wcwidth(c) + if wc < 0: + return -1 + width += wc + return width diff --git a/src/_pytest/assertion/__init__.py b/src/_pytest/assertion/__init__.py index ee7fa6a3af0..a18cf198df0 100644 --- a/src/_pytest/assertion/__init__.py +++ b/src/_pytest/assertion/__init__.py @@ -1,24 +1,25 @@ -""" -support for presenting detailed information in failing assertions. -""" +"""Support for presenting detailed information in failing assertions.""" import sys from typing import Any +from typing import Generator from typing import List from typing import Optional +from typing import TYPE_CHECKING from _pytest.assertion import rewrite from _pytest.assertion import truncate from _pytest.assertion import util from _pytest.assertion.rewrite import assertstate_key -from _pytest.compat import TYPE_CHECKING from _pytest.config import Config from _pytest.config import hookimpl +from _pytest.config.argparsing import Parser +from _pytest.nodes import Item if TYPE_CHECKING: from _pytest.main import Session -def pytest_addoption(parser): +def pytest_addoption(parser: Parser) -> None: group = parser.getgroup("debugconfig") group.addoption( "--assert", @@ -27,11 +28,12 @@ def pytest_addoption(parser): choices=("rewrite", "plain"), default="rewrite", metavar="MODE", - help="""Control assertion debugging tools. 'plain' - performs no assertion debugging. 'rewrite' - (the default) rewrites assert statements in - test modules on import to provide assert - expression information.""", + help=( + "Control assertion debugging tools.\n" + "'plain' performs no assertion debugging.\n" + "'rewrite' (the default) rewrites assert statements in test modules" + " on import to provide assert expression information." + ), ) parser.addini( "enable_assertion_pass_hook", @@ -42,7 +44,7 @@ def pytest_addoption(parser): ) -def register_assert_rewrite(*names) -> None: +def register_assert_rewrite(*names: str) -> None: """Register one or more module names to be rewritten on import. This function will make sure that this module or all modules inside @@ -51,11 +53,11 @@ def register_assert_rewrite(*names) -> None: actually imported, usually in your __init__.py if you are a plugin using a package. - :raise TypeError: if the given module names are not strings. + :raises TypeError: If the given module names are not strings. """ for name in names: if not isinstance(name, str): - msg = "expected module names as *args, got {0} instead" + msg = "expected module names as *args, got {0} instead" # type: ignore[unreachable] raise TypeError(msg.format(repr(names))) for hook in sys.meta_path: if isinstance(hook, rewrite.AssertionRewritingHook): @@ -71,27 +73,27 @@ def register_assert_rewrite(*names) -> None: class DummyRewriteHook: """A no-op import hook for when rewriting is disabled.""" - def mark_rewrite(self, *names): + def mark_rewrite(self, *names: str) -> None: pass class AssertionState: """State for the assertion plugin.""" - def __init__(self, config, mode): + def __init__(self, config: Config, mode) -> None: self.mode = mode self.trace = config.trace.root.get("assertion") - self.hook = None # type: Optional[rewrite.AssertionRewritingHook] + self.hook: Optional[rewrite.AssertionRewritingHook] = None -def install_importhook(config): +def install_importhook(config: Config) -> rewrite.AssertionRewritingHook: """Try to install the rewrite hook, raise SystemError if it fails.""" config._store[assertstate_key] = AssertionState(config, "rewrite") config._store[assertstate_key].hook = hook = rewrite.AssertionRewritingHook(config) sys.meta_path.insert(0, hook) config._store[assertstate_key].trace("installed rewrite import hook") - def undo(): + def undo() -> None: hook = config._store[assertstate_key].hook if hook is not None and hook in sys.meta_path: sys.meta_path.remove(hook) @@ -101,9 +103,9 @@ def undo(): def pytest_collection(session: "Session") -> None: - # this hook is only called when test modules are collected + # This hook is only called when test modules are collected # so for example not in the master process of pytest-xdist - # (which does not collect test modules) + # (which does not collect test modules). assertstate = session.config._store.get(assertstate_key, None) if assertstate: if assertstate.hook is not None: @@ -111,18 +113,18 @@ def pytest_collection(session: "Session") -> None: @hookimpl(tryfirst=True, hookwrapper=True) -def pytest_runtest_protocol(item): - """Setup the pytest_assertrepr_compare and pytest_assertion_pass hooks +def pytest_runtest_protocol(item: Item) -> Generator[None, None, None]: + """Setup the pytest_assertrepr_compare and pytest_assertion_pass hooks. - The newinterpret and rewrite modules will use util._reprcompare if - it exists to use custom reporting via the - pytest_assertrepr_compare hook. This sets up this custom + The rewrite module will use util._reprcompare if it exists to use custom + reporting via the pytest_assertrepr_compare hook. This sets up this custom comparison for the test. """ - def callbinrepr(op, left, right): - # type: (str, object, object) -> Optional[str] - """Call the pytest_assertrepr_compare hook and prepare the result + ihook = item.ihook + + def callbinrepr(op, left: object, right: object) -> Optional[str]: + """Call the pytest_assertrepr_compare hook and prepare the result. This uses the first result from the hook and then ensures the following: @@ -136,7 +138,7 @@ def callbinrepr(op, left, right): The result can be formatted by util.format_explanation() for pretty printing. """ - hook_result = item.ihook.pytest_assertrepr_compare( + hook_result = ihook.pytest_assertrepr_compare( config=item.config, op=op, left=left, right=right ) for new_expl in hook_result: @@ -152,12 +154,10 @@ def callbinrepr(op, left, right): saved_assert_hooks = util._reprcompare, util._assertion_pass util._reprcompare = callbinrepr - if item.ihook.pytest_assertion_pass.get_hookimpls(): + if ihook.pytest_assertion_pass.get_hookimpls(): - def call_assertion_pass_hook(lineno, orig, expl): - item.ihook.pytest_assertion_pass( - item=item, lineno=lineno, orig=orig, expl=expl - ) + def call_assertion_pass_hook(lineno: int, orig: str, expl: str) -> None: + ihook.pytest_assertion_pass(item=item, lineno=lineno, orig=orig, expl=expl) util._assertion_pass = call_assertion_pass_hook @@ -166,7 +166,7 @@ def call_assertion_pass_hook(lineno, orig, expl): util._reprcompare, util._assertion_pass = saved_assert_hooks -def pytest_sessionfinish(session): +def pytest_sessionfinish(session: "Session") -> None: assertstate = session.config._store.get(assertstate_key, None) if assertstate: if assertstate.hook is not None: diff --git a/src/_pytest/assertion/rewrite.py b/src/_pytest/assertion/rewrite.py index f84127dcaf1..805d4c8b35b 100644 --- a/src/_pytest/assertion/rewrite.py +++ b/src/_pytest/assertion/rewrite.py @@ -1,4 +1,4 @@ -"""Rewrite assertion AST to produce nice error messages""" +"""Rewrite assertion AST to produce nice error messages.""" import ast import errno import functools @@ -13,11 +13,21 @@ import sys import tokenize import types +from pathlib import Path +from pathlib import PurePath +from typing import Callable from typing import Dict +from typing import IO +from typing import Iterable from typing import List from typing import Optional +from typing import Sequence from typing import Set from typing import Tuple +from typing import TYPE_CHECKING +from typing import Union + +import py from _pytest._io.saferepr import saferepr from _pytest._version import version @@ -25,22 +35,20 @@ from _pytest.assertion.util import ( # noqa: F401 format_explanation as _format_explanation, ) -from _pytest.compat import fspath -from _pytest.compat import TYPE_CHECKING +from _pytest.config import Config +from _pytest.main import Session from _pytest.pathlib import fnmatch_ex -from _pytest.pathlib import Path -from _pytest.pathlib import PurePath from _pytest.store import StoreKey if TYPE_CHECKING: - from _pytest.assertion import AssertionState # noqa: F401 + from _pytest.assertion import AssertionState assertstate_key = StoreKey["AssertionState"]() # pytest caches rewritten pycs in pycache dirs -PYTEST_TAG = "{}-pytest-{}".format(sys.implementation.cache_tag, version) +PYTEST_TAG = f"{sys.implementation.cache_tag}-pytest-{version}" PYC_EXT = ".py" + (__debug__ and "c" or "o") PYC_TAIL = "." + PYTEST_TAG + PYC_EXT @@ -48,30 +56,35 @@ class AssertionRewritingHook(importlib.abc.MetaPathFinder, importlib.abc.Loader): """PEP302/PEP451 import hook which rewrites asserts.""" - def __init__(self, config): + def __init__(self, config: Config) -> None: self.config = config try: self.fnpats = config.getini("python_files") except ValueError: self.fnpats = ["test_*.py", "*_test.py"] - self.session = None - self._rewritten_names = set() # type: Set[str] - self._must_rewrite = set() # type: Set[str] + self.session: Optional[Session] = None + self._rewritten_names: Set[str] = set() + self._must_rewrite: Set[str] = set() # flag to guard against trying to rewrite a pyc file while we are already writing another pyc file, # which might result in infinite recursion (#3506) self._writing_pyc = False self._basenames_to_check_rewrite = {"conftest"} - self._marked_for_rewrite_cache = {} # type: Dict[str, bool] + self._marked_for_rewrite_cache: Dict[str, bool] = {} self._session_paths_checked = False - def set_session(self, session): + def set_session(self, session: Optional[Session]) -> None: self.session = session self._session_paths_checked = False # Indirection so we can mock calls to find_spec originated from the hook during testing _find_spec = importlib.machinery.PathFinder.find_spec - def find_spec(self, name, path=None, target=None): + def find_spec( + self, + name: str, + path: Optional[Sequence[Union[str, bytes]]] = None, + target: Optional[types.ModuleType] = None, + ) -> Optional[importlib.machinery.ModuleSpec]: if self._writing_pyc: return None state = self.config._store[assertstate_key] @@ -79,13 +92,14 @@ def find_spec(self, name, path=None, target=None): return None state.trace("find_module called for: %s" % name) - spec = self._find_spec(name, path) + # Type ignored because mypy is confused about the `self` binding here. + spec = self._find_spec(name, path) # type: ignore if ( # the import machinery could not find a file to import spec is None # this is a namespace package (without `__init__.py`) # there's nothing to rewrite there - # python3.5 - python3.6: `namespace` + # python3.6: `namespace` # python3.7+: `None` or spec.origin == "namespace" or spec.origin is None @@ -108,10 +122,14 @@ def find_spec(self, name, path=None, target=None): submodule_search_locations=spec.submodule_search_locations, ) - def create_module(self, spec): + def create_module( + self, spec: importlib.machinery.ModuleSpec + ) -> Optional[types.ModuleType]: return None # default behaviour is fine - def exec_module(self, module): + def exec_module(self, module: types.ModuleType) -> None: + assert module.__spec__ is not None + assert module.__spec__.origin is not None fn = Path(module.__spec__.origin) state = self.config._store[assertstate_key] @@ -131,7 +149,7 @@ def exec_module(self, module): ok = try_makedirs(cache_dir) if not ok: write = False - state.trace("read only directory: {}".format(cache_dir)) + state.trace(f"read only directory: {cache_dir}") cache_name = fn.name[:-3] + PYC_TAIL pyc = cache_dir / cache_name @@ -139,7 +157,7 @@ def exec_module(self, module): # to check for a cached pyc. This may not be optimal... co = _read_pyc(fn, pyc, state.trace) if co is None: - state.trace("rewriting {!r}".format(fn)) + state.trace(f"rewriting {fn!r}") source_stat, co = _rewrite_test(fn, self.config) if write: self._writing_pyc = True @@ -148,11 +166,11 @@ def exec_module(self, module): finally: self._writing_pyc = False else: - state.trace("found cached rewritten pyc for {}".format(fn)) + state.trace(f"found cached rewritten pyc for {fn}") exec(co, module.__dict__) - def _early_rewrite_bailout(self, name, state): - """This is a fast way to get out of rewriting modules. + def _early_rewrite_bailout(self, name: str, state: "AssertionState") -> bool: + """A fast way to get out of rewriting modules. Profiling has shown that the call to PathFinder.find_spec (inside of the find_spec from this class) is a major slowdown, so, this method @@ -161,10 +179,10 @@ def _early_rewrite_bailout(self, name, state): """ if self.session is not None and not self._session_paths_checked: self._session_paths_checked = True - for path in self.session._initialpaths: + for initial_path in self.session._initialpaths: # Make something as c:/projects/my_project/path.py -> # ['c:', 'projects', 'my_project', 'path.py'] - parts = str(path).split(os.path.sep) + parts = str(initial_path).split(os.path.sep) # add 'path' to basenames to be checked. self._basenames_to_check_rewrite.add(os.path.splitext(parts[-1])[0]) @@ -187,20 +205,18 @@ def _early_rewrite_bailout(self, name, state): if self._is_marked_for_rewrite(name, state): return False - state.trace("early skip of rewriting module: {}".format(name)) + state.trace(f"early skip of rewriting module: {name}") return True - def _should_rewrite(self, name, fn, state): + def _should_rewrite(self, name: str, fn: str, state: "AssertionState") -> bool: # always rewrite conftest files if os.path.basename(fn) == "conftest.py": - state.trace("rewriting conftest file: {!r}".format(fn)) + state.trace(f"rewriting conftest file: {fn!r}") return True if self.session is not None: - if self.session.isinitpath(fn): - state.trace( - "matched test file (was specified on cmdline): {!r}".format(fn) - ) + if self.session.isinitpath(py.path.local(fn)): + state.trace(f"matched test file (was specified on cmdline): {fn!r}") return True # modules not passed explicitly on the command line are only @@ -208,20 +224,18 @@ def _should_rewrite(self, name, fn, state): fn_path = PurePath(fn) for pat in self.fnpats: if fnmatch_ex(pat, fn_path): - state.trace("matched test file {!r}".format(fn)) + state.trace(f"matched test file {fn!r}") return True return self._is_marked_for_rewrite(name, state) - def _is_marked_for_rewrite(self, name: str, state): + def _is_marked_for_rewrite(self, name: str, state: "AssertionState") -> bool: try: return self._marked_for_rewrite_cache[name] except KeyError: for marked in self._must_rewrite: if name == marked or name.startswith(marked + "."): - state.trace( - "matched marked file {!r} (from {!r})".format(name, marked) - ) + state.trace(f"matched marked file {name!r} (from {marked!r})") self._marked_for_rewrite_cache[name] = True return True @@ -246,33 +260,37 @@ def mark_rewrite(self, *names: str) -> None: self._must_rewrite.update(names) self._marked_for_rewrite_cache.clear() - def _warn_already_imported(self, name): + def _warn_already_imported(self, name: str) -> None: from _pytest.warning_types import PytestAssertRewriteWarning - from _pytest.warnings import _issue_warning_captured - _issue_warning_captured( + self.config.issue_config_time_warning( PytestAssertRewriteWarning( "Module already imported so cannot be rewritten: %s" % name ), - self.config.hook, stacklevel=5, ) - def get_data(self, pathname): + def get_data(self, pathname: Union[str, bytes]) -> bytes: """Optional PEP302 get_data API.""" with open(pathname, "rb") as f: return f.read() -def _write_pyc_fp(fp, source_stat, co): +def _write_pyc_fp( + fp: IO[bytes], source_stat: os.stat_result, co: types.CodeType +) -> None: # Technically, we don't have to have the same pyc format as # (C)Python, since these "pycs" should never be seen by builtin - # import. However, there's little reason deviate. + # import. However, there's little reason to deviate. fp.write(importlib.util.MAGIC_NUMBER) + # https://www.python.org/dev/peps/pep-0552/ + if sys.version_info >= (3, 7): + flags = b"\x00\x00\x00\x00" + fp.write(flags) # as of now, bytecode header expects 32-bit numbers for size and mtime (#4903) mtime = int(source_stat.st_mtime) & 0xFFFFFFFF size = source_stat.st_size & 0xFFFFFFFF - # " bool: try: - with atomic_write(fspath(pyc), mode="wb", overwrite=True) as fp: + with atomic_write(os.fspath(pyc), mode="wb", overwrite=True) as fp: _write_pyc_fp(fp, source_stat, co) - except EnvironmentError as e: - state.trace("error writing pyc file at {}: errno={}".format(pyc, e.errno)) + except OSError as e: + state.trace(f"error writing pyc file at {pyc}: {e}") # we ignore any failure to write the cache file # there are many reasons, permission-denied, pycache dir being a # file etc. @@ -295,21 +318,24 @@ def _write_pyc(state, co, source_stat, pyc): else: - def _write_pyc(state, co, source_stat, pyc): - proc_pyc = "{}.{}".format(pyc, os.getpid()) + def _write_pyc( + state: "AssertionState", + co: types.CodeType, + source_stat: os.stat_result, + pyc: Path, + ) -> bool: + proc_pyc = f"{pyc}.{os.getpid()}" try: fp = open(proc_pyc, "wb") - except EnvironmentError as e: - state.trace( - "error writing pyc file at {}: errno={}".format(proc_pyc, e.errno) - ) + except OSError as e: + state.trace(f"error writing pyc file at {proc_pyc}: errno={e.errno}") return False try: _write_pyc_fp(fp, source_stat, co) - os.rename(proc_pyc, fspath(pyc)) - except BaseException as e: - state.trace("error writing pyc file at {}: errno={}".format(pyc, e.errno)) + os.rename(proc_pyc, os.fspath(pyc)) + except OSError as e: + state.trace(f"error writing pyc file at {pyc}: {e}") # we ignore any failure to write the cache file # there are many reasons, permission-denied, pycache dir being a # file etc. @@ -319,48 +345,62 @@ def _write_pyc(state, co, source_stat, pyc): return True -def _rewrite_test(fn, config): - """read and rewrite *fn* and return the code object.""" - fn = fspath(fn) - stat = os.stat(fn) - with open(fn, "rb") as f: +def _rewrite_test(fn: Path, config: Config) -> Tuple[os.stat_result, types.CodeType]: + """Read and rewrite *fn* and return the code object.""" + fn_ = os.fspath(fn) + stat = os.stat(fn_) + with open(fn_, "rb") as f: source = f.read() - tree = ast.parse(source, filename=fn) - rewrite_asserts(tree, source, fn, config) - co = compile(tree, fn, "exec", dont_inherit=True) + tree = ast.parse(source, filename=fn_) + rewrite_asserts(tree, source, fn_, config) + co = compile(tree, fn_, "exec", dont_inherit=True) return stat, co -def _read_pyc(source, pyc, trace=lambda x: None): +def _read_pyc( + source: Path, pyc: Path, trace: Callable[[str], None] = lambda x: None +) -> Optional[types.CodeType]: """Possibly read a pytest pyc containing rewritten code. Return rewritten code if successful or None if not. """ try: - fp = open(fspath(pyc), "rb") - except IOError: + fp = open(os.fspath(pyc), "rb") + except OSError: return None with fp: + # https://www.python.org/dev/peps/pep-0552/ + has_flags = sys.version_info >= (3, 7) try: - stat_result = os.stat(fspath(source)) + stat_result = os.stat(os.fspath(source)) mtime = int(stat_result.st_mtime) size = stat_result.st_size - data = fp.read(12) - except EnvironmentError as e: - trace("_read_pyc({}): EnvironmentError {}".format(source, e)) + data = fp.read(16 if has_flags else 12) + except OSError as e: + trace(f"_read_pyc({source}): OSError {e}") return None # Check for invalid or out of date pyc file. - if ( - len(data) != 12 - or data[:4] != importlib.util.MAGIC_NUMBER - or struct.unpack(" None: """Rewrite the assert statements in mod.""" AssertionRewriter(module_path, config, source).run(mod) -def _saferepr(obj): - """Get a safe repr of an object for assertion error messages. +def _saferepr(obj: object) -> str: + r"""Get a safe repr of an object for assertion error messages. The assertion formatting (util.format_explanation()) requires newlines to be escaped since they are a special character for it. @@ -382,18 +427,16 @@ def _saferepr(obj): custom repr it is possible to contain one of the special escape sequences, especially '\n{' and '\n}' are likely to be present in JSON reprs. - """ return saferepr(obj).replace("\n", "\\n") -def _format_assertmsg(obj): - """Format the custom assertion message given. +def _format_assertmsg(obj: object) -> str: + r"""Format the custom assertion message given. For strings this simply replaces newlines with '\n~' so that util.format_explanation() will preserve them instead of escaping newlines. For other objects saferepr() is used first. - """ # reprlib appears to have a bug which means that if a string # contains a newline it gets escaped, however if an object has a @@ -410,7 +453,7 @@ def _format_assertmsg(obj): return obj -def _should_repr_global_name(obj): +def _should_repr_global_name(obj: object) -> bool: if callable(obj): return False @@ -420,16 +463,17 @@ def _should_repr_global_name(obj): return True -def _format_boolop(explanations, is_or): +def _format_boolop(explanations: Iterable[str], is_or: bool) -> str: explanation = "(" + (is_or and " or " or " and ").join(explanations) + ")" - if isinstance(explanation, str): - return explanation.replace("%", "%%") - else: - return explanation.replace(b"%", b"%%") + return explanation.replace("%", "%%") -def _call_reprcompare(ops, results, expls, each_obj): - # type: (Tuple[str, ...], Tuple[bool, ...], Tuple[str, ...], Tuple[object, ...]) -> str +def _call_reprcompare( + ops: Sequence[str], + results: Sequence[bool], + expls: Sequence[str], + each_obj: Sequence[object], +) -> str: for i, res, expl in zip(range(len(ops)), results, expls): try: done = not res @@ -444,16 +488,14 @@ def _call_reprcompare(ops, results, expls, each_obj): return expl -def _call_assertion_pass(lineno, orig, expl): - # type: (int, str, str) -> None +def _call_assertion_pass(lineno: int, orig: str, expl: str) -> None: if util._assertion_pass is not None: util._assertion_pass(lineno, orig, expl) -def _check_if_assertion_pass_impl(): - # type: () -> bool - """Checks if any plugins implement the pytest_assertion_pass hook - in order not to generate explanation unecessarily (might be expensive)""" +def _check_if_assertion_pass_impl() -> bool: + """Check if any plugins implement the pytest_assertion_pass hook + in order not to generate explanation unecessarily (might be expensive).""" return True if util._assertion_pass else False @@ -502,13 +544,13 @@ def _fix(node, lineno, col_offset): def _get_assertion_exprs(src: bytes) -> Dict[int, str]: - """Returns a mapping from {lineno: "assertion test expression"}""" - ret = {} # type: Dict[int, str] + """Return a mapping from {lineno: "assertion test expression"}.""" + ret: Dict[int, str] = {} depth = 0 - lines = [] # type: List[str] - assert_lineno = None # type: Optional[int] - seen_lines = set() # type: Set[int] + lines: List[str] = [] + assert_lineno: Optional[int] = None + seen_lines: Set[int] = set() def _write_and_reset() -> None: nonlocal depth, lines, assert_lineno, seen_lines @@ -606,10 +648,11 @@ class AssertionRewriter(ast.NodeVisitor): This state is reset on every new assert statement visited and used by the other visitors. - """ - def __init__(self, module_path, config, source): + def __init__( + self, module_path: Optional[str], config: Optional[Config], source: bytes + ) -> None: super().__init__() self.module_path = module_path self.config = config @@ -622,7 +665,7 @@ def __init__(self, module_path, config, source): self.source = source @functools.lru_cache(maxsize=1) - def _assert_expr_to_lineno(self): + def _assert_expr_to_lineno(self) -> Dict[int, str]: return _get_assertion_exprs(self.source) def run(self, mod: ast.Module) -> None: @@ -653,13 +696,18 @@ def run(self, mod: ast.Module) -> None: return expect_docstring = False elif ( - not isinstance(item, ast.ImportFrom) - or item.level > 0 - or item.module != "__future__" + isinstance(item, ast.ImportFrom) + and item.level == 0 + and item.module == "__future__" ): - lineno = item.lineno + pass + else: break pos += 1 + # Special case: for a decorated function, set the lineno to that of the + # first decorator, not the `def`. Issue #4984. + if isinstance(item, ast.FunctionDef) and item.decorator_list: + lineno = item.decorator_list[0].lineno else: lineno = item.lineno imports = [ @@ -667,12 +715,12 @@ def run(self, mod: ast.Module) -> None: ] mod.body[pos:pos] = imports # Collect asserts. - nodes = [mod] # type: List[ast.AST] + nodes: List[ast.AST] = [mod] while nodes: node = nodes.pop() for name, field in ast.iter_fields(node): if isinstance(field, list): - new = [] # type: List + new: List[ast.AST] = [] for i, child in enumerate(field): if isinstance(child, ast.Assert): # Transform assert. @@ -691,51 +739,50 @@ def run(self, mod: ast.Module) -> None: nodes.append(field) @staticmethod - def is_rewrite_disabled(docstring): + def is_rewrite_disabled(docstring: str) -> bool: return "PYTEST_DONT_REWRITE" in docstring - def variable(self): + def variable(self) -> str: """Get a new variable.""" # Use a character invalid in python identifiers to avoid clashing. name = "@py_assert" + str(next(self.variable_counter)) self.variables.append(name) return name - def assign(self, expr): + def assign(self, expr: ast.expr) -> ast.Name: """Give *expr* a name.""" name = self.variable() self.statements.append(ast.Assign([ast.Name(name, ast.Store())], expr)) return ast.Name(name, ast.Load()) - def display(self, expr): + def display(self, expr: ast.expr) -> ast.expr: """Call saferepr on the expression.""" return self.helper("_saferepr", expr) - def helper(self, name, *args): + def helper(self, name: str, *args: ast.expr) -> ast.expr: """Call a helper in this module.""" py_name = ast.Name("@pytest_ar", ast.Load()) attr = ast.Attribute(py_name, name, ast.Load()) return ast.Call(attr, list(args), []) - def builtin(self, name): + def builtin(self, name: str) -> ast.Attribute: """Return the builtin called *name*.""" builtin_name = ast.Name("@py_builtins", ast.Load()) return ast.Attribute(builtin_name, name, ast.Load()) - def explanation_param(self, expr): + def explanation_param(self, expr: ast.expr) -> str: """Return a new named %-formatting placeholder for expr. This creates a %-formatting placeholder for expr in the current formatting context, e.g. ``%(py0)s``. The placeholder and expr are placed in the current format context so that it can be used on the next call to .pop_format_context(). - """ specifier = "py" + str(next(self.variable_counter)) self.explanation_specifiers[specifier] = expr return "%(" + specifier + ")s" - def push_format_context(self): + def push_format_context(self) -> None: """Create a new formatting context. The format context is used for when an explanation wants to @@ -744,19 +791,17 @@ def push_format_context(self): .explanation_param(). Finally .pop_format_context() is used to format a string of %-formatted values as added by .explanation_param(). - """ - self.explanation_specifiers = {} # type: Dict[str, ast.expr] + self.explanation_specifiers: Dict[str, ast.expr] = {} self.stack.append(self.explanation_specifiers) - def pop_format_context(self, expl_expr): + def pop_format_context(self, expl_expr: ast.expr) -> ast.Name: """Format the %-formatted string with current format context. - The expl_expr should be an ast.Str instance constructed from + The expl_expr should be an str ast.expr instance constructed from the %-placeholders created by .explanation_param(). This will add the required code to format said string to .expl_stmts and return the ast.Name instance of the formatted string. - """ current = self.stack.pop() if self.stack: @@ -770,43 +815,44 @@ def pop_format_context(self, expl_expr): self.expl_stmts.append(ast.Assign([ast.Name(name, ast.Store())], form)) return ast.Name(name, ast.Load()) - def generic_visit(self, node): + def generic_visit(self, node: ast.AST) -> Tuple[ast.Name, str]: """Handle expressions we don't have custom code for.""" assert isinstance(node, ast.expr) res = self.assign(node) return res, self.explanation_param(self.display(res)) - def visit_Assert(self, assert_): + def visit_Assert(self, assert_: ast.Assert) -> List[ast.stmt]: """Return the AST statements to replace the ast.Assert instance. This rewrites the test of an assertion to provide intermediate values and replace it with an if statement which raises an assertion error with a detailed explanation in case the expression is false. - """ if isinstance(assert_.test, ast.Tuple) and len(assert_.test.elts) >= 1: from _pytest.warning_types import PytestAssertRewriteWarning import warnings + # TODO: This assert should not be needed. + assert self.module_path is not None warnings.warn_explicit( PytestAssertRewriteWarning( "assertion is always true, perhaps remove parentheses?" ), category=None, - filename=fspath(self.module_path), + filename=os.fspath(self.module_path), lineno=assert_.lineno, ) - self.statements = [] # type: List[ast.stmt] - self.variables = [] # type: List[str] + self.statements: List[ast.stmt] = [] + self.variables: List[str] = [] self.variable_counter = itertools.count() if self.enable_assertion_pass_hook: - self.format_variables = [] # type: List[str] + self.format_variables: List[str] = [] - self.stack = [] # type: List[Dict[str, ast.expr]] - self.expl_stmts = [] # type: List[ast.stmt] + self.stack: List[Dict[str, ast.expr]] = [] + self.expl_stmts: List[ast.stmt] = [] self.push_format_context() # Rewrite assert into a bunch of statements. top_condition, explanation = self.visit(assert_.test) @@ -891,7 +937,7 @@ def visit_Assert(self, assert_): set_location(stmt, assert_.lineno, assert_.col_offset) return self.statements - def visit_Name(self, name): + 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"), [], []) @@ -901,7 +947,7 @@ def visit_Name(self, name): expr = ast.IfExp(test, self.display(name), ast.Str(name.id)) return name, self.explanation_param(expr) - def visit_BoolOp(self, boolop): + def visit_BoolOp(self, boolop: ast.BoolOp) -> Tuple[ast.Name, str]: res_var = self.variable() expl_list = self.assign(ast.List([], ast.Load())) app = ast.Attribute(expl_list, "append", ast.Load()) @@ -913,7 +959,7 @@ def visit_BoolOp(self, boolop): # Process each operand, short-circuiting if needed. for i, v in enumerate(boolop.values): if i: - fail_inner = [] # type: List[ast.stmt] + fail_inner: List[ast.stmt] = [] # cond is set in a prior loop iteration below self.expl_stmts.append(ast.If(cond, fail_inner, [])) # noqa self.expl_stmts = fail_inner @@ -924,10 +970,10 @@ def visit_BoolOp(self, boolop): call = ast.Call(app, [expl_format], []) self.expl_stmts.append(ast.Expr(call)) if i < levels: - cond = res # type: ast.expr + cond: ast.expr = res if is_or: cond = ast.UnaryOp(ast.Not(), cond) - inner = [] # type: List[ast.stmt] + inner: List[ast.stmt] = [] self.statements.append(ast.If(cond, inner, [])) self.statements = body = inner self.statements = save @@ -936,24 +982,21 @@ def visit_BoolOp(self, boolop): expl = self.pop_format_context(expl_template) return ast.Name(res_var, ast.Load()), self.explanation_param(expl) - def visit_UnaryOp(self, unary): + def visit_UnaryOp(self, unary: ast.UnaryOp) -> Tuple[ast.Name, str]: pattern = UNARY_MAP[unary.op.__class__] operand_res, operand_expl = self.visit(unary.operand) res = self.assign(ast.UnaryOp(unary.op, operand_res)) return res, pattern % (operand_expl,) - def visit_BinOp(self, binop): + def visit_BinOp(self, binop: ast.BinOp) -> Tuple[ast.Name, str]: symbol = BINOP_MAP[binop.op.__class__] left_expr, left_expl = self.visit(binop.left) right_expr, right_expl = self.visit(binop.right) - explanation = "({} {} {})".format(left_expl, symbol, right_expl) + explanation = f"({left_expl} {symbol} {right_expl})" res = self.assign(ast.BinOp(left_expr, binop.op, right_expr)) return res, explanation - def visit_Call(self, call): - """ - visit `ast.Call` nodes - """ + def visit_Call(self, call: ast.Call) -> Tuple[ast.Name, str]: new_func, func_expl = self.visit(call.func) arg_expls = [] new_args = [] @@ -974,16 +1017,16 @@ def visit_Call(self, call): new_call = ast.Call(new_func, new_args, new_kwargs) res = self.assign(new_call) res_expl = self.explanation_param(self.display(res)) - outer_expl = "{}\n{{{} = {}\n}}".format(res_expl, res_expl, expl) + outer_expl = f"{res_expl}\n{{{res_expl} = {expl}\n}}" return res, outer_expl - def visit_Starred(self, starred): - # From Python 3.5, a Starred node can appear in a function call + def visit_Starred(self, starred: ast.Starred) -> Tuple[ast.Starred, str]: + # A Starred node can appear in a function call. res, expl = self.visit(starred.value) new_starred = ast.Starred(res, starred.ctx) return new_starred, "*" + expl - def visit_Attribute(self, attr): + def visit_Attribute(self, attr: ast.Attribute) -> Tuple[ast.Name, str]: if not isinstance(attr.ctx, ast.Load): return self.generic_visit(attr) value, value_expl = self.visit(attr.value) @@ -993,11 +1036,11 @@ def visit_Attribute(self, attr): expl = pat % (res_expl, res_expl, value_expl, attr.attr) return res, expl - def visit_Compare(self, comp: ast.Compare): + def visit_Compare(self, comp: ast.Compare) -> Tuple[ast.expr, str]: self.push_format_context() left_res, left_expl = self.visit(comp.left) if isinstance(comp.left, (ast.Compare, ast.BoolOp)): - left_expl = "({})".format(left_expl) + left_expl = f"({left_expl})" res_variables = [self.variable() for i in range(len(comp.ops))] load_names = [ast.Name(v, ast.Load()) for v in res_variables] store_names = [ast.Name(v, ast.Store()) for v in res_variables] @@ -1008,11 +1051,11 @@ def visit_Compare(self, comp: ast.Compare): for i, op, next_operand in it: next_res, next_expl = self.visit(next_operand) if isinstance(next_operand, (ast.Compare, ast.BoolOp)): - next_expl = "({})".format(next_expl) + next_expl = f"({next_expl})" results.append(next_res) sym = BINOP_MAP[op.__class__] syms.append(ast.Str(sym)) - expl = "{} {} {}".format(left_expl, sym, next_expl) + expl = f"{left_expl} {sym} {next_expl}" expls.append(ast.Str(expl)) res_expr = ast.Compare(left_res, [op], [next_res]) self.statements.append(ast.Assign([store_names[i]], res_expr)) @@ -1026,17 +1069,19 @@ def visit_Compare(self, comp: ast.Compare): ast.Tuple(results, ast.Load()), ) if len(comp.ops) > 1: - res = ast.BoolOp(ast.And(), load_names) # type: ast.expr + 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)) -def try_makedirs(cache_dir) -> bool: - """Attempts to create the given directory and sub-directories exist, returns True if - successful or it already exists""" +def try_makedirs(cache_dir: Path) -> bool: + """Attempt to create the given directory and sub-directories exist. + + Returns True if successful or if it already exists. + """ try: - os.makedirs(fspath(cache_dir), exist_ok=True) + os.makedirs(os.fspath(cache_dir), exist_ok=True) except (FileNotFoundError, NotADirectoryError, FileExistsError): # One of the path components was not a directory: # - we're in a zip file @@ -1053,7 +1098,7 @@ def try_makedirs(cache_dir) -> bool: def get_cache_dir(file_path: Path) -> Path: - """Returns the cache directory to write .pyc files for the given .py file path""" + """Return the cache directory to write .pyc files for the given .py file path.""" if sys.version_info >= (3, 8) and sys.pycache_prefix: # given: # prefix = '/tmp/pycs' diff --git a/src/_pytest/assertion/truncate.py b/src/_pytest/assertion/truncate.py index d97b05b441e..5ba9ddca75a 100644 --- a/src/_pytest/assertion/truncate.py +++ b/src/_pytest/assertion/truncate.py @@ -1,42 +1,47 @@ -""" -Utilities for truncating assertion output. +"""Utilities for truncating assertion output. Current default behaviour is to truncate assertion explanations at ~8 terminal lines, unless running in "-vv" mode or running on CI. """ import os +from typing import List +from typing import Optional + +from _pytest.nodes import Item + DEFAULT_MAX_LINES = 8 DEFAULT_MAX_CHARS = 8 * 80 USAGE_MSG = "use '-vv' to show" -def truncate_if_required(explanation, item, max_length=None): - """ - Truncate this assertion explanation if the given test item is eligible. - """ +def truncate_if_required( + explanation: List[str], item: Item, max_length: Optional[int] = None +) -> List[str]: + """Truncate this assertion explanation if the given test item is eligible.""" if _should_truncate_item(item): return _truncate_explanation(explanation) return explanation -def _should_truncate_item(item): - """ - Whether or not this test item is eligible for truncation. - """ +def _should_truncate_item(item: Item) -> bool: + """Whether or not this test item is eligible for truncation.""" verbose = item.config.option.verbose return verbose < 2 and not _running_on_ci() -def _running_on_ci(): +def _running_on_ci() -> bool: """Check if we're currently running on a CI system.""" env_vars = ["CI", "BUILD_NUMBER"] return any(var in os.environ for var in env_vars) -def _truncate_explanation(input_lines, max_lines=None, max_chars=None): - """ - Truncate given list of strings that makes up the assertion explanation. +def _truncate_explanation( + input_lines: List[str], + max_lines: Optional[int] = None, + max_chars: Optional[int] = None, +) -> List[str]: + """Truncate given list of strings that makes up the assertion explanation. Truncates to either 8 lines, or 640 characters - whichever the input reaches first. The remaining lines will be replaced by a usage message. @@ -65,15 +70,15 @@ def _truncate_explanation(input_lines, max_lines=None, max_chars=None): truncated_line_count += 1 # Account for the part-truncated final line msg = "...Full output truncated" if truncated_line_count == 1: - msg += " ({} line hidden)".format(truncated_line_count) + msg += f" ({truncated_line_count} line hidden)" else: - msg += " ({} lines hidden)".format(truncated_line_count) - msg += ", {}".format(USAGE_MSG) + msg += f" ({truncated_line_count} lines hidden)" + msg += f", {USAGE_MSG}" truncated_explanation.extend(["", str(msg)]) return truncated_explanation -def _truncate_by_char_count(input_lines, max_chars): +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 diff --git a/src/_pytest/assertion/util.py b/src/_pytest/assertion/util.py index 7d525aa4c42..da1ffd15e37 100644 --- a/src/_pytest/assertion/util.py +++ b/src/_pytest/assertion/util.py @@ -1,4 +1,4 @@ -"""Utilities for assertion debugging""" +"""Utilities for assertion debugging.""" import collections.abc import pprint from typing import AbstractSet @@ -9,28 +9,26 @@ from typing import Mapping from typing import Optional from typing import Sequence -from typing import Tuple import _pytest._code from _pytest import outcomes from _pytest._io.saferepr import _pformat_dispatch from _pytest._io.saferepr import safeformat from _pytest._io.saferepr import saferepr -from _pytest.compat import ATTRS_EQ_FIELD # The _reprcompare attribute on the util module is used by the new assertion # interpretation code and assertion rewriter to detect this plugin was # loaded and in turn call the hooks defined here as part of the # DebugInterpreter. -_reprcompare = None # type: Optional[Callable[[str, object, object], Optional[str]]] +_reprcompare: Optional[Callable[[str, object, object], Optional[str]]] = None # Works similarly as _reprcompare attribute. Is populated with the hook call # when pytest_runtest_setup is called. -_assertion_pass = None # type: Optional[Callable[[int, str, str], None]] +_assertion_pass: Optional[Callable[[int, str, str], None]] = None def format_explanation(explanation: str) -> str: - """This formats an explanation + r"""Format an explanation. Normally all embedded newlines are escaped, however there are three exceptions: \n{, \n} and \n~. The first two are intended @@ -45,7 +43,7 @@ def format_explanation(explanation: str) -> str: def _split_explanation(explanation: str) -> List[str]: - """Return a list of individual lines in the explanation + r"""Return a list of individual lines in the explanation. This will return a list of lines split on '\n{', '\n}' and '\n~'. Any other newlines will be escaped and appear in the line as the @@ -62,11 +60,11 @@ def _split_explanation(explanation: str) -> List[str]: def _format_lines(lines: Sequence[str]) -> List[str]: - """Format the individual lines + """Format the individual lines. - This will replace the '{', '}' and '~' characters of our mini - formatting language with the proper 'where ...', 'and ...' and ' + - ...' text, taking care of indentation along the way. + This will replace the '{', '}' and '~' characters of our mini formatting + language with the proper 'where ...', 'and ...' and ' + ...' text, taking + care of indentation along the way. Return a list of formatted lines. """ @@ -112,6 +110,10 @@ def isset(x: Any) -> bool: return isinstance(x, (set, frozenset)) +def isnamedtuple(obj: Any) -> bool: + return isinstance(obj, tuple) and getattr(obj, "_fields", None) is not None + + def isdatacls(obj: Any) -> bool: return getattr(obj, "__dataclass_fields__", None) is not None @@ -129,7 +131,7 @@ def isiterable(obj: Any) -> bool: def assertrepr_compare(config, op: str, left: Any, right: Any) -> Optional[List[str]]: - """Return specialised explanations for some operators/operands""" + """Return specialised explanations for some operators/operands.""" verbose = config.getoption("verbose") if verbose > 1: left_repr = safeformat(left) @@ -143,31 +145,12 @@ def assertrepr_compare(config, op: str, left: Any, right: Any) -> Optional[List[ left_repr = saferepr(left, maxsize=maxsize) right_repr = saferepr(right, maxsize=maxsize) - summary = "{} {} {}".format(left_repr, op, right_repr) + summary = f"{left_repr} {op} {right_repr}" explanation = None try: if op == "==": - if istext(left) and istext(right): - explanation = _diff_text(left, right, verbose) - else: - if issequence(left) and issequence(right): - explanation = _compare_eq_sequence(left, right, verbose) - elif isset(left) and isset(right): - explanation = _compare_eq_set(left, right, verbose) - elif isdict(left) and isdict(right): - explanation = _compare_eq_dict(left, right, verbose) - elif type(left) == type(right) and (isdatacls(left) or isattrs(left)): - type_fn = (isdatacls, isattrs) - explanation = _compare_eq_cls(left, right, verbose, type_fn) - elif verbose > 0: - explanation = _compare_eq_verbose(left, right) - if isiterable(left) and isiterable(right): - expl = _compare_eq_iterable(left, right, verbose) - if explanation is not None: - explanation.extend(expl) - else: - explanation = expl + explanation = _compare_eq_any(left, right, verbose) elif op == "not in": if istext(left) and istext(right): explanation = _notin_text(left, right, verbose) @@ -187,6 +170,33 @@ def assertrepr_compare(config, op: str, left: Any, right: Any) -> Optional[List[ return [summary] + explanation +def _compare_eq_any(left: Any, right: Any, verbose: int = 0) -> List[str]: + explanation = [] + if istext(left) and istext(right): + explanation = _diff_text(left, right, verbose) + else: + if type(left) == type(right) and ( + isdatacls(left) or isattrs(left) or isnamedtuple(left) + ): + # Note: unlike dataclasses/attrs, namedtuples compare only the + # field values, not the type or field names. But this branch + # intentionally only handles the same-type case, which was often + # used in older code bases before dataclasses/attrs were available. + explanation = _compare_eq_cls(left, right, verbose) + elif issequence(left) and issequence(right): + explanation = _compare_eq_sequence(left, right, verbose) + elif isset(left) and isset(right): + explanation = _compare_eq_set(left, right, verbose) + elif isdict(left) and isdict(right): + explanation = _compare_eq_dict(left, right, verbose) + elif verbose > 0: + explanation = _compare_eq_verbose(left, right) + if isiterable(left) and isiterable(right): + expl = _compare_eq_iterable(left, right, verbose) + explanation.extend(expl) + return explanation + + def _diff_text(left: str, right: str, verbose: int = 0) -> List[str]: """Return the explanation for the diff between text. @@ -195,7 +205,7 @@ def _diff_text(left: str, right: str, verbose: int = 0) -> List[str]: """ from difflib import ndiff - explanation = [] # type: List[str] + explanation: List[str] = [] if verbose < 1: i = 0 # just in case left or right has zero length @@ -240,7 +250,7 @@ def _compare_eq_verbose(left: Any, right: Any) -> List[str]: left_lines = repr(left).splitlines(keepends) right_lines = repr(right).splitlines(keepends) - explanation = [] # type: List[str] + explanation: List[str] = [] explanation += ["+" + line for line in left_lines] explanation += ["-" + line for line in right_lines] @@ -294,7 +304,7 @@ def _compare_eq_sequence( left: Sequence[Any], right: Sequence[Any], verbose: int = 0 ) -> List[str]: comparing_bytes = isinstance(left, bytes) and isinstance(right, bytes) - explanation = [] # type: List[str] + explanation: List[str] = [] len_left = len(left) len_right = len(right) for i in range(min(len_left, len_right)): @@ -314,9 +324,7 @@ def _compare_eq_sequence( left_value = left[i] right_value = right[i] - explanation += [ - "At index {} diff: {!r} != {!r}".format(i, left_value, right_value) - ] + explanation += [f"At index {i} diff: {left_value!r} != {right_value!r}"] break if comparing_bytes: @@ -336,9 +344,7 @@ def _compare_eq_sequence( extra = saferepr(right[len_left]) if len_diff == 1: - explanation += [ - "{} contains one more item: {}".format(dir_with_more, extra) - ] + explanation += [f"{dir_with_more} contains one more item: {extra}"] else: explanation += [ "%s contains %d more items, first extra item: %s" @@ -367,7 +373,7 @@ def _compare_eq_set( def _compare_eq_dict( left: Mapping[Any, Any], right: Mapping[Any, Any], verbose: int = 0 ) -> List[str]: - explanation = [] # type: List[str] + explanation: List[str] = [] set_left = set(left) set_right = set(right) common = set_left.intersection(set_right) @@ -405,22 +411,19 @@ def _compare_eq_dict( return explanation -def _compare_eq_cls( - left: Any, - right: Any, - verbose: int, - type_fns: Tuple[Callable[[Any], bool], Callable[[Any], bool]], -) -> List[str]: - isdatacls, isattrs = type_fns +def _compare_eq_cls(left: Any, right: Any, verbose: int) -> List[str]: if isdatacls(left): all_fields = left.__dataclass_fields__ fields_to_check = [field for field, info in all_fields.items() if info.compare] elif isattrs(left): all_fields = left.__attrs_attrs__ - fields_to_check = [ - field.name for field in all_fields if getattr(field, ATTRS_EQ_FIELD) - ] + fields_to_check = [field.name for field in all_fields if getattr(field, "eq")] + elif isnamedtuple(left): + fields_to_check = left._fields + else: + assert False + indent = " " same = [] diff = [] for field in fields_to_check: @@ -430,6 +433,8 @@ def _compare_eq_cls( diff.append(field) explanation = [] + if same or diff: + explanation += [""] if same and verbose < 2: explanation.append("Omitting %s identical items, use -vv to show" % len(same)) elif same: @@ -437,9 +442,18 @@ def _compare_eq_cls( explanation += pprint.pformat(same).splitlines() if diff: explanation += ["Differing attributes:"] + explanation += pprint.pformat(diff).splitlines() for field in diff: + field_left = getattr(left, field) + field_right = getattr(right, field) + explanation += [ + "", + "Drill down into differing attribute %s:" % field, + ("%s%s: %r != %r") % (indent, field, field_left, field_right), + ] explanation += [ - ("%s: %r != %r") % (field, getattr(left, field), getattr(right, field)) + indent + line + for line in _compare_eq_any(field_left, field_right, verbose) ] return explanation diff --git a/src/_pytest/cacheprovider.py b/src/_pytest/cacheprovider.py index a0f486089ff..03acd03109e 100755 --- a/src/_pytest/cacheprovider.py +++ b/src/_pytest/cacheprovider.py @@ -1,31 +1,38 @@ -""" -merged implementation of the cache provider - -the name cache was not chosen to ensure pluggy automatically -ignores the external pytest-cache -""" +"""Implementation of the cache provider.""" +# This plugin was not named "cache" to avoid conflicts with the external +# pytest-cache version. import json import os -from collections import OrderedDict +from pathlib import Path from typing import Dict from typing import Generator +from typing import Iterable from typing import List from typing import Optional from typing import Set +from typing import Union import attr import py -import pytest -from .pathlib import Path from .pathlib import resolve_from_str from .pathlib import rm_rf from .reports import CollectReport from _pytest import nodes from _pytest._io import TerminalWriter +from _pytest.compat import final from _pytest.config import Config +from _pytest.config import ExitCode +from _pytest.config import hookimpl +from _pytest.config.argparsing import Parser +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.python import Package +from _pytest.reports import TestReport + README_CONTENT = """\ # pytest cache directory # @@ -35,7 +42,7 @@ **Do not** commit this to version control. -See [the docs](https://docs.pytest.org/en/latest/cache.html) for more information. +See [the docs](https://docs.pytest.org/en/stable/cache.html) for more information. """ CACHEDIR_TAG_CONTENT = b"""\ @@ -46,10 +53,11 @@ """ -@attr.s +@final +@attr.s(init=False) class Cache: - _cachedir = attr.ib(repr=False) - _config = attr.ib(repr=False) + _cachedir = attr.ib(type=Path, repr=False) + _config = attr.ib(type=Config, repr=False) # sub-directory under cache-dir for directories created by "makedir" _CACHE_PREFIX_DIRS = "d" @@ -57,26 +65,52 @@ class Cache: # sub-directory under cache-dir for values created by "set" _CACHE_PREFIX_VALUES = "v" + def __init__( + self, cachedir: Path, config: Config, *, _ispytest: bool = False + ) -> None: + check_ispytest(_ispytest) + self._cachedir = cachedir + self._config = config + @classmethod - def for_config(cls, config): - cachedir = cls.cache_dir_from_config(config) + def for_config(cls, config: Config, *, _ispytest: bool = False) -> "Cache": + """Create the Cache instance for a Config. + + :meta private: + """ + check_ispytest(_ispytest) + cachedir = cls.cache_dir_from_config(config, _ispytest=True) if config.getoption("cacheclear") and cachedir.is_dir(): - cls.clear_cache(cachedir) - return cls(cachedir, config) + cls.clear_cache(cachedir, _ispytest=True) + return cls(cachedir, config, _ispytest=True) @classmethod - def clear_cache(cls, cachedir: Path): - """Clears the sub-directories used to hold cached directories and values.""" + def clear_cache(cls, cachedir: Path, _ispytest: bool = False) -> None: + """Clear the sub-directories used to hold cached directories and values. + + :meta private: + """ + check_ispytest(_ispytest) for prefix in (cls._CACHE_PREFIX_DIRS, cls._CACHE_PREFIX_VALUES): d = cachedir / prefix if d.is_dir(): rm_rf(d) @staticmethod - def cache_dir_from_config(config): - return resolve_from_str(config.getini("cache_dir"), config.rootdir) + def cache_dir_from_config(config: Config, *, _ispytest: bool = False) -> Path: + """Get the path to the cache directory for a Config. + + :meta private: + """ + check_ispytest(_ispytest) + return resolve_from_str(config.getini("cache_dir"), config.rootpath) + + def warn(self, fmt: str, *, _ispytest: bool = False, **args: object) -> None: + """Issue a cache warning. - def warn(self, fmt, **args): + :meta private: + """ + check_ispytest(_ispytest) import warnings from _pytest.warning_types import PytestCacheWarning @@ -86,52 +120,56 @@ def warn(self, fmt, **args): stacklevel=3, ) - def makedir(self, name): - """ return a directory path object with the given name. If the - directory does not yet exist, it will be created. You can use it - to manage files likes e. g. store/retrieve database - dumps across test sessions. + def makedir(self, name: str) -> py.path.local: + """Return a directory path object with the given name. + + If the directory does not yet exist, it will be created. You can use + it to manage files to e.g. store/retrieve database dumps across test + sessions. - :param name: must be a string not containing a ``/`` separator. - Make sure the name contains your plugin or application - identifiers to prevent clashes with other cache users. + :param name: + Must be a string not containing a ``/`` separator. + Make sure the name contains your plugin or application + identifiers to prevent clashes with other cache users. """ - name = Path(name) - if len(name.parts) > 1: + path = Path(name) + if len(path.parts) > 1: raise ValueError("name is not allowed to contain path separators") - res = self._cachedir.joinpath(self._CACHE_PREFIX_DIRS, name) + res = self._cachedir.joinpath(self._CACHE_PREFIX_DIRS, path) res.mkdir(exist_ok=True, parents=True) return py.path.local(res) - def _getvaluepath(self, key): + def _getvaluepath(self, key: str) -> Path: return self._cachedir.joinpath(self._CACHE_PREFIX_VALUES, Path(key)) - def get(self, key, default): - """ return cached value for the given key. If no value - was yet cached or the value cannot be read, the specified - default is returned. + def get(self, key: str, default): + """Return the cached value for the given key. - :param key: must be a ``/`` separated value. Usually the first - name is the name of your plugin or your application. - :param default: must be provided in case of a cache-miss or - invalid cache values. + If no value was yet cached or the value cannot be read, the specified + default is returned. + :param key: + Must be a ``/`` separated value. Usually the first + name is the name of your plugin or your application. + :param default: + The value to return in case of a cache-miss or invalid cache value. """ path = self._getvaluepath(key) try: with path.open("r") as f: return json.load(f) - except (ValueError, IOError, OSError): + except (ValueError, OSError): return default - def set(self, key, value): - """ save value for the given key. + def set(self, key: str, value: object) -> None: + """Save value for the given key. - :param key: must be a ``/`` separated value. Usually the first - name is the name of your plugin or your application. - :param value: must be of any combination of basic - python types, including nested types - like e. g. lists of dictionaries. + :param key: + Must be a ``/`` separated value. Usually the first + name is the name of your plugin or your application. + :param value: + Must be of any combination of basic python types, + including nested types like lists of dictionaries. """ path = self._getvaluepath(key) try: @@ -140,21 +178,21 @@ def set(self, key, value): else: cache_dir_exists_already = self._cachedir.exists() path.parent.mkdir(exist_ok=True, parents=True) - except (IOError, OSError): - self.warn("could not create cache path {path}", path=path) + except OSError: + self.warn("could not create cache path {path}", path=path, _ispytest=True) return if not cache_dir_exists_already: self._ensure_supporting_files() data = json.dumps(value, indent=2, sort_keys=True) try: f = path.open("w") - except (IOError, OSError): - self.warn("cache could not write path {path}", path=path) + except OSError: + self.warn("cache could not write path {path}", path=path, _ispytest=True) else: with f: f.write(data) - def _ensure_supporting_files(self): + def _ensure_supporting_files(self) -> None: """Create supporting files in the cache dir that are not really part of the cache.""" readme_path = self._cachedir / "README.md" readme_path.write_text(README_CONTENT) @@ -168,52 +206,65 @@ def _ensure_supporting_files(self): class LFPluginCollWrapper: - def __init__(self, lfplugin: "LFPlugin"): + def __init__(self, lfplugin: "LFPlugin") -> None: self.lfplugin = lfplugin self._collected_at_least_one_failure = False - @pytest.hookimpl(hookwrapper=True) - def pytest_make_collect_report(self, collector) -> Generator: + @hookimpl(hookwrapper=True) + def pytest_make_collect_report(self, collector: nodes.Collector): if isinstance(collector, Session): out = yield - res = out.get_result() # type: CollectReport + res: CollectReport = out.get_result() # Sort any lf-paths to the beginning. lf_paths = self.lfplugin._last_failed_paths res.result = sorted( res.result, key=lambda x: 0 if Path(str(x.fspath)) in lf_paths else 1, ) - out.force_result(res) return elif isinstance(collector, Module): if Path(str(collector.fspath)) in self.lfplugin._last_failed_paths: out = yield res = out.get_result() - - filtered_result = [ - x for x in res.result if x.nodeid in self.lfplugin.lastfailed + result = res.result + lastfailed = self.lfplugin.lastfailed + + # Only filter with known failures. + if not self._collected_at_least_one_failure: + if not any(x.nodeid in lastfailed for x in result): + return + self.lfplugin.config.pluginmanager.register( + LFPluginCollSkipfiles(self.lfplugin), "lfplugin-collskip" + ) + self._collected_at_least_one_failure = True + + session = collector.session + result[:] = [ + x + for x in result + if x.nodeid in lastfailed + # Include any passed arguments (not trivial to filter). + or session.isinitpath(x.fspath) + # Keep all sub-collectors. + or isinstance(x, nodes.Collector) ] - if filtered_result: - res.result = filtered_result - out.force_result(res) - - if not self._collected_at_least_one_failure: - self.lfplugin.config.pluginmanager.register( - LFPluginCollSkipfiles(self.lfplugin), "lfplugin-collskip" - ) - self._collected_at_least_one_failure = True - return res + return yield class LFPluginCollSkipfiles: - def __init__(self, lfplugin: "LFPlugin"): + def __init__(self, lfplugin: "LFPlugin") -> None: self.lfplugin = lfplugin - @pytest.hookimpl - def pytest_make_collect_report(self, collector) -> Optional[CollectReport]: - if isinstance(collector, Module): + @hookimpl + 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): if Path(str(collector.fspath)) not in self.lfplugin._last_failed_paths: self.lfplugin._skipped_files += 1 @@ -224,18 +275,16 @@ def pytest_make_collect_report(self, collector) -> Optional[CollectReport]: class LFPlugin: - """ Plugin which implements the --lf (run last-failing) option """ + """Plugin which implements the --lf (run last-failing) option.""" def __init__(self, config: Config) -> None: self.config = config active_keys = "lf", "failedfirst" self.active = any(config.getoption(key) for key in active_keys) assert config.cache - self.lastfailed = config.cache.get( - "cache/lastfailed", {} - ) # type: Dict[str, bool] - self._previously_failed_count = None - self._report_status = None + self.lastfailed: Dict[str, bool] = config.cache.get("cache/lastfailed", {}) + self._previously_failed_count: Optional[int] = None + self._report_status: Optional[str] = None self._skipped_files = 0 # count skipped files during collection due to --lf if config.getoption("lf"): @@ -245,22 +294,23 @@ def __init__(self, config: Config) -> None: ) def get_last_failed_paths(self) -> Set[Path]: - """Returns a set with all Paths()s of the previously failed nodeids.""" - rootpath = Path(str(self.config.rootdir)) + """Return a set with all Paths()s of the previously failed nodeids.""" + rootpath = self.config.rootpath result = {rootpath / nodeid.split("::")[0] for nodeid in self.lastfailed} return {x for x in result if x.exists()} - def pytest_report_collectionfinish(self): + def pytest_report_collectionfinish(self) -> Optional[str]: if self.active and self.config.getoption("verbose") >= 0: return "run-last-failure: %s" % self._report_status + return None - def pytest_runtest_logreport(self, report): + def pytest_runtest_logreport(self, report: TestReport) -> None: if (report.when == "call" and report.passed) or report.skipped: self.lastfailed.pop(report.nodeid, None) elif report.failed: self.lastfailed[report.nodeid] = True - def pytest_collectreport(self, report): + def pytest_collectreport(self, report: CollectReport) -> None: passed = report.outcome in ("passed", "skipped") if passed: if report.nodeid in self.lastfailed: @@ -269,7 +319,12 @@ def pytest_collectreport(self, report): else: self.lastfailed[report.nodeid] = True - def pytest_collection_modifyitems(self, session, config, items): + @hookimpl(hookwrapper=True, tryfirst=True) + def pytest_collection_modifyitems( + self, config: Config, items: List[nodes.Item] + ) -> Generator[None, None, None]: + yield + if not self.active: return @@ -316,30 +371,35 @@ def pytest_collection_modifyitems(self, session, config, items): else: self._report_status += "not deselecting items." - def pytest_sessionfinish(self, session): + def pytest_sessionfinish(self, session: Session) -> None: config = self.config - if config.getoption("cacheshow") or hasattr(config, "slaveinput"): + if config.getoption("cacheshow") or hasattr(config, "workerinput"): return + assert config.cache is not None saved_lastfailed = config.cache.get("cache/lastfailed", {}) if saved_lastfailed != self.lastfailed: config.cache.set("cache/lastfailed", self.lastfailed) class NFPlugin: - """ Plugin which implements the --nf (run new-first) option """ + """Plugin which implements the --nf (run new-first) option.""" - def __init__(self, config): + def __init__(self, config: Config) -> None: self.config = config self.active = config.option.newfirst - self.cached_nodeids = config.cache.get("cache/nodeids", []) + assert config.cache is not None + self.cached_nodeids = set(config.cache.get("cache/nodeids", [])) + @hookimpl(hookwrapper=True, tryfirst=True) def pytest_collection_modifyitems( - self, session: Session, config: Config, items: List[nodes.Item] - ) -> None: - new_items = OrderedDict() # type: OrderedDict[str, nodes.Item] + self, items: List[nodes.Item] + ) -> Generator[None, None, None]: + yield + if self.active: - other_items = OrderedDict() # type: OrderedDict[str, nodes.Item] + new_items: Dict[str, nodes.Item] = {} + other_items: Dict[str, nodes.Item] = {} for item in items: if item.nodeid not in self.cached_nodeids: new_items[item.nodeid] = item @@ -349,24 +409,26 @@ def pytest_collection_modifyitems( items[:] = self._get_increasing_order( new_items.values() ) + self._get_increasing_order(other_items.values()) + self.cached_nodeids.update(new_items) else: - for item in items: - if item.nodeid not in self.cached_nodeids: - new_items[item.nodeid] = item - self.cached_nodeids.extend(new_items) + self.cached_nodeids.update(item.nodeid for item in items) - def _get_increasing_order(self, items): - return sorted(items, key=lambda item: item.fspath.mtime(), reverse=True) + def _get_increasing_order(self, items: Iterable[nodes.Item]) -> List[nodes.Item]: + return sorted(items, key=lambda item: item.fspath.mtime(), reverse=True) # type: ignore[no-any-return] - def pytest_sessionfinish(self, session): + def pytest_sessionfinish(self) -> None: config = self.config - if config.getoption("cacheshow") or hasattr(config, "slaveinput"): + if config.getoption("cacheshow") or hasattr(config, "workerinput"): return - config.cache.set("cache/nodeids", self.cached_nodeids) + if config.getoption("collectonly"): + return + + assert config.cache is not None + config.cache.set("cache/nodeids", sorted(self.cached_nodeids)) -def pytest_addoption(parser): +def pytest_addoption(parser: Parser) -> None: group = parser.getgroup("general") group.addoption( "--lf", @@ -381,9 +443,9 @@ def pytest_addoption(parser): "--failed-first", action="store_true", dest="failedfirst", - help="run all tests but run the last failures first. " + help="run all tests, but run the last failures first.\n" "This may re-order tests and thus lead to " - "repeated fixture setup/teardown", + "repeated fixture setup/teardown.", ) group.addoption( "--nf", @@ -424,53 +486,58 @@ def pytest_addoption(parser): ) -def pytest_cmdline_main(config): +def pytest_cmdline_main(config: Config) -> Optional[Union[int, ExitCode]]: if config.option.cacheshow: from _pytest.main import wrap_session return wrap_session(config, cacheshow) + return None -@pytest.hookimpl(tryfirst=True) +@hookimpl(tryfirst=True) def pytest_configure(config: Config) -> None: - config.cache = Cache.for_config(config) + config.cache = Cache.for_config(config, _ispytest=True) config.pluginmanager.register(LFPlugin(config), "lfplugin") config.pluginmanager.register(NFPlugin(config), "nfplugin") -@pytest.fixture -def cache(request): - """ - Return a cache object that can persist state between testing sessions. +@fixture +def cache(request: FixtureRequest) -> Cache: + """Return a cache object that can persist state between testing sessions. cache.get(key, default) cache.set(key, value) - Keys must be a ``/`` separated value, where the first part is usually the + Keys must be ``/`` separated strings, where the first part is usually the name of your plugin or application to avoid clashes with other cache users. Values can be any object handled by the json stdlib module. """ + assert request.config.cache is not None return request.config.cache -def pytest_report_header(config): +def pytest_report_header(config: Config) -> Optional[str]: """Display cachedir with --cache-show and if non-default.""" if config.option.verbose > 0 or config.getini("cache_dir") != ".pytest_cache": + assert config.cache is not None cachedir = config.cache._cachedir # TODO: evaluate generating upward relative paths # starting with .., ../.. if sensible try: - displaypath = cachedir.relative_to(config.rootdir) + displaypath = cachedir.relative_to(config.rootpath) except ValueError: displaypath = cachedir - return "cachedir: {}".format(displaypath) + return f"cachedir: {displaypath}" + return None -def cacheshow(config, session): +def cacheshow(config: Config, session: Session) -> int: from pprint import pformat + assert config.cache is not None + tw = TerminalWriter() tw.line("cachedir: " + str(config.cache._cachedir)) if not config.cache._cachedir.is_dir(): @@ -486,7 +553,7 @@ def cacheshow(config, session): vdir = basedir / Cache._CACHE_PREFIX_VALUES tw.sep("-", "cache values for %r" % glob) for valpath in sorted(x for x in vdir.rglob(glob) if x.is_file()): - key = valpath.relative_to(vdir) + key = str(valpath.relative_to(vdir)) val = config.cache.get(key, dummy) if val is dummy: tw.line("%s contains unreadable content, will be ignored" % key) @@ -503,6 +570,6 @@ def cacheshow(config, session): # if p.check(dir=1): # print("%s/" % p.relto(basedir)) if p.is_file(): - key = p.relative_to(basedir) - tw.line("{} is a file of length {:d}".format(key, p.stat().st_size)) + key = str(p.relative_to(basedir)) + tw.line(f"{key} is a file of length {p.stat().st_size:d}") return 0 diff --git a/src/_pytest/capture.py b/src/_pytest/capture.py index 5f29c5ca2f6..086302658cb 100644 --- a/src/_pytest/capture.py +++ b/src/_pytest/capture.py @@ -1,40 +1,45 @@ -""" -per-test stdout/stderr capturing mechanism. - -""" -import collections +"""Per-test stdout/stderr capturing mechanism.""" import contextlib +import functools import io import os import sys from io import UnsupportedOperation from tempfile import TemporaryFile -from typing import BinaryIO +from typing import Any +from typing import AnyStr from typing import Generator -from typing import Iterable +from typing import Generic +from typing import Iterator from typing import Optional +from typing import TextIO +from typing import Tuple +from typing import TYPE_CHECKING +from typing import Union -import pytest -from _pytest.compat import CaptureAndPassthroughIO -from _pytest.compat import CaptureIO -from _pytest.compat import TYPE_CHECKING +from _pytest.compat import final from _pytest.config import Config -from _pytest.fixtures import FixtureRequest +from _pytest.config import hookimpl +from _pytest.config.argparsing import Parser +from _pytest.deprecated import check_ispytest +from _pytest.fixtures import fixture +from _pytest.fixtures import SubRequest +from _pytest.nodes import Collector +from _pytest.nodes import File +from _pytest.nodes import Item if TYPE_CHECKING: from typing_extensions import Literal _CaptureMethod = Literal["fd", "sys", "no", "tee-sys"] -patchsysdict = {0: "stdin", 1: "stdout", 2: "stderr"} - -def pytest_addoption(parser): +def pytest_addoption(parser: Parser) -> None: group = parser.getgroup("general") group._addoption( "--capture", action="store", - default="fd" if hasattr(os, "dup") else "sys", + default="fd", metavar="method", choices=["fd", "sys", "no", "tee-sys"], help="per-test capturing method: one of fd|sys|no|tee-sys.", @@ -48,7 +53,102 @@ def pytest_addoption(parser): ) -@pytest.hookimpl(hookwrapper=True) +def _colorama_workaround() -> None: + """Ensure colorama is imported so that it attaches to the correct stdio + handles on Windows. + + colorama uses the terminal on import time. So if something does the + first import of colorama while I/O capture is active, colorama will + fail in various ways. + """ + if sys.platform.startswith("win32"): + try: + import colorama # noqa: F401 + except ImportError: + pass + + +def _readline_workaround() -> None: + """Ensure readline is imported so that it attaches to the correct stdio + handles on Windows. + + Pdb uses readline support where available--when not running from the Python + prompt, the readline module is not imported until running the pdb REPL. If + running pytest with the --pdb option this means the readline module is not + imported until after I/O capture has been started. + + This is a problem for pyreadline, which is often used to implement readline + support on Windows, as it does not attach to the correct handles for stdout + and/or stdin if they have been redirected by the FDCapture mechanism. This + workaround ensures that readline is imported before I/O capture is setup so + that it can attach to the actual stdin/out for the console. + + See https://github.com/pytest-dev/pytest/pull/1281. + """ + if sys.platform.startswith("win32"): + try: + import readline # noqa: F401 + except ImportError: + pass + + +def _py36_windowsconsoleio_workaround(stream: TextIO) -> None: + """Workaround for Windows Unicode console handling on Python>=3.6. + + Python 3.6 implemented Unicode console handling for Windows. This works + by reading/writing to the raw console handle using + ``{Read,Write}ConsoleW``. + + The problem is that we are going to ``dup2`` over the stdio file + descriptors when doing ``FDCapture`` and this will ``CloseHandle`` the + handles used by Python to write to the console. Though there is still some + weirdness and the console handle seems to only be closed randomly and not + on the first call to ``CloseHandle``, or maybe it gets reopened with the + same handle value when we suspend capturing. + + The workaround in this case will reopen stdio with a different fd which + also means a different handle by replicating the logic in + "Py_lifecycle.c:initstdio/create_stdio". + + :param stream: + In practice ``sys.stdout`` or ``sys.stderr``, but given + here as parameter for unittesting purposes. + + See https://github.com/pytest-dev/py/issues/103. + """ + if not sys.platform.startswith("win32") or hasattr(sys, "pypy_version_info"): + return + + # Bail out if ``stream`` doesn't seem like a proper ``io`` stream (#2666). + if not hasattr(stream, "buffer"): # type: ignore[unreachable] + return + + buffered = hasattr(stream.buffer, "raw") + raw_stdout = stream.buffer.raw if buffered else stream.buffer # type: ignore[attr-defined] + + if not isinstance(raw_stdout, io._WindowsConsoleIO): # type: ignore[attr-defined] + return + + def _reopen_stdio(f, mode): + if not buffered and mode[0] == "w": + buffering = 0 + else: + buffering = -1 + + return io.TextIOWrapper( + open(os.dup(f.fileno()), mode, buffering), # type: ignore[arg-type] + f.encoding, + f.errors, + f.newlines, + f.line_buffering, + ) + + sys.stdin = _reopen_stdio(sys.stdin, "rb") + sys.stdout = _reopen_stdio(sys.stdout, "wb") + sys.stderr = _reopen_stdio(sys.stderr, "wb") + + +@hookimpl(hookwrapper=True) def pytest_load_initial_conftests(early_config: Config): ns = early_config.known_args_namespace if ns.capture == "fd": @@ -59,10 +159,10 @@ def pytest_load_initial_conftests(early_config: Config): capman = CaptureManager(ns.capture) pluginmanager.register(capman, "capturemanager") - # make sure that capturemanager is properly reset at final shutdown + # Make sure that capturemanager is properly reset at final shutdown. early_config.add_cleanup(capman.stop_global_capturing) - # finally trigger conftest loading but while capturing (issue93) + # Finally trigger conftest loading but while capturing (issue #93). capman.start_global_capturing() outcome = yield capman.suspend_global_capture() @@ -72,395 +172,394 @@ def pytest_load_initial_conftests(early_config: Config): sys.stderr.write(err) -def _get_multicapture(method: "_CaptureMethod") -> "MultiCapture": - if method == "fd": - return MultiCapture(out=True, err=True, Capture=FDCapture) - elif method == "sys": - return MultiCapture(out=True, err=True, Capture=SysCapture) - elif method == "no": - return MultiCapture(out=False, err=False, in_=False) - elif method == "tee-sys": - return MultiCapture(out=True, err=True, in_=False, Capture=TeeSysCapture) - raise ValueError("unknown capturing method: {!r}".format(method)) - +# IO Helpers. -class CaptureManager: - """ - Capture plugin, manages that the appropriate capture method is enabled/disabled during collection and each - test phase (setup, call, teardown). After each of those points, the captured output is obtained and - attached to the collection/runtest report. - There are two levels of capture: - * global: which is enabled by default and can be suppressed by the ``-s`` option. This is always enabled/disabled - during collection and each test phase. - * fixture: when a test function or one of its fixture depend on the ``capsys`` or ``capfd`` fixtures. In this - case special handling is needed to ensure the fixtures take precedence over the global capture. - """ +class EncodedFile(io.TextIOWrapper): + __slots__ = () - def __init__(self, method: "_CaptureMethod") -> None: - self._method = method - self._global_capturing = None - self._capture_fixture = None # type: Optional[CaptureFixture] + @property + def name(self) -> str: + # Ensure that file.name is a string. Workaround for a Python bug + # fixed in >=3.7.4: https://bugs.python.org/issue36015 + return repr(self.buffer) - def __repr__(self): - return "".format( - self._method, self._global_capturing, self._capture_fixture - ) + @property + def mode(self) -> str: + # TextIOWrapper doesn't expose a mode, but at least some of our + # tests check it. + return self.buffer.mode.replace("b", "") - def is_capturing(self): - if self.is_globally_capturing(): - return "global" - if self._capture_fixture: - return "fixture %s" % self._capture_fixture.request.fixturename - return False - # Global capturing control +class CaptureIO(io.TextIOWrapper): + def __init__(self) -> None: + super().__init__(io.BytesIO(), encoding="UTF-8", newline="", write_through=True) - def is_globally_capturing(self): - return self._method != "no" + def getvalue(self) -> str: + assert isinstance(self.buffer, io.BytesIO) + return self.buffer.getvalue().decode("UTF-8") - def start_global_capturing(self): - assert self._global_capturing is None - self._global_capturing = _get_multicapture(self._method) - self._global_capturing.start_capturing() - def stop_global_capturing(self): - if self._global_capturing is not None: - self._global_capturing.pop_outerr_to_orig() - self._global_capturing.stop_capturing() - self._global_capturing = None +class TeeCaptureIO(CaptureIO): + def __init__(self, other: TextIO) -> None: + self._other = other + super().__init__() - def resume_global_capture(self): - # During teardown of the python process, and on rare occasions, capture - # attributes can be `None` while trying to resume global capture. - if self._global_capturing is not None: - self._global_capturing.resume_capturing() + def write(self, s: str) -> int: + super().write(s) + return self._other.write(s) - def suspend_global_capture(self, in_=False): - cap = getattr(self, "_global_capturing", None) - if cap is not None: - cap.suspend_capturing(in_=in_) - def suspend(self, in_=False): - # Need to undo local capsys-et-al if it exists before disabling global capture. - self.suspend_fixture() - self.suspend_global_capture(in_) +class DontReadFromInput: + encoding = None - def resume(self): - self.resume_global_capture() - self.resume_fixture() + def read(self, *args): + raise OSError( + "pytest: reading from stdin while output is captured! Consider using `-s`." + ) - def read_global_capture(self): - return self._global_capturing.readouterr() + readline = read + readlines = read + __next__ = read - # Fixture Control (it's just forwarding, think about removing this later) + def __iter__(self): + return self - @contextlib.contextmanager - def _capturing_for_request( - self, request: FixtureRequest - ) -> Generator["CaptureFixture", None, None]: - """ - Context manager that creates a ``CaptureFixture`` instance for the - given ``request``, ensuring there is only a single one being requested - at the same time. + def fileno(self) -> int: + raise UnsupportedOperation("redirected stdin is pseudofile, has no fileno()") - This is used as a helper with ``capsys``, ``capfd`` etc. - """ - if self._capture_fixture: - other_name = next( - k - for k, v in map_fixname_class.items() - if v is self._capture_fixture.captureclass - ) - raise request.raiseerror( - "cannot use {} and {} at the same time".format( - request.fixturename, other_name - ) - ) - capture_class = map_fixname_class[request.fixturename] - self._capture_fixture = CaptureFixture(capture_class, request) - self.activate_fixture() - yield self._capture_fixture - self._capture_fixture.close() - self._capture_fixture = None + def isatty(self) -> bool: + return False - def activate_fixture(self): - """If the current item is using ``capsys`` or ``capfd``, activate them so they take precedence over - the global capture. - """ - if self._capture_fixture: - self._capture_fixture._start() + def close(self) -> None: + pass - def deactivate_fixture(self): - """Deactivates the ``capsys`` or ``capfd`` fixture of this item, if any.""" - if self._capture_fixture: - self._capture_fixture.close() + @property + def buffer(self): + return self - def suspend_fixture(self): - if self._capture_fixture: - self._capture_fixture._suspend() - def resume_fixture(self): - if self._capture_fixture: - self._capture_fixture._resume() +# Capture classes. - # Helper context managers - @contextlib.contextmanager - def global_and_fixture_disabled(self): - """Context manager to temporarily disable global and current fixture capturing.""" - self.suspend() - try: - yield - finally: - self.resume() +patchsysdict = {0: "stdin", 1: "stdout", 2: "stderr"} - @contextlib.contextmanager - def item_capture(self, when, item): - self.resume_global_capture() - self.activate_fixture() - try: - yield - finally: - self.deactivate_fixture() - self.suspend_global_capture(in_=False) - out, err = self.read_global_capture() - item.add_report_section(when, "stdout", out) - item.add_report_section(when, "stderr", err) +class NoCapture: + EMPTY_BUFFER = None + __init__ = start = done = suspend = resume = lambda *args: None - # Hooks - @pytest.hookimpl(hookwrapper=True) - def pytest_make_collect_report(self, collector): - if isinstance(collector, pytest.File): - self.resume_global_capture() - outcome = yield - self.suspend_global_capture() - out, err = self.read_global_capture() - rep = outcome.get_result() - if out: - rep.sections.append(("Captured stdout", out)) - if err: - rep.sections.append(("Captured stderr", err)) - else: - yield +class SysCaptureBinary: - @pytest.hookimpl(hookwrapper=True) - def pytest_runtest_setup(self, item): - with self.item_capture("setup", item): - yield + EMPTY_BUFFER = b"" - @pytest.hookimpl(hookwrapper=True) - def pytest_runtest_call(self, item): - with self.item_capture("call", item): - yield + def __init__(self, fd: int, tmpfile=None, *, tee: bool = False) -> None: + name = patchsysdict[fd] + self._old = getattr(sys, name) + self.name = name + if tmpfile is None: + if name == "stdin": + tmpfile = DontReadFromInput() + else: + tmpfile = CaptureIO() if not tee else TeeCaptureIO(self._old) + self.tmpfile = tmpfile + self._state = "initialized" - @pytest.hookimpl(hookwrapper=True) - def pytest_runtest_teardown(self, item): - with self.item_capture("teardown", item): - yield + def repr(self, class_name: str) -> str: + return "<{} {} _old={} _state={!r} tmpfile={!r}>".format( + class_name, + self.name, + hasattr(self, "_old") and repr(self._old) or "", + self._state, + self.tmpfile, + ) - @pytest.hookimpl(tryfirst=True) - def pytest_keyboard_interrupt(self, excinfo): - self.stop_global_capturing() + def __repr__(self) -> str: + return "<{} {} _old={} _state={!r} tmpfile={!r}>".format( + self.__class__.__name__, + self.name, + hasattr(self, "_old") and repr(self._old) or "", + self._state, + self.tmpfile, + ) - @pytest.hookimpl(tryfirst=True) - def pytest_internalerror(self, excinfo): - self.stop_global_capturing() + def _assert_state(self, op: str, states: Tuple[str, ...]) -> None: + assert ( + self._state in states + ), "cannot {} in state {!r}: expected one of {}".format( + op, self._state, ", ".join(states) + ) + def start(self) -> None: + self._assert_state("start", ("initialized",)) + setattr(sys, self.name, self.tmpfile) + self._state = "started" -@pytest.fixture -def capsys(request): - """Enable text capturing of writes to ``sys.stdout`` and ``sys.stderr``. + 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 - The captured output is made available via ``capsys.readouterr()`` method - calls, which return a ``(out, err)`` namedtuple. - ``out`` and ``err`` will be ``text`` objects. - """ - capman = request.config.pluginmanager.getplugin("capturemanager") - with capman._capturing_for_request(request) as fixture: - yield fixture + def done(self) -> None: + self._assert_state("done", ("initialized", "started", "suspended", "done")) + if self._state == "done": + return + setattr(sys, self.name, self._old) + del self._old + self.tmpfile.close() + self._state = "done" + def suspend(self) -> None: + self._assert_state("suspend", ("started", "suspended")) + setattr(sys, self.name, self._old) + self._state = "suspended" -@pytest.fixture -def capsysbinary(request): - """Enable bytes capturing of writes to ``sys.stdout`` and ``sys.stderr``. + def resume(self) -> None: + self._assert_state("resume", ("started", "suspended")) + if self._state == "started": + return + setattr(sys, self.name, self.tmpfile) + self._state = "started" - The captured output is made available via ``capsysbinary.readouterr()`` - method calls, which return a ``(out, err)`` namedtuple. - ``out`` and ``err`` will be ``bytes`` objects. - """ - capman = request.config.pluginmanager.getplugin("capturemanager") - with capman._capturing_for_request(request) as fixture: - yield fixture + def writeorg(self, data) -> None: + self._assert_state("writeorg", ("started", "suspended")) + self._old.flush() + self._old.buffer.write(data) + self._old.buffer.flush() -@pytest.fixture -def capfd(request): - """Enable text capturing of writes to file descriptors ``1`` and ``2``. +class SysCapture(SysCaptureBinary): + EMPTY_BUFFER = "" # type: ignore[assignment] - The captured output is made available via ``capfd.readouterr()`` method - calls, which return a ``(out, err)`` namedtuple. - ``out`` and ``err`` will be ``text`` objects. - """ - if not hasattr(os, "dup"): - pytest.skip( - "capfd fixture needs os.dup function which is not available in this system" - ) - capman = request.config.pluginmanager.getplugin("capturemanager") - with capman._capturing_for_request(request) as fixture: - yield fixture + def snap(self): + res = self.tmpfile.getvalue() + self.tmpfile.seek(0) + self.tmpfile.truncate() + return res + def writeorg(self, data): + self._assert_state("writeorg", ("started", "suspended")) + self._old.write(data) + self._old.flush() -@pytest.fixture -def capfdbinary(request): - """Enable bytes capturing of writes to file descriptors ``1`` and ``2``. - The captured output is made available via ``capfd.readouterr()`` method - calls, which return a ``(out, err)`` namedtuple. - ``out`` and ``err`` will be ``byte`` objects. +class FDCaptureBinary: + """Capture IO to/from a given OS-level file descriptor. + + snap() produces `bytes`. """ - if not hasattr(os, "dup"): - pytest.skip( - "capfdbinary fixture needs os.dup function which is not available in this system" - ) - capman = request.config.pluginmanager.getplugin("capturemanager") - with capman._capturing_for_request(request) as fixture: - yield fixture + EMPTY_BUFFER = b"" -class CaptureFixture: - """ - Object returned by :py:func:`capsys`, :py:func:`capsysbinary`, :py:func:`capfd` and :py:func:`capfdbinary` - fixtures. - """ + def __init__(self, targetfd: int) -> None: + self.targetfd = targetfd - def __init__(self, captureclass, request): - self.captureclass = captureclass - self.request = request - self._capture = None - self._captured_out = self.captureclass.EMPTY_BUFFER - self._captured_err = self.captureclass.EMPTY_BUFFER + try: + os.fstat(targetfd) + except OSError: + # FD capturing is conceptually simple -- create a temporary file, + # redirect the FD to it, redirect back when done. But when the + # target FD is invalid it throws a wrench into this loveley scheme. + # + # Tests themselves shouldn't care if the FD is valid, FD capturing + # should work regardless of external circumstances. So falling back + # to just sys capturing is not a good option. + # + # Further complications are the need to support suspend() and the + # possibility of FD reuse (e.g. the tmpfile getting the very same + # target FD). The following approach is robust, I believe. + self.targetfd_invalid: Optional[int] = os.open(os.devnull, os.O_RDWR) + os.dup2(self.targetfd_invalid, targetfd) + else: + self.targetfd_invalid = None + self.targetfd_save = os.dup(targetfd) - def _start(self): - if self._capture is None: - self._capture = MultiCapture( - out=True, err=True, in_=False, Capture=self.captureclass + if targetfd == 0: + self.tmpfile = open(os.devnull) + self.syscapture = SysCapture(targetfd) + else: + self.tmpfile = EncodedFile( + TemporaryFile(buffering=0), + encoding="utf-8", + errors="replace", + newline="", + write_through=True, ) - self._capture.start_capturing() + if targetfd in patchsysdict: + self.syscapture = SysCapture(targetfd, self.tmpfile) + else: + self.syscapture = NoCapture() - def close(self): - if self._capture is not None: - out, err = self._capture.pop_outerr_to_orig() - self._captured_out += out - self._captured_err += err - self._capture.stop_capturing() - self._capture = None + self._state = "initialized" - def readouterr(self): - """Read and return the captured output so far, resetting the internal buffer. + def __repr__(self) -> str: + return "<{} {} oldfd={} _state={!r} tmpfile={!r}>".format( + self.__class__.__name__, + self.targetfd, + self.targetfd_save, + self._state, + self.tmpfile, + ) - :return: captured content as a namedtuple with ``out`` and ``err`` string attributes - """ - captured_out, captured_err = self._captured_out, self._captured_err - if self._capture is not None: - out, err = self._capture.readouterr() - captured_out += out - captured_err += err - self._captured_out = self.captureclass.EMPTY_BUFFER - self._captured_err = self.captureclass.EMPTY_BUFFER - return CaptureResult(captured_out, captured_err) + def _assert_state(self, op: str, states: Tuple[str, ...]) -> None: + assert ( + self._state in states + ), "cannot {} in state {!r}: expected one of {}".format( + op, self._state, ", ".join(states) + ) - def _suspend(self): - """Suspends this fixture's own capturing temporarily.""" - if self._capture is not None: - self._capture.suspend_capturing() + def start(self) -> None: + """Start capturing on targetfd using memorized tmpfile.""" + self._assert_state("start", ("initialized",)) + os.dup2(self.tmpfile.fileno(), self.targetfd) + self.syscapture.start() + self._state = "started" - def _resume(self): - """Resumes this fixture's own capturing temporarily.""" - if self._capture is not None: - self._capture.resume_capturing() + 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 - @contextlib.contextmanager - def disabled(self): - """Temporarily disables capture while inside the 'with' block.""" - capmanager = self.request.config.pluginmanager.getplugin("capturemanager") - with capmanager.global_and_fixture_disabled(): - yield + def done(self) -> None: + """Stop capturing, restore streams, return original capture file, + seeked to position zero.""" + self._assert_state("done", ("initialized", "started", "suspended", "done")) + if self._state == "done": + return + os.dup2(self.targetfd_save, self.targetfd) + os.close(self.targetfd_save) + if self.targetfd_invalid is not None: + if self.targetfd_invalid != self.targetfd: + os.close(self.targetfd) + os.close(self.targetfd_invalid) + self.syscapture.done() + self.tmpfile.close() + self._state = "done" + + def suspend(self) -> None: + self._assert_state("suspend", ("started", "suspended")) + if self._state == "suspended": + return + self.syscapture.suspend() + os.dup2(self.targetfd_save, self.targetfd) + self._state = "suspended" + + def resume(self) -> None: + self._assert_state("resume", ("started", "suspended")) + if self._state == "started": + return + self.syscapture.resume() + os.dup2(self.tmpfile.fileno(), self.targetfd) + self._state = "started" + + def writeorg(self, data): + """Write to original file descriptor.""" + self._assert_state("writeorg", ("started", "suspended")) + os.write(self.targetfd_save, data) -def safe_text_dupfile(f, mode, default_encoding="UTF8"): - """ return an open text file object that's a duplicate of f on the - FD-level if possible. +class FDCapture(FDCaptureBinary): + """Capture IO to/from a given OS-level file descriptor. + + snap() produces text. """ - encoding = getattr(f, "encoding", None) - try: - fd = f.fileno() - except Exception: - if "b" not in getattr(f, "mode", "") and hasattr(f, "encoding"): - # we seem to have a text stream, let's just use it - return f - else: - newfd = os.dup(fd) - if "b" not in mode: - mode += "b" - f = os.fdopen(newfd, mode, 0) # no buffering - return EncodedFile(f, encoding or default_encoding) - - -class EncodedFile: - errors = "strict" # possibly needed by py3 code (issue555) - - def __init__(self, buffer: BinaryIO, encoding: str) -> None: - self.buffer = buffer - self.encoding = encoding - def write(self, s: str) -> int: - if not isinstance(s, str): - raise TypeError( - "write() argument must be str, not {}".format(type(s).__name__) - ) - return self.buffer.write(s.encode(self.encoding, "replace")) + # Ignore type because it doesn't match the type in the superclass (bytes). + EMPTY_BUFFER = "" # type: ignore - def writelines(self, lines: Iterable[str]) -> None: - self.buffer.writelines(x.encode(self.encoding, "replace") for x in lines) + def snap(self): + self._assert_state("snap", ("started", "suspended")) + self.tmpfile.seek(0) + res = self.tmpfile.read() + self.tmpfile.seek(0) + self.tmpfile.truncate() + return res - @property - def name(self) -> str: - """Ensure that file.name is a string.""" - return repr(self.buffer) + def writeorg(self, data): + """Write to original file descriptor.""" + super().writeorg(data.encode("utf-8")) # XXX use encoding of original stream + + +# MultiCapture - @property - def mode(self) -> str: - return self.buffer.mode.replace("b", "") - def __getattr__(self, name): - return getattr(object.__getattribute__(self, "buffer"), name) +# 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`.""" + __slots__ = ("out", "err") -CaptureResult = collections.namedtuple("CaptureResult", ["out", "err"]) + def __init__(self, out: AnyStr, err: AnyStr) -> None: + self.out: AnyStr = out + self.err: AnyStr = err + def __len__(self) -> int: + return 2 -class MultiCapture: - out = err = in_ = None + def __iter__(self) -> Iterator[AnyStr]: + return iter((self.out, self.err)) + + 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})" + + +class MultiCapture(Generic[AnyStr]): _state = None _in_suspended = False - def __init__(self, out=True, err=True, in_=True, Capture=None): - if in_: - self.in_ = Capture(0) - if out: - self.out = Capture(1) - if err: - self.err = Capture(2) + def __init__(self, in_, out, err) -> None: + self.in_ = in_ + self.out = out + self.err = err - def __repr__(self): + def __repr__(self) -> str: return "".format( self.out, self.err, self.in_, self._state, self._in_suspended, ) - def start_capturing(self): + def start_capturing(self) -> None: self._state = "started" if self.in_: self.in_.start() @@ -469,8 +568,8 @@ def start_capturing(self): if self.err: self.err.start() - def pop_outerr_to_orig(self): - """ pop current snapshot out/err capture and flush to orig streams. """ + def pop_outerr_to_orig(self) -> Tuple[AnyStr, AnyStr]: + """Pop current snapshot out/err capture and flush to orig streams.""" out, err = self.readouterr() if out: self.out.writeorg(out) @@ -478,7 +577,7 @@ def pop_outerr_to_orig(self): self.err.writeorg(err) return out, err - def suspend_capturing(self, in_=False): + def suspend_capturing(self, in_: bool = False) -> None: self._state = "suspended" if self.out: self.out.suspend() @@ -488,8 +587,8 @@ def suspend_capturing(self, in_=False): self.in_.suspend() self._in_suspended = True - def resume_capturing(self): - self._state = "resumed" + def resume_capturing(self) -> None: + self._state = "started" if self.out: self.out.resume() if self.err: @@ -498,8 +597,8 @@ def resume_capturing(self): self.in_.resume() self._in_suspended = False - def stop_capturing(self): - """ stop capturing and reset capturing streams """ + def stop_capturing(self) -> None: + """Stop capturing and reset capturing streams.""" if self._state == "stopped": raise ValueError("was already stopped") self._state = "stopped" @@ -510,7 +609,11 @@ def stop_capturing(self): if self.in_: self.in_.done() - def readouterr(self) -> CaptureResult: + def is_started(self) -> bool: + """Whether actively capturing -- not suspended or stopped.""" + return self._state == "started" + + def readouterr(self) -> CaptureResult[AnyStr]: if self.out: out = self.out.snap() else: @@ -522,324 +625,343 @@ def readouterr(self) -> CaptureResult: return CaptureResult(out, err) -class NoCapture: - EMPTY_BUFFER = None - __init__ = start = done = suspend = resume = lambda *args: None +def _get_multicapture(method: "_CaptureMethod") -> MultiCapture[str]: + if method == "fd": + return MultiCapture(in_=FDCapture(0), out=FDCapture(1), err=FDCapture(2)) + elif method == "sys": + return MultiCapture(in_=SysCapture(0), out=SysCapture(1), err=SysCapture(2)) + elif method == "no": + return MultiCapture(in_=None, out=None, err=None) + elif method == "tee-sys": + return MultiCapture( + in_=None, out=SysCapture(1, tee=True), err=SysCapture(2, tee=True) + ) + raise ValueError(f"unknown capturing method: {method!r}") -class FDCaptureBinary: - """Capture IO to/from a given os-level filedescriptor. +# CaptureManager and CaptureFixture - snap() produces `bytes` - """ - EMPTY_BUFFER = b"" - _state = None +class CaptureManager: + """The capture plugin. - def __init__(self, targetfd, tmpfile=None): - self.targetfd = targetfd - try: - self.targetfd_save = os.dup(self.targetfd) - except OSError: - self.start = lambda: None - self.done = lambda: None - else: - self.start = self._start - self.done = self._done - if targetfd == 0: - assert not tmpfile, "cannot set tmpfile with stdin" - tmpfile = open(os.devnull, "r") - self.syscapture = SysCapture(targetfd) - else: - if tmpfile is None: - f = TemporaryFile() - with f: - tmpfile = safe_text_dupfile(f, mode="wb+") - if targetfd in patchsysdict: - self.syscapture = SysCapture(targetfd, tmpfile) - else: - self.syscapture = NoCapture() - self.tmpfile = tmpfile - self.tmpfile_fd = tmpfile.fileno() - - def __repr__(self): - return "<{} {} oldfd={} _state={!r} tmpfile={}>".format( - self.__class__.__name__, - self.targetfd, - getattr(self, "targetfd_save", ""), - self._state, - hasattr(self, "tmpfile") and repr(self.tmpfile) or "", - ) + Manages that the appropriate capture method is enabled/disabled during + collection and each test phase (setup, call, teardown). After each of + those points, the captured output is obtained and attached to the + collection/runtest report. - def _start(self): - """ Start capturing on targetfd using memorized tmpfile. """ - try: - os.fstat(self.targetfd_save) - except (AttributeError, OSError): - raise ValueError("saved filedescriptor not valid anymore") - os.dup2(self.tmpfile_fd, self.targetfd) - self.syscapture.start() - self._state = "started" + There are two levels of capture: - def snap(self): - self.tmpfile.seek(0) - res = self.tmpfile.read() - self.tmpfile.seek(0) - self.tmpfile.truncate() - return res + * global: enabled by default and can be suppressed by the ``-s`` + option. This is always enabled/disabled during collection and each test + phase. - def _done(self): - """ stop capturing, restore streams, return original capture file, - seeked to position zero. """ - targetfd_save = self.__dict__.pop("targetfd_save") - os.dup2(targetfd_save, self.targetfd) - os.close(targetfd_save) - self.syscapture.done() - self.tmpfile.close() - self._state = "done" + * fixture: when a test function or one of its fixture depend on the + ``capsys`` or ``capfd`` fixtures. In this case special handling is + needed to ensure the fixtures take precedence over the global capture. + """ - def suspend(self): - self.syscapture.suspend() - os.dup2(self.targetfd_save, self.targetfd) - self._state = "suspended" + def __init__(self, method: "_CaptureMethod") -> None: + self._method = method + self._global_capturing: Optional[MultiCapture[str]] = None + self._capture_fixture: Optional[CaptureFixture[Any]] = None - def resume(self): - self.syscapture.resume() - os.dup2(self.tmpfile_fd, self.targetfd) - self._state = "resumed" + def __repr__(self) -> str: + return "".format( + self._method, self._global_capturing, self._capture_fixture + ) - def writeorg(self, data): - """ write to original file descriptor. """ - if isinstance(data, str): - data = data.encode("utf8") # XXX use encoding of original stream - os.write(self.targetfd_save, data) + def is_capturing(self) -> Union[str, bool]: + if self.is_globally_capturing(): + return "global" + if self._capture_fixture: + return "fixture %s" % self._capture_fixture.request.fixturename + return False + # Global capturing control -class FDCapture(FDCaptureBinary): - """Capture IO to/from a given os-level filedescriptor. + def is_globally_capturing(self) -> bool: + return self._method != "no" - snap() produces text - """ + def start_global_capturing(self) -> None: + assert self._global_capturing is None + self._global_capturing = _get_multicapture(self._method) + self._global_capturing.start_capturing() - # Ignore type because it doesn't match the type in the superclass (bytes). - EMPTY_BUFFER = str() # type: ignore + def stop_global_capturing(self) -> None: + if self._global_capturing is not None: + self._global_capturing.pop_outerr_to_orig() + self._global_capturing.stop_capturing() + self._global_capturing = None - def snap(self): - res = super().snap() - enc = getattr(self.tmpfile, "encoding", None) - if enc and isinstance(res, bytes): - res = str(res, enc, "replace") - return res + def resume_global_capture(self) -> None: + # During teardown of the python process, and on rare occasions, capture + # attributes can be `None` while trying to resume global capture. + if self._global_capturing is not None: + self._global_capturing.resume_capturing() + def suspend_global_capture(self, in_: bool = False) -> None: + if self._global_capturing is not None: + self._global_capturing.suspend_capturing(in_=in_) -class SysCaptureBinary: + def suspend(self, in_: bool = False) -> None: + # Need to undo local capsys-et-al if it exists before disabling global capture. + self.suspend_fixture() + self.suspend_global_capture(in_) - EMPTY_BUFFER = b"" - _state = None + def resume(self) -> None: + self.resume_global_capture() + self.resume_fixture() - def __init__(self, fd, tmpfile=None): - name = patchsysdict[fd] - self._old = getattr(sys, name) - self.name = name - if tmpfile is None: - if name == "stdin": - tmpfile = DontReadFromInput() - else: - tmpfile = CaptureIO() - self.tmpfile = tmpfile + def read_global_capture(self) -> CaptureResult[str]: + assert self._global_capturing is not None + return self._global_capturing.readouterr() - def __repr__(self): - return "<{} {} _old={} _state={!r} tmpfile={!r}>".format( - self.__class__.__name__, - self.name, - hasattr(self, "_old") and repr(self._old) or "", - self._state, - self.tmpfile, - ) + # Fixture Control - def start(self): - setattr(sys, self.name, self.tmpfile) - self._state = "started" + def set_fixture(self, capture_fixture: "CaptureFixture[Any]") -> None: + if self._capture_fixture: + current_fixture = self._capture_fixture.request.fixturename + requested_fixture = capture_fixture.request.fixturename + capture_fixture.request.raiseerror( + "cannot use {} and {} at the same time".format( + requested_fixture, current_fixture + ) + ) + self._capture_fixture = capture_fixture - def snap(self): - res = self.tmpfile.buffer.getvalue() - self.tmpfile.seek(0) - self.tmpfile.truncate() - return res + def unset_fixture(self) -> None: + self._capture_fixture = None - def done(self): - setattr(sys, self.name, self._old) - del self._old - self.tmpfile.close() - self._state = "done" + def activate_fixture(self) -> None: + """If the current item is using ``capsys`` or ``capfd``, activate + them so they take precedence over the global capture.""" + if self._capture_fixture: + self._capture_fixture._start() - def suspend(self): - setattr(sys, self.name, self._old) - self._state = "suspended" + def deactivate_fixture(self) -> None: + """Deactivate the ``capsys`` or ``capfd`` fixture of this item, if any.""" + if self._capture_fixture: + self._capture_fixture.close() - def resume(self): - setattr(sys, self.name, self.tmpfile) - self._state = "resumed" + def suspend_fixture(self) -> None: + if self._capture_fixture: + self._capture_fixture._suspend() - def writeorg(self, data): - self._old.write(data) - self._old.flush() + def resume_fixture(self) -> None: + if self._capture_fixture: + self._capture_fixture._resume() + # Helper context managers -class SysCapture(SysCaptureBinary): - EMPTY_BUFFER = str() # type: ignore[assignment] # noqa: F821 + @contextlib.contextmanager + def global_and_fixture_disabled(self) -> Generator[None, None, None]: + """Context manager to temporarily disable global and current fixture capturing.""" + do_fixture = self._capture_fixture and self._capture_fixture._is_started() + if do_fixture: + self.suspend_fixture() + do_global = self._global_capturing and self._global_capturing.is_started() + if do_global: + self.suspend_global_capture() + try: + yield + finally: + if do_global: + self.resume_global_capture() + if do_fixture: + self.resume_fixture() - def snap(self): - res = self.tmpfile.getvalue() - self.tmpfile.seek(0) - self.tmpfile.truncate() - return res + @contextlib.contextmanager + def item_capture(self, when: str, item: Item) -> Generator[None, None, None]: + self.resume_global_capture() + self.activate_fixture() + try: + yield + finally: + self.deactivate_fixture() + self.suspend_global_capture(in_=False) + out, err = self.read_global_capture() + item.add_report_section(when, "stdout", out) + item.add_report_section(when, "stderr", err) -class TeeSysCapture(SysCapture): - def __init__(self, fd, tmpfile=None): - name = patchsysdict[fd] - self._old = getattr(sys, name) - self.name = name - if tmpfile is None: - if name == "stdin": - tmpfile = DontReadFromInput() - else: - tmpfile = CaptureAndPassthroughIO(self._old) - self.tmpfile = tmpfile + # Hooks + @hookimpl(hookwrapper=True) + def pytest_make_collect_report(self, collector: Collector): + if isinstance(collector, File): + self.resume_global_capture() + outcome = yield + self.suspend_global_capture() + out, err = self.read_global_capture() + rep = outcome.get_result() + if out: + rep.sections.append(("Captured stdout", out)) + if err: + rep.sections.append(("Captured stderr", err)) + else: + yield -map_fixname_class = { - "capfd": FDCapture, - "capfdbinary": FDCaptureBinary, - "capsys": SysCapture, - "capsysbinary": SysCaptureBinary, -} + @hookimpl(hookwrapper=True) + def pytest_runtest_setup(self, item: Item) -> Generator[None, None, None]: + with self.item_capture("setup", item): + yield + @hookimpl(hookwrapper=True) + def pytest_runtest_call(self, item: Item) -> Generator[None, None, None]: + with self.item_capture("call", item): + yield -class DontReadFromInput: - encoding = None + @hookimpl(hookwrapper=True) + def pytest_runtest_teardown(self, item: Item) -> Generator[None, None, None]: + with self.item_capture("teardown", item): + yield - def read(self, *args): - raise IOError( - "pytest: reading from stdin while output is captured! Consider using `-s`." - ) + @hookimpl(tryfirst=True) + def pytest_keyboard_interrupt(self) -> None: + self.stop_global_capturing() - readline = read - readlines = read - __next__ = read + @hookimpl(tryfirst=True) + def pytest_internalerror(self) -> None: + self.stop_global_capturing() - def __iter__(self): - return self - def fileno(self): - raise UnsupportedOperation("redirected stdin is pseudofile, has no fileno()") +class CaptureFixture(Generic[AnyStr]): + """Object returned by the :fixture:`capsys`, :fixture:`capsysbinary`, + :fixture:`capfd` and :fixture:`capfdbinary` fixtures.""" - def isatty(self): - return False + def __init__( + self, captureclass, request: SubRequest, *, _ispytest: bool = False + ) -> None: + check_ispytest(_ispytest) + self.captureclass = captureclass + self.request = request + self._capture: Optional[MultiCapture[AnyStr]] = None + self._captured_out = self.captureclass.EMPTY_BUFFER + self._captured_err = self.captureclass.EMPTY_BUFFER - def close(self): - pass + def _start(self) -> None: + if self._capture is None: + self._capture = MultiCapture( + in_=None, out=self.captureclass(1), err=self.captureclass(2), + ) + self._capture.start_capturing() - @property - def buffer(self): - return self + def close(self) -> None: + if self._capture is not None: + out, err = self._capture.pop_outerr_to_orig() + self._captured_out += out + self._captured_err += err + self._capture.stop_capturing() + self._capture = None + def readouterr(self) -> CaptureResult[AnyStr]: + """Read and return the captured output so far, resetting the internal + buffer. -def _colorama_workaround(): - """ - Ensure colorama is imported so that it attaches to the correct stdio - handles on Windows. + :returns: + The captured content as a namedtuple with ``out`` and ``err`` + string attributes. + """ + captured_out, captured_err = self._captured_out, self._captured_err + if self._capture is not None: + out, err = self._capture.readouterr() + captured_out += out + captured_err += err + self._captured_out = self.captureclass.EMPTY_BUFFER + self._captured_err = self.captureclass.EMPTY_BUFFER + return CaptureResult(captured_out, captured_err) - colorama uses the terminal on import time. So if something does the - first import of colorama while I/O capture is active, colorama will - fail in various ways. - """ - if sys.platform.startswith("win32"): - try: - import colorama # noqa: F401 - except ImportError: - pass + def _suspend(self) -> None: + """Suspend this fixture's own capturing temporarily.""" + if self._capture is not None: + self._capture.suspend_capturing() + def _resume(self) -> None: + """Resume this fixture's own capturing temporarily.""" + if self._capture is not None: + self._capture.resume_capturing() -def _readline_workaround(): - """ - Ensure readline is imported so that it attaches to the correct stdio - handles on Windows. + def _is_started(self) -> bool: + """Whether actively capturing -- not disabled or closed.""" + if self._capture is not None: + return self._capture.is_started() + return False - Pdb uses readline support where available--when not running from the Python - prompt, the readline module is not imported until running the pdb REPL. If - running pytest with the --pdb option this means the readline module is not - imported until after I/O capture has been started. + @contextlib.contextmanager + def disabled(self) -> Generator[None, None, None]: + """Temporarily disable capturing while inside the ``with`` block.""" + capmanager = self.request.config.pluginmanager.getplugin("capturemanager") + with capmanager.global_and_fixture_disabled(): + yield - This is a problem for pyreadline, which is often used to implement readline - support on Windows, as it does not attach to the correct handles for stdout - and/or stdin if they have been redirected by the FDCapture mechanism. This - workaround ensures that readline is imported before I/O capture is setup so - that it can attach to the actual stdin/out for the console. - See https://github.com/pytest-dev/pytest/pull/1281 - """ - if sys.platform.startswith("win32"): - try: - import readline # noqa: F401 - except ImportError: - pass +# The fixtures. -def _py36_windowsconsoleio_workaround(stream): - """ - Python 3.6 implemented unicode console handling for Windows. This works - by reading/writing to the raw console handle using - ``{Read,Write}ConsoleW``. +@fixture +def capsys(request: SubRequest) -> Generator[CaptureFixture[str], None, None]: + """Enable text capturing of writes to ``sys.stdout`` and ``sys.stderr``. - The problem is that we are going to ``dup2`` over the stdio file - descriptors when doing ``FDCapture`` and this will ``CloseHandle`` the - handles used by Python to write to the console. Though there is still some - weirdness and the console handle seems to only be closed randomly and not - on the first call to ``CloseHandle``, or maybe it gets reopened with the - same handle value when we suspend capturing. + The captured output is made available via ``capsys.readouterr()`` method + calls, which return a ``(out, err)`` namedtuple. + ``out`` and ``err`` will be ``text`` objects. + """ + capman = request.config.pluginmanager.getplugin("capturemanager") + capture_fixture = CaptureFixture[str](SysCapture, request, _ispytest=True) + capman.set_fixture(capture_fixture) + capture_fixture._start() + yield capture_fixture + capture_fixture.close() + capman.unset_fixture() - The workaround in this case will reopen stdio with a different fd which - also means a different handle by replicating the logic in - "Py_lifecycle.c:initstdio/create_stdio". - :param stream: in practice ``sys.stdout`` or ``sys.stderr``, but given - here as parameter for unittesting purposes. +@fixture +def capsysbinary(request: SubRequest) -> Generator[CaptureFixture[bytes], None, None]: + """Enable bytes capturing of writes to ``sys.stdout`` and ``sys.stderr``. - See https://github.com/pytest-dev/py/issues/103 + The captured output is made available via ``capsysbinary.readouterr()`` + method calls, which return a ``(out, err)`` namedtuple. + ``out`` and ``err`` will be ``bytes`` objects. """ - if ( - not sys.platform.startswith("win32") - or sys.version_info[:2] < (3, 6) - or hasattr(sys, "pypy_version_info") - ): - return + capman = request.config.pluginmanager.getplugin("capturemanager") + capture_fixture = CaptureFixture[bytes](SysCaptureBinary, request, _ispytest=True) + capman.set_fixture(capture_fixture) + capture_fixture._start() + yield capture_fixture + capture_fixture.close() + capman.unset_fixture() - # bail out if ``stream`` doesn't seem like a proper ``io`` stream (#2666) - if not hasattr(stream, "buffer"): - return - buffered = hasattr(stream.buffer, "raw") - raw_stdout = stream.buffer.raw if buffered else stream.buffer +@fixture +def capfd(request: SubRequest) -> Generator[CaptureFixture[str], None, None]: + """Enable text capturing of writes to file descriptors ``1`` and ``2``. - if not isinstance(raw_stdout, io._WindowsConsoleIO): - return + The captured output is made available via ``capfd.readouterr()`` method + calls, which return a ``(out, err)`` namedtuple. + ``out`` and ``err`` will be ``text`` objects. + """ + capman = request.config.pluginmanager.getplugin("capturemanager") + capture_fixture = CaptureFixture[str](FDCapture, request, _ispytest=True) + capman.set_fixture(capture_fixture) + capture_fixture._start() + yield capture_fixture + capture_fixture.close() + capman.unset_fixture() - def _reopen_stdio(f, mode): - if not buffered and mode[0] == "w": - buffering = 0 - else: - buffering = -1 - return io.TextIOWrapper( - open(os.dup(f.fileno()), mode, buffering), - f.encoding, - f.errors, - f.newlines, - f.line_buffering, - ) +@fixture +def capfdbinary(request: SubRequest) -> Generator[CaptureFixture[bytes], None, None]: + """Enable bytes capturing of writes to file descriptors ``1`` and ``2``. - sys.stdin = _reopen_stdio(sys.stdin, "rb") - sys.stdout = _reopen_stdio(sys.stdout, "wb") - sys.stderr = _reopen_stdio(sys.stderr, "wb") + The captured output is made available via ``capfd.readouterr()`` method + calls, which return a ``(out, err)`` namedtuple. + ``out`` and ``err`` will be ``byte`` objects. + """ + capman = request.config.pluginmanager.getplugin("capturemanager") + capture_fixture = CaptureFixture[bytes](FDCaptureBinary, request, _ispytest=True) + capman.set_fixture(capture_fixture) + capture_fixture._start() + yield capture_fixture + capture_fixture.close() + capman.unset_fixture() diff --git a/src/_pytest/compat.py b/src/_pytest/compat.py index 1845d9d91ef..c7f86ea9c0a 100644 --- a/src/_pytest/compat.py +++ b/src/_pytest/compat.py @@ -1,52 +1,43 @@ -""" -python version compatibility code -""" +"""Python version compatibility code.""" +import enum import functools import inspect -import io -import os import re import sys from contextlib import contextmanager from inspect import Parameter from inspect import signature +from pathlib import Path from typing import Any from typing import Callable from typing import Generic -from typing import IO from typing import Optional -from typing import overload from typing import Tuple +from typing import TYPE_CHECKING from typing import TypeVar from typing import Union import attr -import py -from _pytest._io.saferepr import saferepr from _pytest.outcomes import fail from _pytest.outcomes import TEST_OUTCOME -if sys.version_info < (3, 5, 2): - TYPE_CHECKING = False # type: bool -else: - from typing import TYPE_CHECKING - - if TYPE_CHECKING: - from typing import Type # noqa: F401 (used in type string) + from typing import NoReturn + from typing_extensions import Final _T = TypeVar("_T") _S = TypeVar("_S") -NOTSET = object() - -MODULE_NOT_FOUND_ERROR = ( - "ModuleNotFoundError" if sys.version_info[:2] >= (3, 6) else "ImportError" -) - +# fmt: off +# Singleton type for NOTSET, as described in: +# 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 +# fmt: on if sys.version_info >= (3, 8): from importlib import metadata as importlib_metadata @@ -62,27 +53,13 @@ def _format_args(func: Callable[..., Any]) -> str: REGEX_TYPE = type(re.compile("")) -if sys.version_info < (3, 6): - - def fspath(p): - """os.fspath replacement, useful to point out when we should replace it by the - real function once we drop py35. - """ - return str(p) - - -else: - fspath = os.fspath - - def is_generator(func: object) -> bool: genfunc = inspect.isgeneratorfunction(func) return genfunc and not iscoroutinefunction(func) def iscoroutinefunction(func: object) -> bool: - """ - Return True if func is a coroutine function (a function defined with async + """Return True if func is a coroutine function (a function defined with async def syntax, and doesn't contain yield), or a function decorated with @asyncio.coroutine. @@ -93,19 +70,28 @@ def syntax, and doesn't contain yield), or a function decorated with return inspect.iscoroutinefunction(func) or getattr(func, "_is_coroutine", False) -def getlocation(function, curdir=None) -> str: +def is_async_function(func: object) -> bool: + """Return True if the given function seems to be an async function or + an async generator.""" + return iscoroutinefunction(func) or inspect.isasyncgenfunction(func) + + +def getlocation(function, curdir: Optional[str] = None) -> str: function = get_real_func(function) - fn = py.path.local(inspect.getfile(function)) + fn = Path(inspect.getfile(function)) lineno = function.__code__.co_firstlineno if curdir is not None: - relfn = fn.relto(curdir) - if relfn: + try: + relfn = fn.relative_to(curdir) + except ValueError: + pass + else: return "%s:%d" % (relfn, lineno + 1) return "%s:%d" % (fn, lineno + 1) def num_mock_patch_args(function) -> int: - """ return number of arguments used up by mock arguments (if any) """ + """Return number of arguments used up by mock arguments (if any).""" patchings = getattr(function, "patchings", None) if not patchings: return 0 @@ -128,15 +114,15 @@ def getfuncargnames( *, name: str = "", is_method: bool = False, - cls: Optional[type] = None + cls: Optional[type] = None, ) -> Tuple[str, ...]: - """Returns the names of a function's mandatory arguments. + """Return the names of a function's mandatory arguments. - This should return the names of all function arguments that: - * Aren't bound to an instance or type as in instance or class methods. - * Don't have default values. - * Aren't bound with functools.partial. - * Aren't replaced with mocks. + Should return the names of all function arguments that: + * Aren't bound to an instance or type as in instance or class methods. + * Don't have default values. + * Aren't bound with functools.partial. + * Aren't replaced with mocks. The is_method and cls arguments indicate that the function should be treated as a bound method even though it's not unless, only in @@ -157,8 +143,7 @@ def getfuncargnames( parameters = signature(function).parameters except (ValueError, TypeError) as e: fail( - "Could not determine arguments of {!r}: {}".format(function, e), - pytrace=False, + f"Could not determine arguments of {function!r}: {e}", pytrace=False, ) arg_names = tuple( @@ -194,12 +179,13 @@ def nullcontext(): else: - from contextlib import nullcontext # noqa + from contextlib import nullcontext as nullcontext # noqa: F401 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 + # 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. return tuple( p.name for p in signature(function).parameters.values() @@ -209,7 +195,7 @@ def get_default_arg_names(function: Callable[..., Any]) -> Tuple[str, ...]: _non_printable_ascii_translate_table = { - i: "\\x{:02x}".format(i) for i in range(128) if i not in range(32, 127) + i: f"\\x{i:02x}" for i in range(128) if i not in range(32, 127) } _non_printable_ascii_translate_table.update( {ord("\t"): "\\t", ord("\r"): "\\r", ord("\n"): "\\n"} @@ -228,22 +214,21 @@ def _bytes_to_ascii(val: bytes) -> str: def ascii_escaped(val: Union[bytes, str]) -> str: - """If val is pure ascii, returns it as a str(). Otherwise, escapes + r"""If val is pure ASCII, return it as an str, otherwise, escape bytes objects into a sequence of escaped bytes: - b'\xc3\xb4\xc5\xd6' -> '\\xc3\\xb4\\xc5\\xd6' + b'\xc3\xb4\xc5\xd6' -> r'\xc3\xb4\xc5\xd6' and escapes unicode objects into a sequence of escaped unicode ids, e.g.: - '4\\nV\\U00043efa\\x0eMXWB\\x1e\\u3028\\u15fd\\xcd\\U0007d944' + r'4\nV\U00043efa\x0eMXWB\x1e\u3028\u15fd\xcd\U0007d944' - note: - the obvious "v.decode('unicode-escape')" will return - valid utf-8 unicode if it finds them in bytes, but we + Note: + The obvious "v.decode('unicode-escape')" will return + valid UTF-8 unicode if it finds them in bytes, but we want to return escaped bytes for any byte, even if they match - a utf-8 string. - + a UTF-8 string. """ if isinstance(val, bytes): ret = _bytes_to_ascii(val) @@ -256,18 +241,17 @@ def ascii_escaped(val: Union[bytes, str]) -> str: class _PytestWrapper: """Dummy wrapper around a function object for internal use only. - Used to correctly unwrap the underlying function object - when we are creating fixtures, because we wrap the function object ourselves with a decorator - to issue warnings when the fixture function is called directly. + Used to correctly unwrap the underlying function object when we are + creating fixtures, because we wrap the function object ourselves with a + decorator to issue warnings when the fixture function is called directly. """ obj = attr.ib() def get_real_func(obj): - """ gets the real function object of the (possibly) wrapped object by - functools.wraps or functools.partial. - """ + """Get the real function object of the (possibly) wrapped object by + functools.wraps or functools.partial.""" start_obj = obj for i in range(100): # __pytest_wrapped__ is set by @pytest.fixture when wrapping the fixture function @@ -282,6 +266,8 @@ def get_real_func(obj): break obj = new_obj else: + from _pytest._io.saferepr import saferepr + raise ValueError( ("could not find real function of {start}\nstopped at {current}").format( start=saferepr(start_obj), current=saferepr(obj) @@ -293,10 +279,9 @@ def get_real_func(obj): def get_real_method(obj, holder): - """ - Attempts to obtain the real function object that might be wrapping ``obj``, while at the same time - returning a bound method to ``holder`` if the original object was a bound method. - """ + """Attempt to obtain the real function object that might be wrapping + ``obj``, while at the same time returning a bound method to ``holder`` if + the original object was a bound method.""" try: is_method = hasattr(obj, "__func__") obj = get_real_func(obj) @@ -315,12 +300,13 @@ def getimfunc(func): def safe_getattr(object: Any, name: str, default: Any) -> Any: - """ Like getattr but return default upon any Exception or any OutcomeException. + """Like getattr but return default upon any Exception or any OutcomeException. Attribute access can potentially fail for 'evil' Python objects. See issue #214. - It catches OutcomeException because of #2490 (issue #580), new outcomes are derived from BaseException - instead of Exception (for more details check #2707) + It catches OutcomeException because of #2490 (issue #580), new outcomes + are derived from BaseException instead of Exception (for more details + check #2707). """ try: return getattr(object, name, default) @@ -336,64 +322,24 @@ def safe_isclass(obj: object) -> bool: return False -COLLECT_FAKEMODULE_ATTRIBUTES = ( - "Collector", - "Module", - "Function", - "Instance", - "Session", - "Item", - "Class", - "File", - "_fillfuncargs", -) - - -def _setup_collect_fakemodule() -> None: - from types import ModuleType - import pytest - - # Types ignored because the module is created dynamically. - pytest.collect = ModuleType("pytest.collect") # type: ignore - pytest.collect.__all__ = [] # type: ignore # used for setns - for attr_name in COLLECT_FAKEMODULE_ATTRIBUTES: - setattr(pytest.collect, attr_name, getattr(pytest, attr_name)) # type: ignore - - -class CaptureIO(io.TextIOWrapper): - def __init__(self) -> None: - super().__init__(io.BytesIO(), encoding="UTF-8", newline="", write_through=True) - - def getvalue(self) -> str: - assert isinstance(self.buffer, io.BytesIO) - return self.buffer.getvalue().decode("UTF-8") - - -class CaptureAndPassthroughIO(CaptureIO): - def __init__(self, other: IO) -> None: - self._other = other - super().__init__() - - def write(self, s) -> int: - super().write(s) - return self._other.write(s) - - -if sys.version_info < (3, 5, 2): +if TYPE_CHECKING: + if sys.version_info >= (3, 8): + from typing import final as final + else: + from typing_extensions import final as final +elif sys.version_info >= (3, 8): + from typing import final as final +else: - def overload(f): # noqa: F811 + def final(f): return f -if getattr(attr, "__version_info__", ()) >= (19, 2): - ATTRS_EQ_FIELD = "eq" -else: - ATTRS_EQ_FIELD = "cmp" - - if sys.version_info >= (3, 8): - from functools import cached_property + from functools import cached_property as cached_property else: + from typing import overload + from typing import Type class cached_property(Generic[_S, _T]): __slots__ = ("func", "__doc__") @@ -404,18 +350,51 @@ def __init__(self, func: Callable[[_S], _T]) -> None: @overload def __get__( - self, instance: None, owner: Optional["Type[_S]"] = ... + self, instance: None, owner: Optional[Type[_S]] = ... ) -> "cached_property[_S, _T]": - raise NotImplementedError() + ... - @overload # noqa: F811 - def __get__( # noqa: F811 - self, instance: _S, owner: Optional["Type[_S]"] = ... - ) -> _T: - raise NotImplementedError() + @overload + def __get__(self, instance: _S, owner: Optional[Type[_S]] = ...) -> _T: + ... - def __get__(self, instance, owner=None): # noqa: F811 + def __get__(self, instance, owner=None): if instance is None: return self value = instance.__dict__[self.func.__name__] = self.func(instance) return value + + +# Perform exhaustiveness checking. +# +# Consider this example: +# +# MyUnion = Union[int, str] +# +# def handle(x: MyUnion) -> int { +# if isinstance(x, int): +# return 1 +# elif isinstance(x, str): +# return 2 +# else: +# raise Exception('unreachable') +# +# Now suppose we add a new variant: +# +# MyUnion = Union[int, str, bytes] +# +# After doing this, we must remember ourselves to go and update the handle +# function to handle the new variant. +# +# With `assert_never` we can do better: +# +# // raise Exception('unreachable') +# return assert_never(x) +# +# Now, if we forget to handle the new variant, the type-checker will emit a +# compile-time error, instead of the runtime error we would have gotten +# previously. +# +# This also work for Enums (if you use `is` to compare) and Literals. +def assert_never(value: "NoReturn") -> "NoReturn": + assert False, "Unhandled value: {} ({})".format(value, type(value).__name__) diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index e21b9f1e2b2..bd9e2883f9f 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -1,104 +1,142 @@ -""" command line options, ini-file and conftest.py processing. """ +"""Command line options, ini-file and conftest.py processing.""" import argparse +import collections.abc +import contextlib import copy import enum import inspect import os +import re import shlex import sys import types import warnings from functools import lru_cache +from pathlib import Path from types import TracebackType from typing import Any from typing import Callable from typing import Dict +from typing import Generator +from typing import IO +from typing import Iterable +from typing import Iterator from typing import List from typing import Optional from typing import Sequence from typing import Set +from typing import TextIO from typing import Tuple +from typing import Type +from typing import TYPE_CHECKING from typing import Union import attr import py -from packaging.version import Version from pluggy import HookimplMarker from pluggy import HookspecMarker from pluggy import PluginManager import _pytest._code import _pytest.deprecated -import _pytest.hookspec # the extension point definitions -from .exceptions import PrintHelp -from .exceptions import UsageError +import _pytest.hookspec +from .exceptions import PrintHelp as PrintHelp +from .exceptions import UsageError as UsageError from .findpaths import determine_setup -from .findpaths import exists 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 TYPE_CHECKING from _pytest.outcomes import fail from _pytest.outcomes import Skipped -from _pytest.pathlib import Path +from _pytest.pathlib import bestrelpath +from _pytest.pathlib import import_path +from _pytest.pathlib import ImportMode from _pytest.store import Store from _pytest.warning_types import PytestConfigWarning if TYPE_CHECKING: - from typing import Type + from _pytest._code.code import _TracebackStyle + from _pytest.terminal import TerminalReporter from .argparsing import Argument _PluggyPlugin = object """A type to represent plugin objects. + Plugins can be any namespace, so we can't narrow it down much, but we use an alias to make the intent clear. -Ideally this type would be provided by pluggy itself.""" + +Ideally this type would be provided by pluggy itself. +""" hookimpl = HookimplMarker("pytest") hookspec = HookspecMarker("pytest") +@final class ExitCode(enum.IntEnum): - """ - .. versionadded:: 5.0 - - Encodes the valid exit codes by pytest. + """Encodes the valid exit codes by pytest. Currently users and plugins may supply other exit codes as well. + + .. versionadded:: 5.0 """ - #: tests passed + #: Tests passed. OK = 0 - #: tests failed + #: Tests failed. TESTS_FAILED = 1 - #: pytest was interrupted + #: pytest was interrupted. INTERRUPTED = 2 - #: an internal error got in the way + #: An internal error got in the way. INTERNAL_ERROR = 3 - #: pytest was misused + #: pytest was misused. USAGE_ERROR = 4 - #: pytest couldn't find tests + #: pytest couldn't find tests. NO_TESTS_COLLECTED = 5 class ConftestImportFailure(Exception): - def __init__(self, path, excinfo): - Exception.__init__(self, path, excinfo) + def __init__( + self, + path: py.path.local, + excinfo: Tuple[Type[Exception], Exception, TracebackType], + ) -> None: + super().__init__(path, excinfo) self.path = path - self.excinfo = excinfo # type: Tuple[Type[Exception], Exception, TracebackType] + self.excinfo = excinfo + def __str__(self) -> str: + return "{}: {} (from {})".format( + self.excinfo[0].__name__, self.excinfo[1], self.path + ) -def main(args=None, plugins=None) -> Union[int, ExitCode]: - """ return exit code, after performing an in-process test run. - :arg args: list of command line arguments. +def filter_traceback_for_conftest_import_failure( + entry: _pytest._code.TracebackEntry, +) -> bool: + """Filter tracebacks entries which point to pytest internals or importlib. - :arg plugins: list of plugin objects to be auto-registered during - initialization. + Make a special case for importlib because we use it to import test modules and conftest files + in _pytest.pathlib.import_path. + """ + return filter_traceback(entry) and "importlib" not in str(entry.path).split(os.sep) + + +def main( + args: Optional[Union[List[str], py.path.local]] = None, + plugins: Optional[Sequence[Union[str, _PluggyPlugin]]] = None, +) -> Union[int, ExitCode]: + """Perform an in-process test run. + + :param args: List of command line arguments. + :param plugins: List of plugin objects to be auto-registered during initialization. + + :returns: An exit code. """ try: try: @@ -106,10 +144,10 @@ def main(args=None, plugins=None) -> Union[int, ExitCode]: except ConftestImportFailure as e: exc_info = ExceptionInfo(e.excinfo) tw = TerminalWriter(sys.stderr) - tw.line( - "ImportError while loading conftest '{e.path}'.".format(e=e), red=True + tw.line(f"ImportError while loading conftest '{e.path}'.", red=True) + exc_info.traceback = exc_info.traceback.filter( + filter_traceback_for_conftest_import_failure ) - exc_info.traceback = exc_info.traceback.filter(filter_traceback) exc_repr = ( exc_info.getrepr(style="short", chain=False) if exc_info.traceback @@ -121,9 +159,9 @@ def main(args=None, plugins=None) -> Union[int, ExitCode]: return ExitCode.USAGE_ERROR else: try: - ret = config.hook.pytest_cmdline_main( + ret: Union[ExitCode, int] = config.hook.pytest_cmdline_main( config=config - ) # type: Union[ExitCode, int] + ) try: return ExitCode(ret) except ValueError: @@ -133,33 +171,51 @@ def main(args=None, plugins=None) -> Union[int, ExitCode]: except UsageError as e: tw = TerminalWriter(sys.stderr) for msg in e.args: - tw.line("ERROR: {}\n".format(msg), red=True) + tw.line(f"ERROR: {msg}\n", red=True) return ExitCode.USAGE_ERROR +def console_main() -> int: + """The CLI entry point of pytest. + + This function is not meant for programmable use; use `main()` instead. + """ + # https://docs.python.org/3/library/signal.html#note-on-sigpipe + try: + code = main() + sys.stdout.flush() + return code + except BrokenPipeError: + # Python flushes standard streams on exit; redirect remaining output + # to devnull to avoid another BrokenPipeError at shutdown + devnull = os.open(os.devnull, os.O_WRONLY) + os.dup2(devnull, sys.stdout.fileno()) + return 1 # Python exits with error code 1 on EPIPE + + class cmdline: # compatibility namespace main = staticmethod(main) -def filename_arg(path, optname): - """ Argparse type validator for filename arguments. +def filename_arg(path: str, optname: str) -> str: + """Argparse type validator for filename arguments. - :path: path of filename - :optname: name of the option + :path: Path of filename. + :optname: Name of the option. """ if os.path.isdir(path): - raise UsageError("{} must be a filename, given: {}".format(optname, path)) + raise UsageError(f"{optname} must be a filename, given: {path}") return path -def directory_arg(path, optname): +def directory_arg(path: str, optname: str) -> str: """Argparse type validator for directory arguments. - :path: path of directory - :optname: name of the option + :path: Path of directory. + :optname: Name of the option. """ if not os.path.isdir(path): - raise UsageError("{} must be a directory, given: {}".format(optname, path)) + raise UsageError(f"{optname} must be a directory, given: {path}") return path @@ -186,7 +242,6 @@ def directory_arg(path, optname): "nose", "assertion", "junitxml", - "resultlog", "doctest", "cacheprovider", "freeze_support", @@ -196,20 +251,25 @@ def directory_arg(path, optname): "warnings", "logging", "reports", + *(["unraisableexception", "threadexception"] if sys.version_info >= (3, 8) else []), "faulthandler", ) builtin_plugins = set(default_plugins) builtin_plugins.add("pytester") +builtin_plugins.add("pytester_assertions") -def get_config(args=None, plugins=None): +def get_config( + args: Optional[List[str]] = None, + plugins: Optional[Sequence[Union[str, _PluggyPlugin]]] = None, +) -> "Config": # subsequent calls to main will create a fresh instance pluginmanager = PytestPluginManager() config = Config( pluginmanager, invocation_params=Config.InvocationParams( - args=args or (), plugins=plugins, dir=Path().resolve() + args=args or (), plugins=plugins, dir=Path.cwd(), ), ) @@ -219,12 +279,12 @@ def get_config(args=None, plugins=None): for spec in default_plugins: pluginmanager.import_plugin(spec) + return config -def get_plugin_manager(): - """ - Obtain a new instance of the +def get_plugin_manager() -> "PytestPluginManager": + """Obtain a new instance of the :py:class:`_pytest.config.PytestPluginManager`, with default plugins already loaded. @@ -235,8 +295,9 @@ def get_plugin_manager(): def _prepareconfig( - args: Optional[Union[py.path.local, List[str]]] = None, plugins=None -): + args: Optional[Union[py.path.local, List[str]]] = None, + plugins: Optional[Sequence[Union[str, _PluggyPlugin]]] = None, +) -> "Config": if args is None: args = sys.argv[1:] elif isinstance(args, py.path.local): @@ -254,61 +315,55 @@ def _prepareconfig( pluginmanager.consider_pluginarg(plugin) else: pluginmanager.register(plugin) - return pluginmanager.hook.pytest_cmdline_parse( + config = pluginmanager.hook.pytest_cmdline_parse( pluginmanager=pluginmanager, args=args ) + return config except BaseException: config._ensure_unconfigure() raise -def _fail_on_non_top_pytest_plugins(conftestpath, confcutdir): - msg = ( - "Defining 'pytest_plugins' in a non-top-level conftest is no longer supported:\n" - "It affects the entire test suite instead of just below the conftest as expected.\n" - " {}\n" - "Please move it to a top level conftest file at the rootdir:\n" - " {}\n" - "For more information, visit:\n" - " https://docs.pytest.org/en/latest/deprecations.html#pytest-plugins-in-non-top-level-conftest-files" - ) - fail(msg.format(conftestpath, confcutdir), pytrace=False) - - +@final class PytestPluginManager(PluginManager): - """ - Overwrites :py:class:`pluggy.PluginManager ` to add pytest-specific - functionality: + """A :py:class:`pluggy.PluginManager ` with + additional pytest-specific functionality: - * loading plugins from the command line, ``PYTEST_PLUGINS`` env variable and - ``pytest_plugins`` global variables found in plugins being loaded; - * ``conftest.py`` loading during start-up; + * Loading plugins from the command line, ``PYTEST_PLUGINS`` env variable and + ``pytest_plugins`` global variables found in plugins being loaded. + * ``conftest.py`` loading during start-up. """ - def __init__(self): + def __init__(self) -> None: import _pytest.assertion super().__init__("pytest") # The objects are module objects, only used generically. - self._conftest_plugins = set() # type: Set[object] - - # state related to local conftest plugins - # Maps a py.path.local to a list of module objects. - self._dirpath2confmods = {} # type: Dict[Any, List[object]] - # Maps a py.path.local to a module object. - self._conftestpath2mod = {} # type: Dict[Any, object] - self._confcutdir = None + self._conftest_plugins: Set[types.ModuleType] = set() + + # State related to local conftest plugins. + self._dirpath2confmods: Dict[py.path.local, List[types.ModuleType]] = {} + self._conftestpath2mod: Dict[Path, types.ModuleType] = {} + self._confcutdir: Optional[py.path.local] = None self._noconftest = False - # Set of py.path.local's. - self._duplicatepaths = set() # type: Set[Any] + self._duplicatepaths: Set[py.path.local] = set() + + # plugins that were explicitly skipped with pytest.skip + # list of (module name, skip reason) + # previously we would issue a warning when a plugin was skipped, but + # since we refactored warnings as first citizens of Config, they are + # just stored here to be used later. + self.skipped_plugins: List[Tuple[str, str]] = [] self.add_hookspecs(_pytest.hookspec) self.register(self) if os.environ.get("PYTEST_DEBUG"): - err = sys.stderr - encoding = getattr(err, "encoding", "utf8") + err: IO[str] = sys.stderr + encoding: str = getattr(err, "encoding", "utf8") try: - err = py.io.dupfile(err, encoding=encoding) + err = open( + os.dup(err.fileno()), mode=err.mode, buffering=1, encoding=encoding, + ) except Exception: pass self.trace.root.setwriter(err.write) @@ -316,27 +371,27 @@ def __init__(self): # Config._consider_importhook will set a real object if required. self.rewrite_hook = _pytest.assertion.DummyRewriteHook() - # Used to know when we are importing conftests after the pytest_configure stage + # Used to know when we are importing conftests after the pytest_configure stage. self._configured = False - def parse_hookimpl_opts(self, plugin, name): - # pytest hooks are always prefixed with pytest_ + def parse_hookimpl_opts(self, plugin: _PluggyPlugin, name: str): + # pytest hooks are always prefixed with "pytest_", # so we avoid accessing possibly non-readable attributes - # (see issue #1073) + # (see issue #1073). if not name.startswith("pytest_"): return - # ignore names which can not be hooks + # Ignore names which can not be hooks. if name == "pytest_plugins": return method = getattr(plugin, name) opts = super().parse_hookimpl_opts(plugin, name) - # consider only actual functions for hooks (#3775) + # Consider only actual functions for hooks (#3775). if not inspect.isroutine(method): return - # collect unmarked hooks as long as they have the `pytest_' prefix + # Collect unmarked hooks as long as they have the `pytest_' prefix. if opts is None and name.startswith("pytest_"): opts = {} if opts is not None: @@ -348,7 +403,7 @@ def parse_hookimpl_opts(self, plugin, name): opts.setdefault(name, hasattr(method, name) or name in known_marks) return opts - def parse_hookspec_opts(self, module_or_class, name): + def parse_hookspec_opts(self, module_or_class, name: str): opts = super().parse_hookspec_opts(module_or_class, name) if opts is None: method = getattr(module_or_class, name) @@ -365,7 +420,9 @@ def parse_hookspec_opts(self, module_or_class, name): } return opts - def register(self, plugin, name=None): + def register( + self, plugin: _PluggyPlugin, name: Optional[str] = None + ) -> Optional[str]: if name in _pytest.deprecated.DEPRECATED_EXTERNAL_PLUGINS: warnings.warn( PytestConfigWarning( @@ -375,8 +432,8 @@ def register(self, plugin, name=None): ) ) ) - return - ret = super().register(plugin, name) + return None + ret: Optional[str] = super().register(plugin, name) if ret: self.hook.pytest_plugin_registered.call_historic( kwargs=dict(plugin=plugin, manager=self) @@ -386,17 +443,19 @@ def register(self, plugin, name=None): self.consider_module(plugin) return ret - def getplugin(self, name): - # support deprecated naming because plugins (xdist e.g.) use it - return self.get_plugin(name) + def getplugin(self, name: str): + # Support deprecated naming because plugins (xdist e.g.) use it. + plugin: Optional[_PluggyPlugin] = self.get_plugin(name) + return plugin - def hasplugin(self, name): - """Return True if the plugin with the given name is registered.""" + def hasplugin(self, name: str) -> bool: + """Return whether a plugin with the given name is registered.""" return bool(self.get_plugin(name)) - def pytest_configure(self, config): + def pytest_configure(self, config: "Config") -> None: + """:meta private:""" # XXX now that the pluginmanager exposes hookimpl(tryfirst...) - # we should remove tryfirst/trylast as markers + # we should remove tryfirst/trylast as markers. config.addinivalue_line( "markers", "tryfirst: mark a hook implementation function such that the " @@ -410,15 +469,15 @@ def pytest_configure(self, config): self._configured = True # - # internal API for local conftest plugin handling + # Internal API for local conftest plugin handling. # - def _set_initial_conftests(self, namespace): - """ load initial conftest files given a preparsed "namespace". - As conftest files may add their own command line options - which have arguments ('--my-opt somepath') we might get some - false positives. All builtin and 3rd party plugins will have - been loaded, however, so common options will not confuse our logic - here. + def _set_initial_conftests(self, namespace: argparse.Namespace) -> None: + """Load initial conftest files given a preparsed "namespace". + + As conftest files may add their own command line options which have + arguments ('--my-opt somepath') we might get some false positives. + All builtin and 3rd party plugins will have been loaded, however, so + common options will not confuse our logic here. """ current = py.path.local() self._confcutdir = ( @@ -430,29 +489,33 @@ def _set_initial_conftests(self, namespace): self._using_pyargs = namespace.pyargs testpaths = namespace.file_or_dir foundanchor = False - for path in testpaths: - path = str(path) + for testpath in testpaths: + path = str(testpath) # remove node-id syntax i = path.find("::") if i != -1: path = path[:i] anchor = current.join(path, abs=1) - if exists(anchor): # we found some file object - self._try_load_conftest(anchor) + if anchor.exists(): # we found some file object + self._try_load_conftest(anchor, namespace.importmode) foundanchor = True if not foundanchor: - self._try_load_conftest(current) + self._try_load_conftest(current, namespace.importmode) - def _try_load_conftest(self, anchor): - self._getconftestmodules(anchor) + def _try_load_conftest( + self, anchor: py.path.local, importmode: Union[str, ImportMode] + ) -> None: + self._getconftestmodules(anchor, importmode) # let's also consider test* subdirs if anchor.check(dir=1): for x in anchor.listdir("test*"): if x.check(dir=1): - self._getconftestmodules(x) + self._getconftestmodules(x, importmode) @lru_cache(maxsize=128) - def _getconftestmodules(self, path): + def _getconftestmodules( + self, path: py.path.local, importmode: Union[str, ImportMode], + ) -> List[types.ModuleType]: if self._noconftest: return [] @@ -461,22 +524,24 @@ def _getconftestmodules(self, path): else: directory = path - # XXX these days we may rather want to use config.rootdir + # XXX these days we may rather want to use config.rootpath # and allow users to opt into looking into the rootdir parent - # directories instead of requiring to specify confcutdir + # directories instead of requiring to specify confcutdir. clist = [] - for parent in directory.realpath().parts(): + for parent in directory.parts(): if self._confcutdir and self._confcutdir.relto(parent): continue conftestpath = parent.join("conftest.py") if conftestpath.isfile(): - mod = self._importconftest(conftestpath) + mod = self._importconftest(conftestpath, importmode) clist.append(mod) self._dirpath2confmods[directory] = clist return clist - def _rget_with_confmod(self, name, path): - modules = self._getconftestmodules(path) + def _rget_with_confmod( + self, name: str, path: py.path.local, importmode: Union[str, ImportMode], + ) -> Tuple[types.ModuleType, Any]: + modules = self._getconftestmodules(path, importmode) for mod in reversed(modules): try: return mod, getattr(mod, name) @@ -484,48 +549,71 @@ def _rget_with_confmod(self, name, path): continue raise KeyError(name) - def _importconftest(self, conftestpath): - # Use a resolved Path object as key to avoid loading the same conftest twice - # with build systems that create build directories containing + def _importconftest( + self, conftestpath: py.path.local, importmode: Union[str, ImportMode], + ) -> types.ModuleType: + # Use a resolved Path object as key to avoid loading the same conftest + # twice with build systems that create build directories containing # symlinks to actual files. # Using Path().resolve() is better than py.path.realpath because # it resolves to the correct path/drive in case-insensitive file systems (#5792) key = Path(str(conftestpath)).resolve() - try: + + with contextlib.suppress(KeyError): return self._conftestpath2mod[key] - except KeyError: - pkgpath = conftestpath.pypkgpath() - if pkgpath is None: - _ensure_removed_sysmodule(conftestpath.purebasename) - try: - mod = conftestpath.pyimport() - if ( - hasattr(mod, "pytest_plugins") - and self._configured - and not self._using_pyargs - ): - _fail_on_non_top_pytest_plugins(conftestpath, self._confcutdir) - except Exception: - raise ConftestImportFailure(conftestpath, sys.exc_info()) - - self._conftest_plugins.add(mod) - self._conftestpath2mod[key] = mod - dirpath = conftestpath.dirpath() - if dirpath in self._dirpath2confmods: - for path, mods in self._dirpath2confmods.items(): - if path and path.relto(dirpath) or path == dirpath: - assert mod not in mods - mods.append(mod) - self.trace("loading conftestmodule {!r}".format(mod)) - self.consider_conftest(mod) - return mod + + pkgpath = conftestpath.pypkgpath() + if pkgpath is None: + _ensure_removed_sysmodule(conftestpath.purebasename) + + try: + mod = import_path(conftestpath, mode=importmode) + except Exception as e: + assert e.__traceback__ is not None + exc_info = (type(e), e, e.__traceback__) + raise ConftestImportFailure(conftestpath, exc_info) from e + + self._check_non_top_pytest_plugins(mod, conftestpath) + + self._conftest_plugins.add(mod) + self._conftestpath2mod[key] = mod + dirpath = conftestpath.dirpath() + if dirpath in self._dirpath2confmods: + for path, mods in self._dirpath2confmods.items(): + if path and path.relto(dirpath) or path == dirpath: + assert mod not in mods + mods.append(mod) + self.trace(f"loading conftestmodule {mod!r}") + self.consider_conftest(mod) + return mod + + def _check_non_top_pytest_plugins( + self, mod: types.ModuleType, conftestpath: py.path.local, + ) -> None: + if ( + hasattr(mod, "pytest_plugins") + and self._configured + and not self._using_pyargs + ): + msg = ( + "Defining 'pytest_plugins' in a non-top-level conftest is no longer supported:\n" + "It affects the entire test suite instead of just below the conftest as expected.\n" + " {}\n" + "Please move it to a top level conftest file at the rootdir:\n" + " {}\n" + "For more information, visit:\n" + " https://docs.pytest.org/en/stable/deprecations.html#pytest-plugins-in-non-top-level-conftest-files" + ) + fail(msg.format(conftestpath, self._confcutdir), pytrace=False) # # API for bootstrapping plugin loading # # - def consider_preparse(self, args, *, exclude_only=False): + def consider_preparse( + self, args: Sequence[str], *, exclude_only: bool = False + ) -> None: i = 0 n = len(args) while i < n: @@ -546,13 +634,13 @@ def consider_preparse(self, args, *, exclude_only=False): continue self.consider_pluginarg(parg) - def consider_pluginarg(self, arg): + def consider_pluginarg(self, arg: str) -> None: if arg.startswith("no:"): name = arg[3:] if name in essential_plugins: raise UsageError("plugin %s cannot be disabled" % name) - # PR #4304 : remove stepwise if cacheprovider is blocked + # PR #4304: remove stepwise if cacheprovider is blocked. if name == "cacheprovider": self.set_blocked("stepwise") self.set_blocked("pytest_stepwise") @@ -571,33 +659,35 @@ def consider_pluginarg(self, arg): del self._name2plugin["pytest_" + name] self.import_plugin(arg, consider_entry_points=True) - def consider_conftest(self, conftestmodule): + def consider_conftest(self, conftestmodule: types.ModuleType) -> None: self.register(conftestmodule, name=conftestmodule.__file__) - def consider_env(self): + def consider_env(self) -> None: self._import_plugin_specs(os.environ.get("PYTEST_PLUGINS")) - def consider_module(self, mod): + def consider_module(self, mod: types.ModuleType) -> None: self._import_plugin_specs(getattr(mod, "pytest_plugins", [])) - def _import_plugin_specs(self, spec): + def _import_plugin_specs( + self, spec: Union[None, types.ModuleType, str, Sequence[str]] + ) -> None: plugins = _get_plugin_specs_as_list(spec) for import_spec in plugins: self.import_plugin(import_spec) - def import_plugin(self, modname, consider_entry_points=False): - """ - Imports a plugin with ``modname``. If ``consider_entry_points`` is True, entry point - names are also considered to find a plugin. + def import_plugin(self, modname: str, consider_entry_points: bool = False) -> None: + """Import a plugin with ``modname``. + + If ``consider_entry_points`` is True, entry point names are also + considered to find a plugin. """ - # most often modname refers to builtin modules, e.g. "pytester", + # Most often modname refers to builtin modules, e.g. "pytester", # "terminal" or "capture". Those plugins are registered under their # basename for historic purposes but must be imported with the # _pytest prefix. assert isinstance(modname, str), ( "module name as text required, got %r" % modname ) - modname = str(modname) if self.is_blocked(modname) or self.get_plugin(modname) is not None: return @@ -614,42 +704,38 @@ def import_plugin(self, modname, consider_entry_points=False): except ImportError as e: raise ImportError( 'Error importing plugin "{}": {}'.format(modname, str(e.args[0])) - ).with_traceback(e.__traceback__) + ).with_traceback(e.__traceback__) from e except Skipped as e: - from _pytest.warnings import _issue_warning_captured - - _issue_warning_captured( - PytestConfigWarning("skipped plugin {!r}: {}".format(modname, e.msg)), - self.hook, - stacklevel=2, - ) + self.skipped_plugins.append((modname, e.msg or "")) else: mod = sys.modules[importspec] self.register(mod, modname) -def _get_plugin_specs_as_list(specs): - """ - Parses a list of "plugin specs" and returns a list of plugin names. - - Plugin specs can be given as a list of strings separated by "," or already as a list/tuple in - which case it is returned as a list. Specs can also be `None` in which case an - empty list is returned. - """ - if specs is not None and not isinstance(specs, types.ModuleType): - if isinstance(specs, str): - specs = specs.split(",") if specs else [] - if not isinstance(specs, (list, tuple)): - raise UsageError( - "Plugin specs must be a ','-separated string or a " - "list/tuple of strings for plugin names. Given: %r" % specs - ) +def _get_plugin_specs_as_list( + specs: Union[None, types.ModuleType, str, Sequence[str]] +) -> List[str]: + """Parse a plugins specification into a list of plugin names.""" + # None means empty. + if specs is None: + return [] + # Workaround for #3899 - a submodule which happens to be called "pytest_plugins". + if isinstance(specs, types.ModuleType): + return [] + # Comma-separated list. + if isinstance(specs, str): + return specs.split(",") if specs else [] + # Direct specification. + if isinstance(specs, collections.abc.Sequence): return list(specs) - return [] + raise UsageError( + "Plugins may be specified as a sequence or a ','-separated string of plugin names. Got: %r" + % specs + ) -def _ensure_removed_sysmodule(modname): +def _ensure_removed_sysmodule(modname: str) -> None: try: del sys.modules[modname] except KeyError: @@ -664,11 +750,12 @@ def __repr__(self): notset = Notset() -def _iter_rewritable_modules(package_files): - """ - Given an iterable of file names in a source distribution, return the "names" that should - be marked for assertion rewrite (for example the package "pytest_mock/__init__.py" should - be added as "pytest_mock" in the assertion rewrite mechanism. +def _iter_rewritable_modules(package_files: Iterable[str]) -> Iterator[str]: + """Given an iterable of file names in a source distribution, return the "names" that should + be marked for assertion rewrite. + + For example the package "pytest_mock/__init__.py" should be added as "pytest_mock" in + the assertion rewrite mechanism. This function has to deal with dist-info based distributions and egg based distributions (which are still very much in use for "editable" installs). @@ -712,11 +799,11 @@ def _iter_rewritable_modules(package_files): yield package_name if not seen_some: - # at this point we did not find any packages or modules suitable for assertion + # At this point we did not find any packages or modules suitable for assertion # rewriting, so we try again by stripping the first path component (to account for - # "src" based source trees for example) - # this approach lets us have the common case continue to be fast, as egg-distributions - # are rarer + # "src" based source trees for example). + # This approach lets us have the common case continue to be fast, as egg-distributions + # are rarer. new_package_files = [] for fn in package_files: parts = fn.split("/") @@ -727,29 +814,27 @@ def _iter_rewritable_modules(package_files): yield from _iter_rewritable_modules(new_package_files) -class Config: - """ - Access to configuration values, pluginmanager and plugin hooks. +def _args_converter(args: Iterable[str]) -> Tuple[str, ...]: + return tuple(args) - :ivar PytestPluginManager pluginmanager: the plugin manager handles plugin registration and hook invocation. - :ivar argparse.Namespace option: access to command line option as attributes. +@final +class Config: + """Access to configuration values, pluginmanager and plugin hooks. - :ivar InvocationParams invocation_params: + :param PytestPluginManager pluginmanager: - Object containing the parameters regarding the ``pytest.main`` + :param InvocationParams invocation_params: + Object containing parameters regarding the :func:`pytest.main` invocation. - - Contains the following read-only attributes: - - * ``args``: tuple of command-line arguments as passed to ``pytest.main()``. - * ``plugins``: list of extra plugins, might be None. - * ``dir``: directory where ``pytest.main()`` was invoked from. """ + @final @attr.s(frozen=True) class InvocationParams: - """Holds parameters passed during ``pytest.main()`` + """Holds parameters passed during :func:`pytest.main`. + + The object attributes are read-only. .. versionadded:: 5.1 @@ -761,33 +846,64 @@ class InvocationParams: Plugins accessing ``InvocationParams`` must be aware of that. """ - args = attr.ib(converter=tuple) - plugins = attr.ib() + args = attr.ib(type=Tuple[str, ...], converter=_args_converter) + """The command-line arguments as passed to :func:`pytest.main`. + + :type: Tuple[str, ...] + """ + plugins = attr.ib(type=Optional[Sequence[Union[str, _PluggyPlugin]]]) + """Extra plugins, might be `None`. + + :type: Optional[Sequence[Union[str, plugin]]] + """ dir = attr.ib(type=Path) + """The directory from which :func:`pytest.main` was invoked. - def __init__(self, pluginmanager, *, invocation_params=None) -> None: + :type: pathlib.Path + """ + + def __init__( + self, + pluginmanager: PytestPluginManager, + *, + invocation_params: Optional[InvocationParams] = None, + ) -> None: from .argparsing import Parser, FILE_OR_DIR if invocation_params is None: invocation_params = self.InvocationParams( - args=(), plugins=None, dir=Path().resolve() + args=(), plugins=None, dir=Path.cwd() ) self.option = argparse.Namespace() + """Access to command line option as attributes. + + :type: argparse.Namespace + """ + self.invocation_params = invocation_params + """The parameters with which pytest was invoked. + + :type: InvocationParams + """ _a = FILE_OR_DIR self._parser = Parser( - usage="%(prog)s [options] [{}] [{}] [...]".format(_a, _a), + usage=f"%(prog)s [options] [{_a}] [{_a}] [...]", processopt=self._processopt, ) self.pluginmanager = pluginmanager + """The plugin manager handles plugin registration and hook invocation. + + :type: PytestPluginManager + """ + self.trace = self.pluginmanager.trace.root.get("config") self.hook = self.pluginmanager.hook - self._inicache = {} # type: Dict[str, Any] - self._override_ini = () # type: Sequence[str] - self._opt2dest = {} # type: Dict[str, str] - self._cleanup = [] # type: List[Callable[[], None]] + self._inicache: Dict[str, Any] = {} + self._override_ini: Sequence[str] = () + self._opt2dest: Dict[str, str] = {} + self._cleanup: List[Callable[[], None]] = [] # A place where plugins can store information on the config for their # own use. Currently only intended for internal plugins. self._store = Store() @@ -800,26 +916,72 @@ def __init__(self, pluginmanager, *, invocation_params=None) -> None: if TYPE_CHECKING: from _pytest.cacheprovider import Cache - self.cache = None # type: Optional[Cache] + self.cache: Optional[Cache] = None @property - def invocation_dir(self): - """Backward compatibility""" + def invocation_dir(self) -> py.path.local: + """The directory from which pytest was invoked. + + Prefer to use :attr:`invocation_params.dir `, + which is a :class:`pathlib.Path`. + + :type: py.path.local + """ return py.path.local(str(self.invocation_params.dir)) - def add_cleanup(self, func): - """ Add a function to be called when the config object gets out of + @property + def rootpath(self) -> Path: + """The path to the :ref:`rootdir `. + + :type: pathlib.Path + + .. versionadded:: 6.1 + """ + return self._rootpath + + @property + def rootdir(self) -> py.path.local: + """The path to the :ref:`rootdir `. + + Prefer to use :attr:`rootpath`, which is a :class:`pathlib.Path`. + + :type: py.path.local + """ + return py.path.local(str(self.rootpath)) + + @property + def inipath(self) -> Optional[Path]: + """The path to the :ref:`configfile `. + + :type: Optional[pathlib.Path] + + .. versionadded:: 6.1 + """ + return self._inipath + + @property + def inifile(self) -> Optional[py.path.local]: + """The path to the :ref:`configfile `. + + Prefer to use :attr:`inipath`, which is a :class:`pathlib.Path`. + + :type: Optional[py.path.local] + """ + return py.path.local(str(self.inipath)) if self.inipath else None + + def add_cleanup(self, func: Callable[[], None]) -> None: + """Add a function to be called when the config object gets out of use (usually coninciding with pytest_unconfigure).""" self._cleanup.append(func) - def _do_configure(self): + def _do_configure(self) -> None: assert not self._configured self._configured = True with warnings.catch_warnings(): warnings.simplefilter("default") self.hook.pytest_configure.call_historic(kwargs=dict(config=self)) - def _ensure_unconfigure(self): + def _ensure_unconfigure(self) -> None: if self._configured: self._configured = False self.hook.pytest_unconfigure(config=self) @@ -828,10 +990,15 @@ def _ensure_unconfigure(self): fin = self._cleanup.pop() fin() - def get_terminal_writer(self): - return self.pluginmanager.get_plugin("terminalreporter")._tw + def get_terminal_writer(self) -> TerminalWriter: + terminalreporter: TerminalReporter = self.pluginmanager.get_plugin( + "terminalreporter" + ) + return terminalreporter._tw - def pytest_cmdline_parse(self, pluginmanager, args): + def pytest_cmdline_parse( + self, pluginmanager: PytestPluginManager, args: List[str] + ) -> "Config": try: self.parse(args) except UsageError: @@ -855,9 +1022,13 @@ def pytest_cmdline_parse(self, pluginmanager, args): return self - def notify_exception(self, excinfo, option=None): + def notify_exception( + self, + excinfo: ExceptionInfo[BaseException], + option: Optional[argparse.Namespace] = None, + ) -> None: if option and getattr(option, "fulltrace", False): - style = "long" + style: _TracebackStyle = "long" else: style = "native" excrepr = excinfo.getrepr( @@ -869,16 +1040,16 @@ def notify_exception(self, excinfo, option=None): sys.stderr.write("INTERNALERROR> %s\n" % line) sys.stderr.flush() - def cwd_relative_nodeid(self, nodeid): - # nodeid's are relative to the rootpath, compute relative to cwd - if self.invocation_dir != self.rootdir: - fullpath = self.rootdir.join(nodeid) - nodeid = self.invocation_dir.bestrelpath(fullpath) + def cwd_relative_nodeid(self, nodeid: str) -> str: + # nodeid's are relative to the rootpath, compute relative to cwd. + if self.invocation_params.dir != self.rootpath: + fullpath = self.rootpath / nodeid + nodeid = bestrelpath(self.invocation_params.dir, fullpath) return nodeid @classmethod - def fromdictargs(cls, option_dict, args): - """ constructor usable for subprocesses. """ + def fromdictargs(cls, option_dict, args) -> "Config": + """Constructor usable for subprocesses.""" config = get_config(args) config.option.__dict__.update(option_dict) config.parse(args, addopts=False) @@ -895,24 +1066,32 @@ def _processopt(self, opt: "Argument") -> None: setattr(self.option, opt.dest, opt.default) @hookimpl(trylast=True) - def pytest_load_initial_conftests(self, early_config): + def pytest_load_initial_conftests(self, early_config: "Config") -> None: self.pluginmanager._set_initial_conftests(early_config.known_args_namespace) def _initini(self, args: Sequence[str]) -> None: ns, unknown_args = self._parser.parse_known_and_unknown_args( args, namespace=copy.copy(self.option) ) - r = determine_setup( + rootpath, inipath, inicfg = determine_setup( ns.inifilename, ns.file_or_dir + unknown_args, rootdir_cmd_arg=ns.rootdir or None, config=self, ) - self.rootdir, self.inifile, self.inicfg = r - self._parser.extra_info["rootdir"] = self.rootdir - self._parser.extra_info["inifile"] = self.inifile + self._rootpath = rootpath + self._inipath = inipath + self.inicfg = inicfg + self._parser.extra_info["rootdir"] = str(self.rootpath) + self._parser.extra_info["inifile"] = str(self.inipath) self._parser.addini("addopts", "extra command line options", "args") self._parser.addini("minversion", "minimally required pytest version") + self._parser.addini( + "required_plugins", + "plugins that must be present for pytest to run", + type="args", + default=[], + ) self._override_ini = ns.override_ini or () def _consider_importhook(self, args: Sequence[str]) -> None: @@ -933,14 +1112,12 @@ def _consider_importhook(self, args: Sequence[str]) -> None: mode = "plain" else: self._mark_plugins_for_rewrite(hook) - _warn_about_missing_assertion(mode) + self._warn_about_missing_assertion(mode) - def _mark_plugins_for_rewrite(self, hook): - """ - Given an importhook, mark for rewrite any top-level + def _mark_plugins_for_rewrite(self, hook) -> None: + """Given an importhook, mark for rewrite any top-level modules or packages in the distribution package for - all pytest plugins. - """ + all pytest plugins.""" self.pluginmanager.rewrite_hook = hook if os.environ.get("PYTEST_DISABLE_PLUGIN_AUTOLOAD"): @@ -983,6 +1160,9 @@ def _preparse(self, args: List[str], addopts: bool = True) -> None: self._validate_args(self.getini("addopts"), "via addopts config") + args ) + self.known_args_namespace = self._parser.parse_known_args( + args, namespace=copy.copy(self.option) + ) self._checkversion() self._consider_importhook(args) self.pluginmanager.consider_preparse(args, exclude_only=False) @@ -991,50 +1171,109 @@ def _preparse(self, args: List[str], addopts: bool = True) -> None: # plugins are going to be loaded. self.pluginmanager.load_setuptools_entrypoints("pytest11") self.pluginmanager.consider_env() - self.known_args_namespace = ns = self._parser.parse_known_args( - args, namespace=copy.copy(self.option) + + self.known_args_namespace = self._parser.parse_known_args( + args, namespace=copy.copy(self.known_args_namespace) ) - if self.known_args_namespace.confcutdir is None and self.inifile: - confcutdir = py.path.local(self.inifile).dirname + + self._validate_plugins() + self._warn_about_skipped_plugins() + + if self.known_args_namespace.strict: + self.issue_config_time_warning( + _pytest.deprecated.STRICT_OPTION, stacklevel=2 + ) + + if self.known_args_namespace.confcutdir is None and self.inipath is not None: + confcutdir = str(self.inipath.parent) self.known_args_namespace.confcutdir = confcutdir try: self.hook.pytest_load_initial_conftests( early_config=self, args=args, parser=self._parser ) except ConftestImportFailure as e: - if ns.help or ns.version: + if self.known_args_namespace.help or self.known_args_namespace.version: # we don't want to prevent --help/--version to work # so just let is pass and print a warning at the end - from _pytest.warnings import _issue_warning_captured - - _issue_warning_captured( - PytestConfigWarning( - "could not load initial conftests: {}".format(e.path) - ), - self.hook, + self.issue_config_time_warning( + PytestConfigWarning(f"could not load initial conftests: {e.path}"), stacklevel=2, ) else: raise - def _checkversion(self): + @hookimpl(hookwrapper=True) + def pytest_collection(self) -> Generator[None, None, None]: + """Validate invalid ini keys after collection is done so we take in account + options added by late-loading conftest files.""" + yield + self._validate_config_options() + + def _checkversion(self) -> None: import pytest minver = self.inicfg.get("minversion", None) if minver: + # Imported lazily to improve start-up time. + from packaging.version import Version + + if not isinstance(minver, str): + raise pytest.UsageError( + "%s: 'minversion' must be a single value" % self.inipath + ) + if Version(minver) > Version(pytest.__version__): raise pytest.UsageError( - "%s:%d: requires pytest-%s, actual pytest-%s'" - % ( - self.inicfg.config.path, - self.inicfg.lineof("minversion"), - minver, - pytest.__version__, - ) + "%s: 'minversion' requires pytest-%s, actual pytest-%s'" + % (self.inipath, minver, pytest.__version__,) ) + def _validate_config_options(self) -> None: + for key in sorted(self._get_unknown_ini_keys()): + self._warn_or_fail_if_strict(f"Unknown config option: {key}\n") + + def _validate_plugins(self) -> None: + required_plugins = sorted(self.getini("required_plugins")) + if not required_plugins: + return + + # Imported lazily to improve start-up time. + from packaging.version import Version + from packaging.requirements import InvalidRequirement, Requirement + + plugin_info = self.pluginmanager.list_plugin_distinfo() + plugin_dist_info = {dist.project_name: dist.version for _, dist in plugin_info} + + missing_plugins = [] + for required_plugin in required_plugins: + try: + spec = Requirement(required_plugin) + except InvalidRequirement: + missing_plugins.append(required_plugin) + continue + + if spec.name not in plugin_dist_info: + missing_plugins.append(required_plugin) + elif Version(plugin_dist_info[spec.name]) not in spec.specifier: + missing_plugins.append(required_plugin) + + if missing_plugins: + raise UsageError( + "Missing required plugins: {}".format(", ".join(missing_plugins)), + ) + + def _warn_or_fail_if_strict(self, message: str) -> None: + if self.known_args_namespace.strict_config: + raise UsageError(message) + + self.issue_config_time_warning(PytestConfigWarning(message), stacklevel=3) + + def _get_unknown_ini_keys(self) -> List[str]: + parser_inicfg = self._parser._inidict + return [name for name in self.inicfg if name not in parser_inicfg] + def parse(self, args: List[str], addopts: bool = True) -> None: - # parse given cmdline arguments into this config object. + # Parse given cmdline arguments into this config object. assert not hasattr( self, "args" ), "can only parse cmdline args at most once per Config object" @@ -1050,40 +1289,85 @@ def parse(self, args: List[str], addopts: bool = True) -> None: args, self.option, namespace=self.option ) if not args: - if self.invocation_dir == self.rootdir: + if self.invocation_params.dir == self.rootpath: args = self.getini("testpaths") if not args: - args = [str(self.invocation_dir)] + args = [str(self.invocation_params.dir)] self.args = args except PrintHelp: pass - def addinivalue_line(self, name, line): - """ add a line to an ini-file option. The option must have been - declared but might not yet be set in which case the line becomes the - the first line in its value. """ + def issue_config_time_warning(self, warning: Warning, stacklevel: int) -> None: + """Issue and handle a warning during the "configure" stage. + + During ``pytest_configure`` we can't capture warnings using the ``catch_warnings_for_item`` + function because it is not possible to have hookwrappers around ``pytest_configure``. + + This function is mainly intended for plugins that need to issue warnings during + ``pytest_configure`` (or similar stages). + + :param warning: The warning instance. + :param stacklevel: stacklevel forwarded to warnings.warn. + """ + if self.pluginmanager.is_blocked("warnings"): + return + + cmdline_filters = self.known_args_namespace.pythonwarnings or [] + config_filters = self.getini("filterwarnings") + + with warnings.catch_warnings(record=True) as records: + warnings.simplefilter("always", type(warning)) + apply_warning_filters(config_filters, cmdline_filters) + warnings.warn(warning, stacklevel=stacklevel) + + if records: + frame = sys._getframe(stacklevel - 1) + location = frame.f_code.co_filename, frame.f_lineno, frame.f_code.co_name + self.hook.pytest_warning_captured.call_historic( + kwargs=dict( + warning_message=records[0], + when="config", + item=None, + location=location, + ) + ) + self.hook.pytest_warning_recorded.call_historic( + kwargs=dict( + warning_message=records[0], + when="config", + nodeid="", + location=location, + ) + ) + + def addinivalue_line(self, name: str, line: str) -> None: + """Add a line to an ini-file option. The option must have been + declared but might not yet be set in which case the line becomes + the first line in its value.""" x = self.getini(name) assert isinstance(x, list) x.append(line) # modifies the cached list inline def getini(self, name: str): - """ return configuration value from an :ref:`ini file `. If the - specified name hasn't been registered through a prior + """Return configuration value from an :ref:`ini file `. + + If the specified name hasn't been registered through a prior :py:func:`parser.addini <_pytest.config.argparsing.Parser.addini>` - call (usually from a plugin), a ValueError is raised. """ + call (usually from a plugin), a ValueError is raised. + """ try: return self._inicache[name] except KeyError: self._inicache[name] = val = self._getini(name) return val - def _getini(self, name: str) -> Any: + def _getini(self, name: str): try: description, type, default = self._parser._inidict[name] - except KeyError: - raise ValueError("unknown configuration value: {!r}".format(name)) - value = self._get_override_ini_value(name) - if value is None: + except KeyError as e: + raise ValueError(f"unknown configuration value: {name!r}") from e + override_value = self._get_override_ini_value(name) + if override_value is None: try: value = self.inicfg[name] except KeyError: @@ -1092,62 +1376,86 @@ def _getini(self, name: str) -> Any: if type is None: return "" return [] + else: + value = override_value + # Coerce the values based on types. + # + # Note: some coercions are only required if we are reading from .ini files, because + # the file format doesn't contain type information, but when reading from toml we will + # get either str or list of str values (see _parse_ini_config_from_pyproject_toml). + # For example: + # + # ini: + # a_line_list = "tests acceptance" + # in this case, we need to split the string to obtain a list of strings. + # + # toml: + # a_line_list = ["tests", "acceptance"] + # in this case, we already have a list ready to use. + # if type == "pathlist": - dp = py.path.local(self.inicfg.config.path).dirpath() - values = [] - for relpath in shlex.split(value): - values.append(dp.join(relpath, abs=True)) - return values + # TODO: This assert is probably not valid in all cases. + assert self.inipath is not None + dp = self.inipath.parent + input_values = shlex.split(value) if isinstance(value, str) else value + return [py.path.local(str(dp / x)) for x in input_values] elif type == "args": - return shlex.split(value) + return shlex.split(value) if isinstance(value, str) else value elif type == "linelist": - return [t for t in map(lambda x: x.strip(), value.split("\n")) if t] + if isinstance(value, str): + return [t for t in map(lambda x: x.strip(), value.split("\n")) if t] + else: + return value elif type == "bool": - return bool(_strtobool(value.strip())) + return _strtobool(str(value).strip()) else: - assert type is None + assert type in [None, "string"] return value - def _getconftest_pathlist(self, name, path): + def _getconftest_pathlist( + self, name: str, path: py.path.local + ) -> Optional[List[py.path.local]]: try: - mod, relroots = self.pluginmanager._rget_with_confmod(name, path) + mod, relroots = self.pluginmanager._rget_with_confmod( + name, path, self.getoption("importmode") + ) except KeyError: return None modpath = py.path.local(mod.__file__).dirpath() - values = [] + values: List[py.path.local] = [] for relroot in relroots: if not isinstance(relroot, py.path.local): - relroot = relroot.replace("/", py.path.local.sep) + relroot = relroot.replace("/", os.sep) relroot = modpath.join(relroot, abs=True) values.append(relroot) return values def _get_override_ini_value(self, name: str) -> Optional[str]: value = None - # override_ini is a list of "ini=value" options - # always use the last item if multiple values are set for same ini-name, - # e.g. -o foo=bar1 -o foo=bar2 will set foo to bar2 + # override_ini is a list of "ini=value" options. + # Always use the last item if multiple values are set for same ini-name, + # e.g. -o foo=bar1 -o foo=bar2 will set foo to bar2. for ini_config in self._override_ini: try: key, user_ini_value = ini_config.split("=", 1) - except ValueError: + except ValueError as e: raise UsageError( "-o/--override-ini expects option=value style (got: {!r}).".format( ini_config ) - ) + ) from e else: if key == name: value = user_ini_value return value def getoption(self, name: str, default=notset, skip: bool = False): - """ return command line option value. + """Return command line option value. - :arg name: name of the option. You may also specify + :param name: Name of the option. You may also specify the literal ``--OPT`` option instead of the "dest" option name. - :arg default: default value if no option of that name exists. - :arg skip: if True raise pytest.skip if option does not exists + :param default: Default value if no option of that name exists. + :param skip: If True, raise pytest.skip if option does not exists or has a None value. """ name = self._opt2dest.get(name, name) @@ -1156,77 +1464,143 @@ def getoption(self, name: str, default=notset, skip: bool = False): if val is None and skip: raise AttributeError(name) return val - except AttributeError: + except AttributeError as e: if default is not notset: return default if skip: import pytest - pytest.skip("no {!r} option found".format(name)) - raise ValueError("no option named {!r}".format(name)) + pytest.skip(f"no {name!r} option found") + raise ValueError(f"no option named {name!r}") from e - def getvalue(self, name, path=None): - """ (deprecated, use getoption()) """ + def getvalue(self, name: str, path=None): + """Deprecated, use getoption() instead.""" return self.getoption(name) - def getvalueorskip(self, name, path=None): - """ (deprecated, use getoption(skip=True)) """ + def getvalueorskip(self, name: str, path=None): + """Deprecated, use getoption(skip=True) instead.""" return self.getoption(name, skip=True) + def _warn_about_missing_assertion(self, mode: str) -> None: + if not _assertion_supported(): + if mode == "plain": + warning_text = ( + "ASSERTIONS ARE NOT EXECUTED" + " and FAILING TESTS WILL PASS. Are you" + " using python -O?" + ) + else: + warning_text = ( + "assertions not in test modules or" + " plugins will be ignored" + " because assert statements are not executed " + "by the underlying Python interpreter " + "(are you using python -O?)\n" + ) + self.issue_config_time_warning( + PytestConfigWarning(warning_text), stacklevel=3, + ) + + def _warn_about_skipped_plugins(self) -> None: + for module_name, msg in self.pluginmanager.skipped_plugins: + self.issue_config_time_warning( + PytestConfigWarning(f"skipped plugin {module_name!r}: {msg}"), + stacklevel=2, + ) + -def _assertion_supported(): +def _assertion_supported() -> bool: try: assert False except AssertionError: return True else: - return False - + return False # type: ignore[unreachable] -def _warn_about_missing_assertion(mode): - if not _assertion_supported(): - if mode == "plain": - sys.stderr.write( - "WARNING: ASSERTIONS ARE NOT EXECUTED" - " and FAILING TESTS WILL PASS. Are you" - " using python -O?" - ) - else: - sys.stderr.write( - "WARNING: assertions not in test modules or" - " plugins will be ignored" - " because assert statements are not executed " - "by the underlying Python interpreter " - "(are you using python -O?)\n" - ) - -def create_terminal_writer(config: Config, *args, **kwargs) -> TerminalWriter: +def create_terminal_writer( + config: Config, file: Optional[TextIO] = None +) -> TerminalWriter: """Create a TerminalWriter instance configured according to the options - in the config object. Every code which requires a TerminalWriter object - and has access to a config object should use this function. + in the config object. + + Every code which requires a TerminalWriter object and has access to a + config object should use this function. """ - tw = TerminalWriter(*args, **kwargs) + tw = TerminalWriter(file=file) + if config.option.color == "yes": tw.hasmarkup = True - if config.option.color == "no": + elif config.option.color == "no": tw.hasmarkup = False + + if config.option.code_highlight == "yes": + tw.code_highlight = True + elif config.option.code_highlight == "no": + tw.code_highlight = False + return tw -def _strtobool(val): - """Convert a string representation of truth to true (1) or false (0). +def _strtobool(val: str) -> bool: + """Convert a string representation of truth to True or False. True values are 'y', 'yes', 't', 'true', 'on', and '1'; false values are 'n', 'no', 'f', 'false', 'off', and '0'. Raises ValueError if 'val' is anything else. - .. note:: copied from distutils.util + .. note:: Copied from distutils.util. """ val = val.lower() if val in ("y", "yes", "t", "true", "on", "1"): - return 1 + return True elif val in ("n", "no", "f", "false", "off", "0"): - return 0 + return False else: - raise ValueError("invalid truth value {!r}".format(val)) + raise ValueError(f"invalid truth value {val!r}") + + +@lru_cache(maxsize=50) +def parse_warning_filter( + arg: str, *, escape: bool +) -> Tuple[str, str, Type[Warning], str, int]: + """Parse a warnings filter string. + + This is copied from warnings._setoption, but does not apply the filter, + only parses it, and makes the escaping optional. + """ + parts = arg.split(":") + if len(parts) > 5: + raise warnings._OptionError(f"too many fields (max 5): {arg!r}") + while len(parts) < 5: + parts.append("") + action_, message, category_, module, lineno_ = [s.strip() for s in parts] + action: str = warnings._getaction(action_) # type: ignore[attr-defined] + category: Type[Warning] = warnings._getcategory(category_) # type: ignore[attr-defined] + if message and escape: + message = re.escape(message) + if module and escape: + module = re.escape(module) + r"\Z" + if lineno_: + try: + lineno = int(lineno_) + if lineno < 0: + raise ValueError + except (ValueError, OverflowError) as e: + raise warnings._OptionError(f"invalid lineno {lineno_!r}") from e + else: + lineno = 0 + return action, message, category, module, lineno + + +def apply_warning_filters( + config_filters: Iterable[str], cmdline_filters: Iterable[str] +) -> None: + """Applies pytest-configured filters to the warnings module""" + # Filters should have this precedence: cmdline options, config. + # Filters should be applied in the inverse order of precedence. + for arg in config_filters: + warnings.filterwarnings(*parse_warning_filter(arg, escape=False)) + + for arg in cmdline_filters: + warnings.filterwarnings(*parse_warning_filter(arg, escape=True)) diff --git a/src/_pytest/config/argparsing.py b/src/_pytest/config/argparsing.py index 140e04e9723..9a481965526 100644 --- a/src/_pytest/config/argparsing.py +++ b/src/_pytest/config/argparsing.py @@ -11,28 +11,31 @@ from typing import Optional from typing import Sequence from typing import Tuple +from typing import TYPE_CHECKING from typing import Union import py -from _pytest.compat import TYPE_CHECKING +import _pytest._io +from _pytest.compat import final from _pytest.config.exceptions import UsageError if TYPE_CHECKING: from typing import NoReturn - from typing_extensions import Literal # noqa: F401 + from typing_extensions import Literal FILE_OR_DIR = "file_or_dir" +@final class Parser: - """ Parser for command line arguments and ini-file values. + """Parser for command line arguments and ini-file values. - :ivar extra_info: dict of generic param -> value to display in case + :ivar extra_info: Dict of generic param -> value to display in case there's an error processing the command line arguments. """ - prog = None # type: Optional[str] + prog: Optional[str] = None def __init__( self, @@ -40,12 +43,12 @@ def __init__( processopt: Optional[Callable[["Argument"], None]] = None, ) -> None: self._anonymous = OptionGroup("custom options", parser=self) - self._groups = [] # type: List[OptionGroup] + self._groups: List[OptionGroup] = [] self._processopt = processopt self._usage = usage - self._inidict = {} # type: Dict[str, Tuple[str, Optional[str], Any]] - self._ininames = [] # type: List[str] - self.extra_info = {} # type: Dict[str, Any] + self._inidict: Dict[str, Tuple[str, Optional[str], Any]] = {} + self._ininames: List[str] = [] + self.extra_info: Dict[str, Any] = {} def processoption(self, option: "Argument") -> None: if self._processopt: @@ -55,11 +58,11 @@ def processoption(self, option: "Argument") -> None: def getgroup( self, name: str, description: str = "", after: Optional[str] = None ) -> "OptionGroup": - """ get (or create) a named option Group. + """Get (or create) a named option Group. - :name: name of the option group. - :description: long description for --help output. - :after: name of other group, used for ordering --help output. + :name: Name of the option group. + :description: Long description for --help output. + :after: Name of another group, used for ordering --help output. The returned group object has an ``addoption`` method with the same signature as :py:func:`parser.addoption @@ -78,15 +81,14 @@ def getgroup( return group def addoption(self, *opts: str, **attrs: Any) -> None: - """ register a command line option. + """Register a command line option. - :opts: option names, can be short or long options. - :attrs: same attributes which the ``add_argument()`` function of the - `argparse library - `_ + :opts: Option names, can be short or long options. + :attrs: Same attributes which the ``add_argument()`` function of the + `argparse library `_ accepts. - After command line parsing options are available on the pytest config + After command line parsing, options are available on the pytest config object via ``config.option.NAME`` where ``NAME`` is usually set by passing a ``dest`` attribute, for example ``addoption("--long", dest="NAME", ...)``. @@ -140,9 +142,7 @@ def parse_known_args( args: Sequence[Union[str, py.path.local]], namespace: Optional[argparse.Namespace] = None, ) -> argparse.Namespace: - """parses and returns a namespace object with known arguments at this - point. - """ + """Parse and return a namespace object with known arguments at this point.""" return self.parse_known_and_unknown_args(args, namespace=namespace)[0] def parse_known_and_unknown_args( @@ -150,9 +150,8 @@ def parse_known_and_unknown_args( args: Sequence[Union[str, py.path.local]], namespace: Optional[argparse.Namespace] = None, ) -> Tuple[argparse.Namespace, List[str]]: - """parses and returns a namespace object with known arguments, and - the remaining arguments unknown at this point. - """ + """Parse and return a namespace object with known arguments, and + the remaining arguments unknown at this point.""" optparser = self._getparser() strargs = [str(x) if isinstance(x, py.path.local) else x for x in args] return optparser.parse_known_args(strargs, namespace=namespace) @@ -161,29 +160,30 @@ def addini( self, name: str, help: str, - type: Optional["Literal['pathlist', 'args', 'linelist', 'bool']"] = None, + type: Optional[ + "Literal['string', 'pathlist', 'args', 'linelist', 'bool']" + ] = None, default=None, ) -> None: - """ register an ini-file option. + """Register an ini-file option. - :name: name of the ini-variable - :type: type of the variable, can be ``pathlist``, ``args``, ``linelist`` - or ``bool``. - :default: default value if no ini-file option exists but is queried. + :name: Name of the ini-variable. + :type: Type of the variable, can be ``string``, ``pathlist``, ``args``, + ``linelist`` or ``bool``. Defaults to ``string`` if ``None`` or + not passed. + :default: Default value if no ini-file option exists but is queried. The value of ini-variables can be retrieved via a call to :py:func:`config.getini(name) <_pytest.config.Config.getini>`. """ - assert type in (None, "pathlist", "args", "linelist", "bool") + assert type in (None, "string", "pathlist", "args", "linelist", "bool") self._inidict[name] = (help, type, default) self._ininames.append(name) class ArgumentError(Exception): - """ - Raised if an Argument instance is created with invalid or - inconsistent arguments. - """ + """Raised if an Argument instance is created with invalid or + inconsistent arguments.""" def __init__(self, msg: str, option: Union["Argument", str]) -> None: self.msg = msg @@ -191,26 +191,27 @@ def __init__(self, msg: str, option: Union["Argument", str]) -> None: def __str__(self) -> str: if self.option_id: - return "option {}: {}".format(self.option_id, self.msg) + return f"option {self.option_id}: {self.msg}" else: return self.msg class Argument: - """class that mimics the necessary behaviour of optparse.Option + """Class that mimics the necessary behaviour of optparse.Option. + + It's currently a least effort implementation and ignoring choices + and integer prefixes. - it's currently a least effort implementation - and ignoring choices and integer prefixes https://docs.python.org/3/library/optparse.html#optparse-standard-option-types """ _typ_map = {"int": int, "string": str, "float": float, "complex": complex} def __init__(self, *names: str, **attrs: Any) -> None: - """store parms in private vars for use in add_argument""" + """Store parms in private vars for use in add_argument.""" self._attrs = attrs - self._short_opts = [] # type: List[str] - self._long_opts = [] # type: List[str] + self._short_opts: List[str] = [] + self._long_opts: List[str] = [] if "%default" in (attrs.get("help") or ""): warnings.warn( 'pytest now uses argparse. "%default" should be' @@ -223,7 +224,7 @@ def __init__(self, *names: str, **attrs: Any) -> None: except KeyError: pass else: - # this might raise a keyerror as well, don't want to catch that + # This might raise a keyerror as well, don't want to catch that. if isinstance(typ, str): if typ == "choice": warnings.warn( @@ -246,17 +247,17 @@ def __init__(self, *names: str, **attrs: Any) -> None: stacklevel=4, ) attrs["type"] = Argument._typ_map[typ] - # used in test_parseopt -> test_parse_defaultgetter + # Used in test_parseopt -> test_parse_defaultgetter. self.type = attrs["type"] else: self.type = typ try: - # attribute existence is tested in Config._processopt + # Attribute existence is tested in Config._processopt. self.default = attrs["default"] except KeyError: pass self._set_opt_strings(names) - dest = attrs.get("dest") # type: Optional[str] + dest: Optional[str] = attrs.get("dest") if dest: self.dest = dest elif self._long_opts: @@ -264,15 +265,15 @@ def __init__(self, *names: str, **attrs: Any) -> None: else: try: self.dest = self._short_opts[0][1:] - except IndexError: + except IndexError as e: self.dest = "???" # Needed for the error repr. - raise ArgumentError("need a long or short option", self) + raise ArgumentError("need a long or short option", self) from e def names(self) -> List[str]: return self._short_opts + self._long_opts def attrs(self) -> Mapping[str, Any]: - # update any attributes set by processopt + # Update any attributes set by processopt. attrs = "default dest help".split() attrs.append(self.dest) for attr in attrs: @@ -288,9 +289,10 @@ def attrs(self) -> Mapping[str, Any]: return self._attrs def _set_opt_strings(self, opts: Sequence[str]) -> None: - """directly from optparse + """Directly from optparse. - might not be necessary as this is passed to argparse later on""" + Might not be necessary as this is passed to argparse later on. + """ for opt in opts: if len(opt) < 2: raise ArgumentError( @@ -316,7 +318,7 @@ def _set_opt_strings(self, opts: Sequence[str]) -> None: self._long_opts.append(opt) def __repr__(self) -> str: - args = [] # type: List[str] + args: List[str] = [] if self._short_opts: args += ["_short_opts: " + repr(self._short_opts)] if self._long_opts: @@ -335,16 +337,16 @@ def __init__( ) -> None: self.name = name self.description = description - self.options = [] # type: List[Argument] + self.options: List[Argument] = [] self.parser = parser def addoption(self, *optnames: str, **attrs: Any) -> None: - """ add an option to this group. + """Add an option to this group. - if a shortened version of a long option is specified it will + If a shortened version of a long option is specified, it will be suppressed in the help. addoption('--twowords', '--two-words') results in help showing '--two-words' only, but --twowords gets - accepted **and** the automatic destination is in args.twowords + accepted **and** the automatic destination is in args.twowords. """ conflict = set(optnames).intersection( name for opt in self.options for name in opt.names() @@ -385,16 +387,16 @@ def __init__( allow_abbrev=False, ) # extra_info is a dict of (param -> value) to display if there's - # an usage error to provide more contextual information to the user + # an usage error to provide more contextual information to the user. self.extra_info = extra_info if extra_info else {} def error(self, message: str) -> "NoReturn": """Transform argparse error message into UsageError.""" - msg = "{}: error: {}".format(self.prog, message) + msg = f"{self.prog}: error: {message}" if hasattr(self._parser, "_config_source_hint"): # Type ignored because the attribute is set dynamically. - msg = "{} ({})".format(msg, self._parser._config_source_hint) # type: ignore + msg = f"{msg} ({self._parser._config_source_hint})" # type: ignore raise UsageError(self.format_usage() + msg) @@ -404,14 +406,14 @@ def parse_args( # type: ignore args: Optional[Sequence[str]] = None, namespace: Optional[argparse.Namespace] = None, ) -> argparse.Namespace: - """allow splitting of positional arguments""" + """Allow splitting of positional arguments.""" parsed, unrecognized = self.parse_known_args(args, namespace) if unrecognized: for arg in unrecognized: if arg and arg[0] == "-": lines = ["unrecognized arguments: %s" % (" ".join(unrecognized))] for k, v in sorted(self.extra_info.items()): - lines.append(" {}: {}".format(k, v)) + lines.append(f" {k}: {v}") self.error("\n".join(lines)) getattr(parsed, FILE_OR_DIR).extend(unrecognized) return parsed @@ -456,26 +458,24 @@ def _parse_optional( class DropShorterLongHelpFormatter(argparse.HelpFormatter): - """shorten help for long options that differ only in extra hyphens + """Shorten help for long options that differ only in extra hyphens. - - collapse **long** options that are the same except for extra hyphens - - shortcut if there are only two options and one of them is a short one - - cache result on action object as this is called at least 2 times + - Collapse **long** options that are the same except for extra hyphens. + - Shortcut if there are only two options and one of them is a short one. + - Cache result on the action object as this is called at least 2 times. """ def __init__(self, *args: Any, **kwargs: Any) -> None: - """Use more accurate terminal width via pylib.""" + # Use more accurate terminal width. if "width" not in kwargs: - kwargs["width"] = py.io.get_terminal_width() + kwargs["width"] = _pytest._io.get_terminal_width() super().__init__(*args, **kwargs) def _format_action_invocation(self, action: argparse.Action) -> str: orgstr = argparse.HelpFormatter._format_action_invocation(self, action) if orgstr and orgstr[0] != "-": # only optional arguments return orgstr - res = getattr( - action, "_formatted_action_invocation", None - ) # type: Optional[str] + res: Optional[str] = getattr(action, "_formatted_action_invocation", None) if res: return res options = orgstr.split(", ") @@ -484,7 +484,7 @@ def _format_action_invocation(self, action: argparse.Action) -> str: action._formatted_action_invocation = orgstr # type: ignore return orgstr return_list = [] - short_long = {} # type: Dict[str, str] + short_long: Dict[str, str] = {} for option in options: if len(option) == 2 or option[2] == " ": continue @@ -508,3 +508,15 @@ def _format_action_invocation(self, action: argparse.Action) -> str: formatted_action_invocation = ", ".join(return_list) action._formatted_action_invocation = formatted_action_invocation # type: ignore return formatted_action_invocation + + def _split_lines(self, text, width): + """Wrap lines after splitting on original newlines. + + This allows to have explicit line breaks in the help text. + """ + import textwrap + + lines = [] + for line in text.splitlines(): + lines.extend(textwrap.wrap(line.strip(), width)) + return lines diff --git a/src/_pytest/config/exceptions.py b/src/_pytest/config/exceptions.py index 19fe5cb08ed..4f1320e758d 100644 --- a/src/_pytest/config/exceptions.py +++ b/src/_pytest/config/exceptions.py @@ -1,9 +1,11 @@ +from _pytest.compat import final + + +@final class UsageError(Exception): - """ error in pytest usage or invocation""" + """Error in pytest usage or invocation.""" class PrintHelp(Exception): - """Raised when pytest should print it's help to skip the rest of the + """Raised when pytest should print its help to skip the rest of the argument parsing and validation.""" - - pass diff --git a/src/_pytest/config/findpaths.py b/src/_pytest/config/findpaths.py index fb84160c1ff..2edf54536ba 100644 --- a/src/_pytest/config/findpaths.py +++ b/src/_pytest/config/findpaths.py @@ -1,111 +1,165 @@ import os -from typing import Any +from pathlib import Path +from typing import Dict from typing import Iterable from typing import List from typing import Optional +from typing import Sequence from typing import Tuple +from typing import TYPE_CHECKING +from typing import Union -import py +import iniconfig from .exceptions import UsageError -from _pytest.compat import TYPE_CHECKING from _pytest.outcomes import fail +from _pytest.pathlib import absolutepath +from _pytest.pathlib import commonpath if TYPE_CHECKING: - from . import Config # noqa: F401 + from . import Config -def exists(path, ignore=EnvironmentError): +def _parse_ini_config(path: Path) -> iniconfig.IniConfig: + """Parse the given generic '.ini' file using legacy IniConfig parser, returning + the parsed object. + + Raise UsageError if the file cannot be parsed. + """ try: - return path.check() - except ignore: - return False + return iniconfig.IniConfig(str(path)) + except iniconfig.ParseError as exc: + raise UsageError(str(exc)) from exc -def getcfg(args, config=None): - """ - Search the list of arguments for a valid ini-file for pytest, - and return a tuple of (rootdir, inifile, cfg-dict). +def load_config_dict_from_file( + filepath: Path, +) -> Optional[Dict[str, Union[str, List[str]]]]: + """Load pytest configuration from the given file path, if supported. - note: config is optional and used only to issue warnings explicitly (#2891). + Return None if the file does not contain valid pytest configuration. """ - inibasenames = ["pytest.ini", "tox.ini", "setup.cfg"] + + # Configuration from ini files are obtained from the [pytest] section, if present. + if filepath.suffix == ".ini": + iniconfig = _parse_ini_config(filepath) + + if "pytest" in iniconfig: + return dict(iniconfig["pytest"].items()) + else: + # "pytest.ini" files are always the source of configuration, even if empty. + if filepath.name == "pytest.ini": + return {} + + # '.cfg' files are considered if they contain a "[tool:pytest]" section. + elif filepath.suffix == ".cfg": + iniconfig = _parse_ini_config(filepath) + + if "tool:pytest" in iniconfig.sections: + return dict(iniconfig["tool:pytest"].items()) + elif "pytest" in iniconfig.sections: + # If a setup.cfg contains a "[pytest]" section, we raise a failure to indicate users that + # plain "[pytest]" sections in setup.cfg files is no longer supported (#3086). + fail(CFG_PYTEST_SECTION.format(filename="setup.cfg"), pytrace=False) + + # '.toml' files are considered if they contain a [tool.pytest.ini_options] table. + elif filepath.suffix == ".toml": + import toml + + config = toml.load(str(filepath)) + + result = config.get("tool", {}).get("pytest", {}).get("ini_options", None) + if result is not None: + # TOML supports richer data types than ini files (strings, arrays, floats, ints, etc), + # however we need to convert all scalar values to str for compatibility with the rest + # of the configuration system, which expects strings only. + def make_scalar(v: object) -> Union[str, List[str]]: + return v if isinstance(v, list) else str(v) + + return {k: make_scalar(v) for k, v in result.items()} + + return None + + +def locate_config( + args: Iterable[Path], +) -> Tuple[ + Optional[Path], Optional[Path], Dict[str, Union[str, List[str]]], +]: + """Search in the list of arguments for a valid ini-file for pytest, + and return a tuple of (rootdir, inifile, cfg-dict).""" + config_names = [ + "pytest.ini", + "pyproject.toml", + "tox.ini", + "setup.cfg", + ] args = [x for x in args if not str(x).startswith("-")] if not args: - args = [py.path.local()] + args = [Path.cwd()] for arg in args: - arg = py.path.local(arg) - for base in arg.parts(reverse=True): - for inibasename in inibasenames: - p = base.join(inibasename) - if exists(p): - try: - iniconfig = py.iniconfig.IniConfig(p) - except py.iniconfig.ParseError as exc: - raise UsageError(str(exc)) - - if ( - inibasename == "setup.cfg" - and "tool:pytest" in iniconfig.sections - ): - return base, p, iniconfig["tool:pytest"] - elif "pytest" in iniconfig.sections: - if inibasename == "setup.cfg" and config is not None: - - fail( - CFG_PYTEST_SECTION.format(filename=inibasename), - pytrace=False, - ) - return base, p, iniconfig["pytest"] - elif inibasename == "pytest.ini": - # allowed to be empty - return base, p, {} - return None, None, None - - -def get_common_ancestor(paths: Iterable[py.path.local]) -> py.path.local: - common_ancestor = None + argpath = absolutepath(arg) + for base in (argpath, *argpath.parents): + for config_name in config_names: + p = base / config_name + if p.is_file(): + ini_config = load_config_dict_from_file(p) + if ini_config is not None: + return base, p, ini_config + return None, None, {} + + +def get_common_ancestor(paths: Iterable[Path]) -> Path: + common_ancestor: Optional[Path] = None for path in paths: if not path.exists(): continue if common_ancestor is None: common_ancestor = path else: - if path.relto(common_ancestor) or path == common_ancestor: + if common_ancestor in path.parents or path == common_ancestor: continue - elif common_ancestor.relto(path): + elif path in common_ancestor.parents: common_ancestor = path else: - shared = path.common(common_ancestor) + shared = commonpath(path, common_ancestor) if shared is not None: common_ancestor = shared if common_ancestor is None: - common_ancestor = py.path.local() - elif common_ancestor.isfile(): - common_ancestor = common_ancestor.dirpath() + common_ancestor = Path.cwd() + elif common_ancestor.is_file(): + common_ancestor = common_ancestor.parent return common_ancestor -def get_dirs_from_args(args): - def is_option(x): - return str(x).startswith("-") +def get_dirs_from_args(args: Iterable[str]) -> List[Path]: + def is_option(x: str) -> bool: + return x.startswith("-") - def get_file_part_from_node_id(x): - return str(x).split("::")[0] + def get_file_part_from_node_id(x: str) -> str: + return x.split("::")[0] - def get_dir_from_path(path): - if path.isdir(): + def get_dir_from_path(path: Path) -> Path: + if path.is_dir(): return path - return py.path.local(path.dirname) + return path.parent + + def safe_exists(path: Path) -> bool: + # This can throw on paths that contain characters unrepresentable at the OS level, + # or with invalid syntax on Windows (https://bugs.python.org/issue35306) + try: + return path.exists() + except OSError: + return False # These look like paths but may not exist possible_paths = ( - py.path.local(get_file_part_from_node_id(arg)) + absolutepath(get_file_part_from_node_id(arg)) for arg in args if not is_option(arg) ) - return [get_dir_from_path(path) for path in possible_paths if path.exists()] + return [get_dir_from_path(path) for path in possible_paths if safe_exists(path)] CFG_PYTEST_SECTION = "[pytest] section in {filename} files is no longer supported, change to [tool:pytest] instead." @@ -113,55 +167,45 @@ def get_dir_from_path(path): def determine_setup( inifile: Optional[str], - args: List[str], + args: Sequence[str], rootdir_cmd_arg: Optional[str] = None, config: Optional["Config"] = None, -) -> Tuple[py.path.local, Optional[str], Any]: +) -> Tuple[Path, Optional[Path], Dict[str, Union[str, List[str]]]]: + rootdir = None dirs = get_dirs_from_args(args) if inifile: - iniconfig = py.iniconfig.IniConfig(inifile) - is_cfg_file = str(inifile).endswith(".cfg") - sections = ["tool:pytest", "pytest"] if is_cfg_file else ["pytest"] - for section in sections: - try: - inicfg = iniconfig[ - section - ] # type: Optional[py.iniconfig._SectionWrapper] - if is_cfg_file and section == "pytest" and config is not None: - fail( - CFG_PYTEST_SECTION.format(filename=str(inifile)), pytrace=False - ) - break - except KeyError: - inicfg = None + inipath_ = absolutepath(inifile) + inipath: Optional[Path] = inipath_ + inicfg = load_config_dict_from_file(inipath_) or {} if rootdir_cmd_arg is None: rootdir = get_common_ancestor(dirs) else: ancestor = get_common_ancestor(dirs) - rootdir, inifile, inicfg = getcfg([ancestor], config=config) + rootdir, inipath, inicfg = locate_config([ancestor]) if rootdir is None and rootdir_cmd_arg is None: - for possible_rootdir in ancestor.parts(reverse=True): - if possible_rootdir.join("setup.py").exists(): + for possible_rootdir in (ancestor, *ancestor.parents): + if (possible_rootdir / "setup.py").is_file(): rootdir = possible_rootdir break else: if dirs != [ancestor]: - rootdir, inifile, inicfg = getcfg(dirs, config=config) + rootdir, inipath, inicfg = locate_config(dirs) if rootdir is None: if config is not None: - cwd = config.invocation_dir + cwd = config.invocation_params.dir else: - cwd = py.path.local() + cwd = Path.cwd() rootdir = get_common_ancestor([cwd, ancestor]) is_fs_root = os.path.splitdrive(str(rootdir))[1] == "/" if is_fs_root: rootdir = ancestor if rootdir_cmd_arg: - rootdir = py.path.local(os.path.expandvars(rootdir_cmd_arg)) - if not rootdir.isdir(): + rootdir = absolutepath(os.path.expandvars(rootdir_cmd_arg)) + if not rootdir.is_dir(): raise UsageError( "Directory '{}' not found. Check your '--rootdir' option.".format( rootdir ) ) - return rootdir, inifile, inicfg or {} + assert rootdir is not None + return rootdir, inipath, inicfg or {} diff --git a/src/_pytest/debugging.py b/src/_pytest/debugging.py index 9155d7e98e3..d3a5c6173f3 100644 --- a/src/_pytest/debugging.py +++ b/src/_pytest/debugging.py @@ -1,25 +1,46 @@ -""" interactive debugging with PDB, the Python Debugger. """ +"""Interactive debugging with PDB, the Python Debugger.""" import argparse import functools import sys +import types +from typing import Any +from typing import Callable +from typing import Generator +from typing import List +from typing import Optional +from typing import Tuple +from typing import Type +from typing import TYPE_CHECKING +from typing import Union from _pytest import outcomes +from _pytest._code import ExceptionInfo +from _pytest.config import Config +from _pytest.config import ConftestImportFailure from _pytest.config import hookimpl +from _pytest.config import PytestPluginManager +from _pytest.config.argparsing import Parser from _pytest.config.exceptions import UsageError +from _pytest.nodes import Node +from _pytest.reports import BaseReport +if TYPE_CHECKING: + from _pytest.capture import CaptureManager + from _pytest.runner import CallInfo -def _validate_usepdb_cls(value): + +def _validate_usepdb_cls(value: str) -> Tuple[str, str]: """Validate syntax of --pdbcls option.""" try: modname, classname = value.split(":") - except ValueError: + except ValueError as e: raise argparse.ArgumentTypeError( - "{!r} is not in the format 'modname:classname'".format(value) - ) + f"{value!r} is not in the format 'modname:classname'" + ) from e return (modname, classname) -def pytest_addoption(parser): +def pytest_addoption(parser: Parser) -> None: group = parser.getgroup("general") group._addoption( "--pdb", @@ -43,7 +64,7 @@ def pytest_addoption(parser): ) -def pytest_configure(config): +def pytest_configure(config: Config) -> None: import pdb if config.getvalue("trace"): @@ -60,7 +81,7 @@ def pytest_configure(config): # NOTE: not using pytest_unconfigure, since it might get called although # pytest_configure was not (if another plugin raises UsageError). - def fin(): + def fin() -> None: ( pdb.set_trace, pytestPDB._pluginmanager, @@ -71,22 +92,24 @@ def fin(): class pytestPDB: - """ Pseudo PDB that defers to the real pdb. """ + """Pseudo PDB that defers to the real pdb.""" - _pluginmanager = None - _config = None - _saved = [] # type: list + _pluginmanager: Optional[PytestPluginManager] = None + _config: Optional[Config] = None + _saved: List[ + Tuple[Callable[..., None], Optional[PytestPluginManager], Optional[Config]] + ] = [] _recursive_debug = 0 - _wrapped_pdb_cls = None + _wrapped_pdb_cls: Optional[Tuple[Type[Any], Type[Any]]] = None @classmethod - def _is_capturing(cls, capman): + def _is_capturing(cls, capman: Optional["CaptureManager"]) -> Union[str, bool]: if capman: return capman.is_capturing() return False @classmethod - def _import_pdb_cls(cls, capman): + def _import_pdb_cls(cls, capman: Optional["CaptureManager"]): if not cls._config: import pdb @@ -113,8 +136,8 @@ def _import_pdb_cls(cls, capman): except Exception as exc: value = ":".join((modname, classname)) raise UsageError( - "--pdbcls: could not import {!r}: {}".format(value, exc) - ) + f"--pdbcls: could not import {value!r}: {exc}" + ) from exc else: import pdb @@ -125,10 +148,12 @@ def _import_pdb_cls(cls, capman): return wrapped_cls @classmethod - def _get_pdb_wrapper_class(cls, pdb_cls, capman): + def _get_pdb_wrapper_class(cls, pdb_cls, capman: Optional["CaptureManager"]): import _pytest.config - class PytestPdbWrapper(pdb_cls): + # Type ignored because mypy doesn't support "dynamic" + # inheritance like this. + class PytestPdbWrapper(pdb_cls): # type: ignore[valid-type,misc] _pytest_capman = capman _continued = False @@ -141,6 +166,7 @@ def do_debug(self, arg): def do_continue(self, arg): ret = super().do_continue(arg) if cls._recursive_debug == 0: + assert cls._config is not None tw = _pytest.config.create_terminal_writer(cls._config) tw.line() @@ -155,9 +181,11 @@ def do_continue(self, arg): "PDB continue (IO-capturing resumed for %s)" % capturing, ) + assert capman is not None capman.resume() else: tw.sep(">", "PDB continue") + assert cls._pluginmanager is not None cls._pluginmanager.hook.pytest_leave_pdb(config=cls._config, pdb=self) self._continued = True return ret @@ -208,13 +236,13 @@ def get_stack(self, f, t): @classmethod def _init_pdb(cls, method, *args, **kwargs): - """ Initialize PDB debugging, dropping any IO capturing. """ + """Initialize PDB debugging, dropping any IO capturing.""" import _pytest.config - if cls._pluginmanager is not None: - capman = cls._pluginmanager.getplugin("capturemanager") + if cls._pluginmanager is None: + capman: Optional[CaptureManager] = None else: - capman = None + capman = cls._pluginmanager.getplugin("capturemanager") if capman: capman.suspend(in_=True) @@ -230,7 +258,7 @@ def _init_pdb(cls, method, *args, **kwargs): else: capturing = cls._is_capturing(capman) if capturing == "global": - tw.sep(">", "PDB {} (IO-capturing turned off)".format(method)) + tw.sep(">", f"PDB {method} (IO-capturing turned off)") elif capturing: tw.sep( ">", @@ -238,7 +266,7 @@ def _init_pdb(cls, method, *args, **kwargs): % (method, capturing), ) else: - tw.sep(">", "PDB {}".format(method)) + tw.sep(">", f"PDB {method}") _pdb = cls._import_pdb_cls(capman)(**kwargs) @@ -247,7 +275,7 @@ def _init_pdb(cls, method, *args, **kwargs): return _pdb @classmethod - def set_trace(cls, *args, **kwargs): + def set_trace(cls, *args, **kwargs) -> None: """Invoke debugging via ``Pdb.set_trace``, dropping any IO capturing.""" frame = sys._getframe().f_back _pdb = cls._init_pdb("set_trace", *args, **kwargs) @@ -255,34 +283,41 @@ def set_trace(cls, *args, **kwargs): class PdbInvoke: - def pytest_exception_interact(self, node, call, report): + def pytest_exception_interact( + self, node: Node, call: "CallInfo[Any]", report: BaseReport + ) -> None: capman = node.config.pluginmanager.getplugin("capturemanager") if capman: capman.suspend_global_capture(in_=True) out, err = capman.read_global_capture() sys.stdout.write(out) sys.stdout.write(err) + assert call.excinfo is not None _enter_pdb(node, call.excinfo, report) - def pytest_internalerror(self, excrepr, excinfo): + def pytest_internalerror(self, excinfo: ExceptionInfo[BaseException]) -> None: tb = _postmortem_traceback(excinfo) post_mortem(tb) class PdbTrace: @hookimpl(hookwrapper=True) - def pytest_pyfunc_call(self, pyfuncitem): - _test_pytest_function(pyfuncitem) + def pytest_pyfunc_call(self, pyfuncitem) -> Generator[None, None, None]: + wrap_pytest_function_for_tracing(pyfuncitem) yield -def _test_pytest_function(pyfuncitem): +def wrap_pytest_function_for_tracing(pyfuncitem): + """Change the Python function object of the given Function item by a + wrapper which actually enters pdb before calling the python function + itself, effectively leaving the user in the pdb prompt in the first + statement of the function.""" _pdb = pytestPDB._init_pdb("runcall") testfunction = pyfuncitem.obj # we can't just return `partial(pdb.runcall, testfunction)` because (on # python < 3.7.4) runcall's first param is `func`, which means we'd get - # an exception if one of the kwargs to testfunction was called `func` + # an exception if one of the kwargs to testfunction was called `func`. @functools.wraps(testfunction) def wrapper(*args, **kwargs): func = functools.partial(testfunction, *args, **kwargs) @@ -291,7 +326,16 @@ def wrapper(*args, **kwargs): pyfuncitem.obj = wrapper -def _enter_pdb(node, excinfo, rep): +def maybe_wrap_pytest_function_for_tracing(pyfuncitem): + """Wrap the given pytestfunct item for tracing support if --trace was given in + the command line.""" + if pyfuncitem.config.getvalue("trace"): + wrap_pytest_function_for_tracing(pyfuncitem) + + +def _enter_pdb( + node: Node, excinfo: ExceptionInfo[BaseException], rep: BaseReport +) -> BaseReport: # XXX we re-use the TerminalReporter's terminalwriter # because this seems to avoid some encoding related troubles # for not completely clear reasons. @@ -315,23 +359,28 @@ def _enter_pdb(node, excinfo, rep): rep.toterminal(tw) tw.sep(">", "entering PDB") tb = _postmortem_traceback(excinfo) - rep._pdbshown = True + rep._pdbshown = True # type: ignore[attr-defined] post_mortem(tb) return rep -def _postmortem_traceback(excinfo): +def _postmortem_traceback(excinfo: ExceptionInfo[BaseException]) -> types.TracebackType: from doctest import UnexpectedException if isinstance(excinfo.value, UnexpectedException): # A doctest.UnexpectedException is not useful for post_mortem. # Use the underlying exception instead: return excinfo.value.exc_info[2] + elif isinstance(excinfo.value, ConftestImportFailure): + # A config.ConftestImportFailure is not useful for post_mortem. + # Use the underlying exception instead: + return excinfo.value.excinfo[2] else: + assert excinfo._excinfo is not None return excinfo._excinfo[2] -def post_mortem(t): +def post_mortem(t: types.TracebackType) -> None: p = pytestPDB._init_pdb("post_mortem") p.reset() p.interaction(None, t) diff --git a/src/_pytest/deprecated.py b/src/_pytest/deprecated.py index 7e241ae1b39..19b31d66538 100644 --- a/src/_pytest/deprecated.py +++ b/src/_pytest/deprecated.py @@ -1,13 +1,15 @@ -""" -This module contains deprecation messages and bits of code used elsewhere in the codebase -that is planned to be removed in the next pytest release. +"""Deprecation messages and bits of code used elsewhere in the codebase that +is planned to be removed in the next pytest release. Keeping it in a central location makes it easy to track what is deprecated and should be removed when the time comes. -All constants defined in this module should be either PytestWarning instances or UnformattedWarning +All constants defined in this module should be either instances of +:class:`PytestWarning`, or :class:`UnformattedWarning` in case of warnings which need to format their messages. """ +from warnings import warn + from _pytest.warning_types import PytestDeprecationWarning from _pytest.warning_types import UnformattedWarning @@ -19,38 +21,67 @@ "pytest_faulthandler", } -FUNCARGNAMES = PytestDeprecationWarning( - "The `funcargnames` attribute was an alias for `fixturenames`, " - "since pytest 2.3 - use the newer attribute instead." + +FILLFUNCARGS = UnformattedWarning( + PytestDeprecationWarning, + "{name} is deprecated, use " + "function._request._fillfixtures() instead if you cannot avoid reaching into internals.", ) -RESULT_LOG = PytestDeprecationWarning( - "--result-log is deprecated, please try the new pytest-reportlog plugin.\n" - "See https://docs.pytest.org/en/latest/deprecations.html#result-log-result-log for more information." +PYTEST_COLLECT_MODULE = UnformattedWarning( + PytestDeprecationWarning, + "pytest.collect.{name} was moved to pytest.{name}\n" + "Please update to the new name.", ) -FIXTURE_POSITIONAL_ARGUMENTS = PytestDeprecationWarning( - "Passing arguments to pytest.fixture() as positional arguments is deprecated - pass them " - "as a keyword argument instead." +YIELD_FIXTURE = PytestDeprecationWarning( + "@pytest.yield_fixture is deprecated.\n" + "Use @pytest.fixture instead; they are the same." ) -NODE_USE_FROM_PARENT = UnformattedWarning( - PytestDeprecationWarning, - "direct construction of {name} has been deprecated, please use {name}.from_parent", +MINUS_K_DASH = PytestDeprecationWarning( + "The `-k '-expr'` syntax to -k is deprecated.\nUse `-k 'not expr'` instead." ) -JUNIT_XML_DEFAULT_FAMILY = PytestDeprecationWarning( - "The 'junit_family' default value will change to 'xunit2' in pytest 6.0.\n" - "Add 'junit_family=xunit1' to your pytest.ini file to keep the current format " - "in future versions of pytest and silence this warning." +MINUS_K_COLON = PytestDeprecationWarning( + "The `-k 'expr:'` syntax to -k is deprecated.\n" + "Please open an issue if you use this and want a replacement." ) -NO_PRINT_LOGS = PytestDeprecationWarning( - "--no-print-logs is deprecated and scheduled for removal in pytest 6.0.\n" - "Please use --show-capture instead." +WARNING_CAPTURED_HOOK = PytestDeprecationWarning( + "The pytest_warning_captured is deprecated and will be removed in a future release.\n" + "Please use pytest_warning_recorded instead." ) -COLLECT_DIRECTORY_HOOK = PytestDeprecationWarning( - "The pytest_collect_directory hook is not working.\n" - "Please use collect_ignore in conftests or pytest_collection_modifyitems." +FSCOLLECTOR_GETHOOKPROXY_ISINITPATH = PytestDeprecationWarning( + "The gethookproxy() and isinitpath() methods of FSCollector and Package are deprecated; " + "use self.session.gethookproxy() and self.session.isinitpath() instead. " ) + +STRICT_OPTION = PytestDeprecationWarning( + "The --strict option is deprecated, use --strict-markers instead." +) + +PRIVATE = PytestDeprecationWarning("A private pytest class or function was used.") + + +# You want to make some `__init__` or function "private". +# +# def my_private_function(some, args): +# ... +# +# Do this: +# +# def my_private_function(some, args, *, _ispytest: bool = False): +# check_ispytest(_ispytest) +# ... +# +# Change all internal/allowed calls to +# +# my_private_function(some, args, _ispytest=True) +# +# All other calls will get the default _ispytest=False and trigger +# the warning (possibly error in the future). +def check_ispytest(ispytest: bool) -> None: + if not ispytest: + warn(PRIVATE, stacklevel=3) diff --git a/src/_pytest/doctest.py b/src/_pytest/doctest.py index 02108477818..64e8f0e0eee 100644 --- a/src/_pytest/doctest.py +++ b/src/_pytest/doctest.py @@ -1,16 +1,24 @@ -""" discover and run doctests in modules and test files.""" +"""Discover and run doctests in modules and test files.""" import bdb import inspect import platform import sys import traceback +import types import warnings from contextlib import contextmanager +from typing import Any +from typing import Callable from typing import Dict +from typing import Generator +from typing import Iterable from typing import List from typing import Optional +from typing import Pattern from typing import Sequence from typing import Tuple +from typing import Type +from typing import TYPE_CHECKING from typing import Union import py.path @@ -22,15 +30,17 @@ from _pytest._code.code import TerminalRepr from _pytest._io import TerminalWriter from _pytest.compat import safe_getattr -from _pytest.compat import TYPE_CHECKING +from _pytest.config import Config +from _pytest.config.argparsing import Parser from _pytest.fixtures import FixtureRequest +from _pytest.nodes import Collector from _pytest.outcomes import OutcomeException +from _pytest.pathlib import import_path from _pytest.python_api import approx from _pytest.warning_types import PytestWarning if TYPE_CHECKING: import doctest - from typing import Type DOCTEST_REPORT_CHOICE_NONE = "none" DOCTEST_REPORT_CHOICE_CDIFF = "cdiff" @@ -49,10 +59,10 @@ # Lazy definition of runner class RUNNER_CLASS = None # Lazy definition of output checker class -CHECKER_CLASS = None # type: Optional[Type[doctest.OutputChecker]] +CHECKER_CLASS: Optional[Type["doctest.OutputChecker"]] = None -def pytest_addoption(parser): +def pytest_addoption(parser: Parser) -> None: parser.addini( "doctest_optionflags", "option flags for doctests", @@ -102,29 +112,34 @@ def pytest_addoption(parser): ) -def pytest_unconfigure(): +def pytest_unconfigure() -> None: global RUNNER_CLASS RUNNER_CLASS = None -def pytest_collect_file(path, parent): +def pytest_collect_file( + path: py.path.local, parent: Collector, +) -> Optional[Union["DoctestModule", "DoctestTextfile"]]: config = parent.config if path.ext == ".py": - if config.option.doctestmodules and not _is_setup_py(config, path, parent): - return DoctestModule.from_parent(parent, fspath=path) + if config.option.doctestmodules and not _is_setup_py(path): + mod: DoctestModule = DoctestModule.from_parent(parent, fspath=path) + return mod elif _is_doctest(config, path, parent): - return DoctestTextfile.from_parent(parent, fspath=path) + txt: DoctestTextfile = DoctestTextfile.from_parent(parent, fspath=path) + return txt + return None -def _is_setup_py(config, path, parent): +def _is_setup_py(path: py.path.local) -> bool: if path.basename != "setup.py": return False - contents = path.read() - return "setuptools" in contents or "distutils" in contents + contents = path.read_binary() + return b"setuptools" in contents or b"distutils" in contents -def _is_doctest(config, path, parent): +def _is_doctest(config: Config, path: py.path.local, parent) -> bool: if path.ext in (".txt", ".rst") and parent.session.isinitpath(path): return True globs = config.getoption("doctestglob") or ["test*.txt"] @@ -137,7 +152,7 @@ def _is_doctest(config, path, parent): class ReprFailDoctest(TerminalRepr): def __init__( self, reprlocation_lines: Sequence[Tuple[ReprFileLocation, Sequence[str]]] - ): + ) -> None: self.reprlocation_lines = reprlocation_lines def toterminal(self, tw: TerminalWriter) -> None: @@ -148,36 +163,49 @@ def toterminal(self, tw: TerminalWriter) -> None: class MultipleDoctestFailures(Exception): - def __init__(self, failures): + def __init__(self, failures: Sequence["doctest.DocTestFailure"]) -> None: super().__init__() self.failures = failures -def _init_runner_class() -> "Type[doctest.DocTestRunner]": +def _init_runner_class() -> Type["doctest.DocTestRunner"]: import doctest class PytestDoctestRunner(doctest.DebugRunner): - """ - Runner to collect failures. Note that the out variable in this case is - a list instead of a stdout-like object + """Runner to collect failures. + + Note that the out variable in this case is a list instead of a + stdout-like object. """ def __init__( - self, checker=None, verbose=None, optionflags=0, continue_on_failure=True - ): + self, + checker: Optional["doctest.OutputChecker"] = None, + verbose: Optional[bool] = None, + optionflags: int = 0, + continue_on_failure: bool = True, + ) -> None: doctest.DebugRunner.__init__( self, checker=checker, verbose=verbose, optionflags=optionflags ) self.continue_on_failure = continue_on_failure - def report_failure(self, out, test, example, got): + def report_failure( + self, out, test: "doctest.DocTest", example: "doctest.Example", got: str, + ) -> None: failure = doctest.DocTestFailure(test, example, got) if self.continue_on_failure: out.append(failure) else: raise failure - def report_unexpected_exception(self, out, test, example, exc_info): + def report_unexpected_exception( + self, + out, + test: "doctest.DocTest", + example: "doctest.Example", + exc_info: Tuple[Type[BaseException], BaseException, types.TracebackType], + ) -> None: if isinstance(exc_info[1], OutcomeException): raise exc_info[1] if isinstance(exc_info[1], bdb.BdbQuit): @@ -212,24 +240,33 @@ def _get_runner( class DoctestItem(pytest.Item): - def __init__(self, name, parent, runner=None, dtest=None): + def __init__( + self, + name: str, + parent: "Union[DoctestTextfile, DoctestModule]", + runner: Optional["doctest.DocTestRunner"] = None, + dtest: Optional["doctest.DocTest"] = None, + ) -> None: super().__init__(name, parent) self.runner = runner self.dtest = dtest self.obj = None - self.fixture_request = None + self.fixture_request: Optional[FixtureRequest] = None @classmethod def from_parent( # type: ignore - cls, parent: "Union[DoctestTextfile, DoctestModule]", *, name, runner, dtest + cls, + parent: "Union[DoctestTextfile, DoctestModule]", + *, + name: str, + runner: "doctest.DocTestRunner", + dtest: "doctest.DocTest", ): # incompatible signature due to to imposed limits on sublcass - """ - the public named constructor - """ + """The public named constructor.""" return super().from_parent(name=name, parent=parent, runner=runner, dtest=dtest) - def setup(self): + def setup(self) -> None: if self.dtest is not None: self.fixture_request = _setup_fixtures(self) globs = dict(getfixture=self.fixture_request.getfixturevalue) @@ -240,17 +277,19 @@ def setup(self): self.dtest.globs.update(globs) def runtest(self) -> None: + assert self.dtest is not None + assert self.runner is not None _check_all_skipped(self.dtest) self._disable_output_capturing_for_darwin() - failures = [] # type: List[doctest.DocTestFailure] - self.runner.run(self.dtest, out=failures) + failures: List["doctest.DocTestFailure"] = [] + # Type ignored because we change the type of `out` from what + # doctest expects. + self.runner.run(self.dtest, out=failures) # type: ignore[arg-type] if failures: raise MultipleDoctestFailures(failures) - def _disable_output_capturing_for_darwin(self): - """ - Disable output capturing. Otherwise, stdout is lost to doctest (#985) - """ + def _disable_output_capturing_for_darwin(self) -> None: + """Disable output capturing. Otherwise, stdout is lost to doctest (#985).""" if platform.system() != "Darwin": return capman = self.config.pluginmanager.getplugin("capturemanager") @@ -260,15 +299,20 @@ def _disable_output_capturing_for_darwin(self): sys.stdout.write(out) sys.stderr.write(err) - def repr_failure(self, excinfo): + # TODO: Type ignored -- breaks Liskov Substitution. + def repr_failure( # type: ignore[override] + self, excinfo: ExceptionInfo[BaseException], + ) -> Union[str, TerminalRepr]: import doctest - failures = ( - None - ) # type: Optional[List[Union[doctest.DocTestFailure, doctest.UnexpectedException]]] - if excinfo.errisinstance((doctest.DocTestFailure, doctest.UnexpectedException)): + failures: Optional[ + Sequence[Union[doctest.DocTestFailure, doctest.UnexpectedException]] + ] = (None) + if isinstance( + excinfo.value, (doctest.DocTestFailure, doctest.UnexpectedException) + ): failures = [excinfo.value] - elif excinfo.errisinstance(MultipleDoctestFailures): + elif isinstance(excinfo.value, MultipleDoctestFailures): failures = excinfo.value.failures if failures is not None: @@ -282,7 +326,8 @@ def repr_failure(self, excinfo): else: lineno = test.lineno + example.lineno + 1 message = type(failure).__name__ - reprlocation = ReprFileLocation(filename, lineno, message) + # TODO: ReprFileLocation doesn't expect a None lineno. + reprlocation = ReprFileLocation(filename, lineno, message) # type: ignore[arg-type] checker = _get_checker() report_choice = _get_report_choice( self.config.getoption("doctestreport") @@ -304,7 +349,7 @@ def repr_failure(self, excinfo): ] indent = ">>>" for line in example.source.splitlines(): - lines.append("??? {} {}".format(indent, line)) + lines.append(f"??? {indent} {line}") indent = "..." if isinstance(failure, doctest.DocTestFailure): lines += checker.output_difference( @@ -322,7 +367,8 @@ def repr_failure(self, excinfo): else: return super().repr_failure(excinfo) - def reportinfo(self) -> Tuple[py.path.local, int, str]: + def reportinfo(self): + assert self.dtest is not None return self.fspath, self.dtest.lineno, "[doctest] %s" % self.name @@ -355,7 +401,7 @@ def _get_continue_on_failure(config): continue_on_failure = config.getvalue("doctest_continue_on_failure") if continue_on_failure: # We need to turn off this if we use pdb since we should stop at - # the first failure + # the first failure. if config.getvalue("usepdb"): continue_on_failure = False return continue_on_failure @@ -364,11 +410,11 @@ def _get_continue_on_failure(config): class DoctestTextfile(pytest.Module): obj = None - def collect(self): + def collect(self) -> Iterable[DoctestItem]: import doctest - # inspired by doctest.testfile; ideally we would use it directly, - # but it doesn't support passing a custom checker + # Inspired by doctest.testfile; ideally we would use it directly, + # but it doesn't support passing a custom checker. encoding = self.config.getini("doctest_encoding") text = self.fspath.read_text(encoding) filename = str(self.fspath) @@ -392,10 +438,9 @@ def collect(self): ) -def _check_all_skipped(test): - """raises pytest.skip() if all examples in the given DocTest have the SKIP - option set. - """ +def _check_all_skipped(test: "doctest.DocTest") -> None: + """Raise pytest.skip() if all examples in the given DocTest have the SKIP + option set.""" import doctest all_skipped = all(x.options.get(doctest.SKIP, False) for x in test.examples) @@ -403,10 +448,9 @@ def _check_all_skipped(test): pytest.skip("all tests skipped by +SKIP option") -def _is_mocked(obj): - """ - returns if a object is possibly a mock object by checking the existence of a highly improbable attribute - """ +def _is_mocked(obj: object) -> bool: + """Return if an object is possibly a mock object by checking the + existence of a highly improbable attribute.""" return ( safe_getattr(obj, "pytest_mock_example_attribute_that_shouldnt_exist", None) is not None @@ -414,23 +458,24 @@ def _is_mocked(obj): @contextmanager -def _patch_unwrap_mock_aware(): - """ - contextmanager which replaces ``inspect.unwrap`` with a version - that's aware of mock objects and doesn't recurse on them - """ +def _patch_unwrap_mock_aware() -> Generator[None, None, None]: + """Context manager which replaces ``inspect.unwrap`` with a version + that's aware of mock objects and doesn't recurse into them.""" real_unwrap = inspect.unwrap - def _mock_aware_unwrap(obj, stop=None): + def _mock_aware_unwrap( + func: Callable[..., Any], *, stop: Optional[Callable[[Any], Any]] = None + ) -> Any: try: if stop is None or stop is _is_mocked: - return real_unwrap(obj, stop=_is_mocked) - return real_unwrap(obj, stop=lambda obj: _is_mocked(obj) or stop(obj)) + return real_unwrap(func, stop=_is_mocked) + _stop = stop + return real_unwrap(func, stop=lambda obj: _is_mocked(obj) or _stop(func)) except Exception as e: warnings.warn( "Got %r when unwrapping %r. This is usually caused " "by a violation of Python's object protocol; see e.g. " - "https://github.com/pytest-dev/pytest/issues/5080" % (e, obj), + "https://github.com/pytest-dev/pytest/issues/5080" % (e, func), PytestWarning, ) raise @@ -443,26 +488,28 @@ def _mock_aware_unwrap(obj, stop=None): class DoctestModule(pytest.Module): - def collect(self): + def collect(self) -> Iterable[DoctestItem]: import doctest class MockAwareDocTestFinder(doctest.DocTestFinder): - """ - a hackish doctest finder that overrides stdlib internals to fix a stdlib bug + """A hackish doctest finder that overrides stdlib internals to fix a stdlib bug. https://github.com/pytest-dev/pytest/issues/3456 https://bugs.python.org/issue25532 """ def _find_lineno(self, obj, source_lines): - """ - Doctest code does not take into account `@property`, this is a hackish way to fix it. + """Doctest code does not take into account `@property`, this + is a hackish way to fix it. https://bugs.python.org/issue17446 """ if isinstance(obj, property): obj = getattr(obj, "fget", obj) - return doctest.DocTestFinder._find_lineno(self, obj, source_lines) + # Type ignored because this is a private function. + return doctest.DocTestFinder._find_lineno( # type: ignore + self, obj, source_lines, + ) def _find( self, tests, obj, name, module, source_lines, globs, seen @@ -477,16 +524,18 @@ def _find( ) if self.fspath.basename == "conftest.py": - module = self.config.pluginmanager._importconftest(self.fspath) + module = self.config.pluginmanager._importconftest( + self.fspath, self.config.getoption("importmode") + ) else: try: - module = self.fspath.pyimport() + module = import_path(self.fspath) except ImportError: if self.config.getvalue("doctest_ignore_import_errors"): pytest.skip("unable to import module %r" % self.fspath) else: raise - # uses internal doctest module parsing mechanism + # Uses internal doctest module parsing mechanism. finder = MockAwareDocTestFinder() optionflags = get_optionflags(self) runner = _get_runner( @@ -503,34 +552,30 @@ def _find( ) -def _setup_fixtures(doctest_item): - """ - Used by DoctestTextfile and DoctestItem to setup fixture information. - """ +def _setup_fixtures(doctest_item: DoctestItem) -> FixtureRequest: + """Used by DoctestTextfile and DoctestItem to setup fixture information.""" - def func(): + def func() -> None: pass - doctest_item.funcargs = {} + doctest_item.funcargs = {} # type: ignore[attr-defined] fm = doctest_item.session._fixturemanager - doctest_item._fixtureinfo = fm.getfixtureinfo( + doctest_item._fixtureinfo = fm.getfixtureinfo( # type: ignore[attr-defined] node=doctest_item, func=func, cls=None, funcargs=False ) - fixture_request = FixtureRequest(doctest_item) + fixture_request = FixtureRequest(doctest_item, _ispytest=True) fixture_request._fillfixtures() return fixture_request -def _init_checker_class() -> "Type[doctest.OutputChecker]": +def _init_checker_class() -> Type["doctest.OutputChecker"]: import doctest import re class LiteralsOutputChecker(doctest.OutputChecker): - """ - Based on doctest_nose_plugin.py from the nltk project - (https://github.com/nltk/nltk) and on the "numtest" doctest extension - by Sebastien Boisgerault (https://github.com/boisgera/numtest). - """ + # Based on doctest_nose_plugin.py from the nltk project + # (https://github.com/nltk/nltk) and on the "numtest" doctest extension + # by Sebastien Boisgerault (https://github.com/boisgera/numtest). _unicode_literal_re = re.compile(r"(\W|^)[uU]([rR]?[\'\"])", re.UNICODE) _bytes_literal_re = re.compile(r"(\W|^)[bB]([rR]?[\'\"])", re.UNICODE) @@ -557,7 +602,7 @@ class LiteralsOutputChecker(doctest.OutputChecker): re.VERBOSE, ) - def check_output(self, want, got, optionflags): + def check_output(self, want: str, got: str, optionflags: int) -> bool: if doctest.OutputChecker.check_output(self, want, got, optionflags): return True @@ -568,7 +613,7 @@ def check_output(self, want, got, optionflags): if not allow_unicode and not allow_bytes and not allow_number: return False - def remove_prefixes(regex, txt): + def remove_prefixes(regex: Pattern[str], txt: str) -> str: return re.sub(regex, r"\1\2", txt) if allow_unicode: @@ -584,15 +629,15 @@ def remove_prefixes(regex, txt): return doctest.OutputChecker.check_output(self, want, got, optionflags) - def _remove_unwanted_precision(self, want, got): + def _remove_unwanted_precision(self, want: str, got: str) -> str: wants = list(self._number_re.finditer(want)) gots = list(self._number_re.finditer(got)) if len(wants) != len(gots): return got offset = 0 for w, g in zip(wants, gots): - fraction = w.group("fraction") - exponent = w.group("exponent1") + fraction: Optional[str] = w.group("fraction") + exponent: Optional[str] = w.group("exponent1") if exponent is None: exponent = w.group("exponent2") if fraction is None: @@ -615,8 +660,7 @@ def _remove_unwanted_precision(self, want, got): def _get_checker() -> "doctest.OutputChecker": - """ - Returns a doctest.OutputChecker subclass that supports some + """Return a doctest.OutputChecker subclass that supports some additional options: * ALLOW_UNICODE and ALLOW_BYTES options to ignore u'' and b'' @@ -636,36 +680,31 @@ def _get_checker() -> "doctest.OutputChecker": def _get_allow_unicode_flag() -> int: - """ - Registers and returns the ALLOW_UNICODE flag. - """ + """Register and return the ALLOW_UNICODE flag.""" import doctest return doctest.register_optionflag("ALLOW_UNICODE") def _get_allow_bytes_flag() -> int: - """ - Registers and returns the ALLOW_BYTES flag. - """ + """Register and return the ALLOW_BYTES flag.""" import doctest return doctest.register_optionflag("ALLOW_BYTES") def _get_number_flag() -> int: - """ - Registers and returns the NUMBER flag. - """ + """Register and return the NUMBER flag.""" import doctest return doctest.register_optionflag("NUMBER") def _get_report_choice(key: str) -> int: - """ - This function returns the actual `doctest` module flag value, we want to do it as late as possible to avoid - importing `doctest` and all its dependencies when parsing options, as it adds overhead and breaks tests. + """Return the actual `doctest` module flag value. + + We want to do it as late as possible to avoid importing `doctest` and all + its dependencies when parsing options, as it adds overhead and breaks tests. """ import doctest @@ -679,8 +718,7 @@ def _get_report_choice(key: str) -> int: @pytest.fixture(scope="session") -def doctest_namespace(): - """ - Fixture that returns a :py:class:`dict` that will be injected into the namespace of doctests. - """ +def doctest_namespace() -> Dict[str, Any]: + """Fixture that returns a :py:class:`dict` that will be injected into the + namespace of doctests.""" return dict() diff --git a/src/_pytest/faulthandler.py b/src/_pytest/faulthandler.py index 8d723c206cb..d0cc0430c49 100644 --- a/src/_pytest/faulthandler.py +++ b/src/_pytest/faulthandler.py @@ -1,25 +1,28 @@ import io import os import sys +from typing import Generator from typing import TextIO import pytest +from _pytest.config import Config +from _pytest.config.argparsing import Parser +from _pytest.nodes import Item from _pytest.store import StoreKey fault_handler_stderr_key = StoreKey[TextIO]() -def pytest_addoption(parser): +def pytest_addoption(parser: Parser) -> None: help = ( "Dump the traceback of all threads if a test takes " - "more than TIMEOUT seconds to finish.\n" - "Not available on Windows." + "more than TIMEOUT seconds to finish." ) parser.addini("faulthandler_timeout", help, default=0.0) -def pytest_configure(config): +def pytest_configure(config: Config) -> None: import faulthandler if not faulthandler.is_enabled(): @@ -27,18 +30,15 @@ def pytest_configure(config): # of enabling faulthandler before each test executes. config.pluginmanager.register(FaultHandlerHooks(), "faulthandler-hooks") else: - from _pytest.warnings import _issue_warning_captured - # Do not handle dumping to stderr if faulthandler is already enabled, so warn # users that the option is being ignored. timeout = FaultHandlerHooks.get_timeout_config_value(config) if timeout > 0: - _issue_warning_captured( + config.issue_config_time_warning( pytest.PytestConfigWarning( "faulthandler module enabled before pytest configuration step, " "'faulthandler_timeout' option ignored" ), - config.hook, stacklevel=2, ) @@ -47,14 +47,14 @@ class FaultHandlerHooks: """Implements hooks that will actually install fault handler before tests execute, as well as correctly handle pdb and internal errors.""" - def pytest_configure(self, config): + def pytest_configure(self, config: Config) -> None: import faulthandler stderr_fd_copy = os.dup(self._get_stderr_fileno()) config._store[fault_handler_stderr_key] = open(stderr_fd_copy, "w") faulthandler.enable(file=config._store[fault_handler_stderr_key]) - def pytest_unconfigure(self, config): + def pytest_unconfigure(self, config: Config) -> None: import faulthandler faulthandler.disable() @@ -80,8 +80,8 @@ def _get_stderr_fileno(): def get_timeout_config_value(config): return float(config.getini("faulthandler_timeout") or 0.0) - @pytest.hookimpl(hookwrapper=True) - def pytest_runtest_protocol(self, item): + @pytest.hookimpl(hookwrapper=True, trylast=True) + def pytest_runtest_protocol(self, item: Item) -> Generator[None, None, None]: timeout = self.get_timeout_config_value(item.config) stderr = item.config._store[fault_handler_stderr_key] if timeout > 0 and stderr is not None: @@ -96,18 +96,16 @@ def pytest_runtest_protocol(self, item): yield @pytest.hookimpl(tryfirst=True) - def pytest_enter_pdb(self): - """Cancel any traceback dumping due to timeout before entering pdb. - """ + def pytest_enter_pdb(self) -> None: + """Cancel any traceback dumping due to timeout before entering pdb.""" import faulthandler faulthandler.cancel_dump_traceback_later() @pytest.hookimpl(tryfirst=True) - def pytest_exception_interact(self): + def pytest_exception_interact(self) -> None: """Cancel any traceback dumping due to an interactive exception being - raised. - """ + raised.""" import faulthandler faulthandler.cancel_dump_traceback_later() diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index 081e95a6db8..273bcafd393 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -1,24 +1,43 @@ import functools import inspect +import os import sys import warnings from collections import defaultdict from collections import deque -from collections import OrderedDict +from types import TracebackType +from typing import Any +from typing import Callable +from typing import cast from typing import Dict +from typing import Generator +from typing import Generic +from typing import Iterable +from typing import Iterator from typing import List +from typing import Optional +from typing import overload +from typing import Sequence +from typing import Set from typing import Tuple +from typing import Type +from typing import TYPE_CHECKING +from typing import TypeVar +from typing import Union import attr import py import _pytest +from _pytest import nodes +from _pytest._code import getfslineno from _pytest._code.code import FormattedExcinfo from _pytest._code.code import TerminalRepr -from _pytest._code.source import getfslineno from _pytest._io import TerminalWriter from _pytest.compat import _format_args from _pytest.compat import _PytestWrapper +from _pytest.compat import assert_never +from _pytest.compat import final from _pytest.compat import get_real_func from _pytest.compat import get_real_method from _pytest.compat import getfuncargnames @@ -27,69 +46,71 @@ from _pytest.compat import is_generator from _pytest.compat import NOTSET from _pytest.compat import safe_getattr -from _pytest.compat import TYPE_CHECKING -from _pytest.deprecated import FIXTURE_POSITIONAL_ARGUMENTS -from _pytest.deprecated import FUNCARGNAMES +from _pytest.config import _PluggyPlugin +from _pytest.config import Config +from _pytest.config.argparsing import Parser +from _pytest.deprecated import check_ispytest +from _pytest.deprecated import FILLFUNCARGS +from _pytest.deprecated import YIELD_FIXTURE +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 TEST_OUTCOME +from _pytest.pathlib import absolutepath +from _pytest.store import StoreKey if TYPE_CHECKING: - from typing import Type + from typing import Deque + from typing import NoReturn + from typing_extensions import Literal - from _pytest import nodes from _pytest.main import Session + from _pytest.python import CallSpec2 + from _pytest.python import Function + from _pytest.python import Metafunc + + _Scope = Literal["session", "package", "module", "class", "function"] + + +# The value of the fixture -- return/yield of the fixture function (type variable). +_FixtureValue = TypeVar("_FixtureValue") +# The type of the fixture function (type variable). +_FixtureFunction = TypeVar("_FixtureFunction", bound=Callable[..., object]) +# The type of a fixture function (type alias generic in fixture value). +_FixtureFunc = Union[ + Callable[..., _FixtureValue], Callable[..., Generator[_FixtureValue, None, None]] +] +# The type of FixtureDef.cached_result (type alias generic in fixture value). +_FixtureCachedResult = Union[ + Tuple[ + # The result. + _FixtureValue, + # Cache key. + object, + None, + ], + Tuple[ + None, + # Cache key. + object, + # Exc info if raised. + Tuple[Type[BaseException], BaseException, TracebackType], + ], +] @attr.s(frozen=True) -class PseudoFixtureDef: - cached_result = attr.ib() - scope = attr.ib() +class PseudoFixtureDef(Generic[_FixtureValue]): + cached_result = attr.ib(type="_FixtureCachedResult[_FixtureValue]") + scope = attr.ib(type="_Scope") -def pytest_sessionstart(session: "Session"): - import _pytest.python - import _pytest.nodes - - scopename2class.update( - { - "package": _pytest.python.Package, - "class": _pytest.python.Class, - "module": _pytest.python.Module, - "function": _pytest.nodes.Item, - "session": _pytest.main.Session, - } - ) +def pytest_sessionstart(session: "Session") -> None: session._fixturemanager = FixtureManager(session) -scopename2class = {} # type: Dict[str, Type[nodes.Node]] - -scope2props = dict(session=()) # type: Dict[str, Tuple[str, ...]] -scope2props["package"] = ("fspath",) -scope2props["module"] = ("fspath", "module") -scope2props["class"] = scope2props["module"] + ("cls",) -scope2props["instance"] = scope2props["class"] + ("instance",) -scope2props["function"] = scope2props["instance"] + ("function", "keywords") - - -def scopeproperty(name=None, doc=None): - def decoratescope(func): - scopename = name or func.__name__ - - def provide(self): - if func.__name__ in scope2props[self.scope]: - return func(self) - raise AttributeError( - "{} not available in {}-scoped context".format(scopename, self.scope) - ) - - return property(provide, None, None, func.__doc__) - - return decoratescope - - -def get_scope_package(node, fixturedef): +def get_scope_package(node, fixturedef: "FixtureDef[object]"): import pytest cls = pytest.Package @@ -104,25 +125,44 @@ def get_scope_package(node, fixturedef): return current -def get_scope_node(node, scope): - cls = scopename2class.get(scope) - if cls is None: - raise ValueError("unknown scope") - return node.getparent(cls) +def get_scope_node( + node: nodes.Node, scope: "_Scope" +) -> Optional[Union[nodes.Item, nodes.Collector]]: + import _pytest.python + + if scope == "function": + return node.getparent(nodes.Item) + elif scope == "class": + return node.getparent(_pytest.python.Class) + elif scope == "module": + return node.getparent(_pytest.python.Module) + elif scope == "package": + return node.getparent(_pytest.python.Package) + elif scope == "session": + return node.getparent(_pytest.main.Session) + else: + assert_never(scope) -def add_funcarg_pseudo_fixture_def(collector, metafunc, fixturemanager): - # this function will transform all collected calls to a functions +# Used for storing artificial fixturedefs for direct parametrization. +name2pseudofixturedef_key = StoreKey[Dict[str, "FixtureDef[Any]"]]() + + +def add_funcarg_pseudo_fixture_def( + collector: nodes.Collector, metafunc: "Metafunc", fixturemanager: "FixtureManager" +) -> None: + # This function will transform all collected calls to functions # if they use direct funcargs (i.e. direct parametrization) # because we want later test execution to be able to rely on # an existing FixtureDef structure for all arguments. # XXX we can probably avoid this algorithm if we modify CallSpec2 # to directly care for creating the fixturedefs within its methods. if not metafunc._calls[0].funcargs: - return # this function call does not have direct parametrization - # collect funcargs of all callspecs into a list of values - arg2params = {} - arg2scope = {} + # This function call does not have direct parametrization. + return + # Collect funcargs of all callspecs into a list of values. + arg2params: Dict[str, List[object]] = {} + arg2scope: Dict[str, _Scope] = {} for callspec in metafunc._calls: for argname, argvalue in callspec.funcargs.items(): assert argname not in callspec.params @@ -135,11 +175,11 @@ def add_funcarg_pseudo_fixture_def(collector, metafunc, fixturemanager): arg2scope[argname] = scopes[scopenum] callspec.funcargs.clear() - # register artificial FixtureDef's so that later at test execution + # Register artificial FixtureDef's so that later at test execution # time we can rely on a proper FixtureDef to exist for fixture setup. arg2fixturedefs = metafunc._arg2fixturedefs for argname, valuelist in arg2params.items(): - # if we have a scope that is higher than function we need + # If we have a scope that is higher than function, we need # to make sure we only ever create an according fixturedef on # a per-scope basis. We thus store and cache the fixturedef on the # node related to the scope. @@ -149,46 +189,61 @@ def add_funcarg_pseudo_fixture_def(collector, metafunc, fixturemanager): node = get_scope_node(collector, scope) if node is None: assert scope == "class" and isinstance(collector, _pytest.python.Module) - # use module-level collector for class-scope (for now) + # Use module-level collector for class-scope (for now). node = collector - if node and argname in node._name2pseudofixturedef: - arg2fixturedefs[argname] = [node._name2pseudofixturedef[argname]] + if node is None: + name2pseudofixturedef = None + else: + default: Dict[str, FixtureDef[Any]] = {} + name2pseudofixturedef = node._store.setdefault( + name2pseudofixturedef_key, default + ) + if name2pseudofixturedef is not None and argname in name2pseudofixturedef: + arg2fixturedefs[argname] = [name2pseudofixturedef[argname]] else: fixturedef = FixtureDef( - fixturemanager, - "", - argname, - get_direct_param_fixture_func, - arg2scope[argname], - valuelist, - False, - False, + fixturemanager=fixturemanager, + baseid="", + argname=argname, + func=get_direct_param_fixture_func, + scope=arg2scope[argname], + params=valuelist, + unittest=False, + ids=None, ) arg2fixturedefs[argname] = [fixturedef] - if node is not None: - node._name2pseudofixturedef[argname] = fixturedef + if name2pseudofixturedef is not None: + name2pseudofixturedef[argname] = fixturedef -def getfixturemarker(obj): - """ return fixturemarker or None if it doesn't exist or raised +def getfixturemarker(obj: object) -> Optional["FixtureFunctionMarker"]: + """Return fixturemarker or None if it doesn't exist or raised exceptions.""" try: - return getattr(obj, "_pytestfixturefunction", None) + fixturemarker: Optional[FixtureFunctionMarker] = getattr( + obj, "_pytestfixturefunction", None + ) except TEST_OUTCOME: # some objects raise errors like request (from flask import request) # we don't expect them to be fixture functions return None + return fixturemarker + + +# Parametrized fixture key, helper alias for code below. +_Key = Tuple[object, ...] -def get_parametrized_fixture_keys(item, scopenum): - """ return list of keys for all parametrized arguments which match +def get_parametrized_fixture_keys(item: nodes.Item, scopenum: int) -> Iterator[_Key]: + """Return list of keys for all parametrized arguments which match the specified scope. """ assert scopenum < scopenum_function # function try: - cs = item.callspec + callspec = item.callspec # type: ignore[attr-defined] except AttributeError: pass else: + cs: CallSpec2 = callspec # cs.indices.items() is random order of argnames. Need to # sort this so that different calls to # get_parametrized_fixture_keys will be deterministic. @@ -196,67 +251,80 @@ def get_parametrized_fixture_keys(item, scopenum): if cs._arg2scopenum[argname] != scopenum: continue if scopenum == 0: # session - key = (argname, param_index) + key: _Key = (argname, param_index) elif scopenum == 1: # package key = (argname, param_index, item.fspath.dirpath()) elif scopenum == 2: # module key = (argname, param_index, item.fspath) elif scopenum == 3: # class - key = (argname, param_index, item.fspath, item.cls) + item_cls = item.cls # type: ignore[attr-defined] + key = (argname, param_index, item.fspath, item_cls) yield key -# algorithm for sorting on a per-parametrized resource setup basis -# it is called for scopenum==0 (session) first and performs sorting +# Algorithm for sorting on a per-parametrized resource setup basis. +# It is called for scopenum==0 (session) first and performs sorting # down to the lower scopes such as to minimize number of "high scope" -# setups and teardowns +# setups and teardowns. -def reorder_items(items): - argkeys_cache = {} - items_by_argkey = {} +def reorder_items(items: Sequence[nodes.Item]) -> List[nodes.Item]: + argkeys_cache: Dict[int, Dict[nodes.Item, Dict[_Key, None]]] = {} + items_by_argkey: Dict[int, Dict[_Key, Deque[nodes.Item]]] = {} for scopenum in range(0, scopenum_function): - argkeys_cache[scopenum] = d = {} - items_by_argkey[scopenum] = item_d = defaultdict(deque) + d: Dict[nodes.Item, Dict[_Key, None]] = {} + argkeys_cache[scopenum] = d + item_d: Dict[_Key, Deque[nodes.Item]] = defaultdict(deque) + items_by_argkey[scopenum] = item_d for item in items: - keys = OrderedDict.fromkeys(get_parametrized_fixture_keys(item, scopenum)) + keys = dict.fromkeys(get_parametrized_fixture_keys(item, scopenum), None) if keys: d[item] = keys for key in keys: item_d[key].append(item) - items = OrderedDict.fromkeys(items) - return list(reorder_items_atscope(items, argkeys_cache, items_by_argkey, 0)) + items_dict = dict.fromkeys(items, None) + return list(reorder_items_atscope(items_dict, argkeys_cache, items_by_argkey, 0)) -def fix_cache_order(item, argkeys_cache, items_by_argkey): +def fix_cache_order( + item: nodes.Item, + argkeys_cache: Dict[int, Dict[nodes.Item, Dict[_Key, None]]], + items_by_argkey: Dict[int, Dict[_Key, "Deque[nodes.Item]"]], +) -> None: for scopenum in range(0, scopenum_function): for key in argkeys_cache[scopenum].get(item, []): items_by_argkey[scopenum][key].appendleft(item) -def reorder_items_atscope(items, argkeys_cache, items_by_argkey, scopenum): +def reorder_items_atscope( + items: Dict[nodes.Item, None], + argkeys_cache: Dict[int, Dict[nodes.Item, Dict[_Key, None]]], + items_by_argkey: Dict[int, Dict[_Key, "Deque[nodes.Item]"]], + scopenum: int, +) -> Dict[nodes.Item, None]: if scopenum >= scopenum_function or len(items) < 3: return items - ignore = set() + ignore: Set[Optional[_Key]] = set() items_deque = deque(items) - items_done = OrderedDict() + items_done: Dict[nodes.Item, None] = {} scoped_items_by_argkey = items_by_argkey[scopenum] scoped_argkeys_cache = argkeys_cache[scopenum] while items_deque: - no_argkey_group = OrderedDict() + no_argkey_group: Dict[nodes.Item, None] = {} slicing_argkey = None while items_deque: item = items_deque.popleft() if item in items_done or item in no_argkey_group: continue - argkeys = OrderedDict.fromkeys( - k for k in scoped_argkeys_cache.get(item, []) if k not in ignore + argkeys = dict.fromkeys( + (k for k in scoped_argkeys_cache.get(item, []) if k not in ignore), None ) if not argkeys: no_argkey_group[item] = None else: slicing_argkey, _ = argkeys.popitem() - # we don't have to remove relevant items from later in the deque because they'll just be ignored + # We don't have to remove relevant items from later in the + # deque because they'll just be ignored. matching_items = [ i for i in scoped_items_by_argkey[slicing_argkey] if i in items ] @@ -274,8 +342,22 @@ def reorder_items_atscope(items, argkeys_cache, items_by_argkey, scopenum): return items_done -def fillfixtures(function): - """ fill missing funcargs for a test function. """ +def _fillfuncargs(function: "Function") -> None: + """Fill missing fixtures for a test function, old public API (deprecated).""" + warnings.warn(FILLFUNCARGS.format(name="pytest._fillfuncargs()"), stacklevel=2) + _fill_fixtures_impl(function) + + +def fillfixtures(function: "Function") -> None: + """Fill missing fixtures for a test function (deprecated).""" + warnings.warn( + FILLFUNCARGS.format(name="_pytest.fixtures.fillfixtures()"), stacklevel=2 + ) + _fill_fixtures_impl(function) + + +def _fill_fixtures_impl(function: "Function") -> None: + """Internal implementation to fill fixtures on the given function object.""" try: request = function._request except AttributeError: @@ -283,11 +365,12 @@ def fillfixtures(function): # with the oejskit plugin. It uses classes with funcargs # and we thus have to work a bit to allow this. fm = function.session._fixturemanager + assert function.parent is not None fi = fm.getfixtureinfo(function.parent, function.obj, None) function._fixtureinfo = fi - request = function._request = FixtureRequest(function) + request = function._request = FixtureRequest(function, _ispytest=True) request._fillfixtures() - # prune out funcargs for jstests + # Prune out funcargs for jstests. newfuncargs = {} for name in fi.argnames: newfuncargs[name] = function.funcargs[name] @@ -302,17 +385,17 @@ def get_direct_param_fixture_func(request): @attr.s(slots=True) class FuncFixtureInfo: - # original function argument names - argnames = attr.ib(type=tuple) - # argnames that function immediately requires. These include argnames + + # Original function argument names. + argnames = attr.ib(type=Tuple[str, ...]) + # Argnames that function immediately requires. These include argnames + # fixture names specified via usefixtures and via autouse=True in fixture # definitions. - initialnames = attr.ib(type=tuple) - names_closure = attr.ib() # List[str] - name2fixturedefs = attr.ib() # List[str, List[FixtureDef]] + initialnames = attr.ib(type=Tuple[str, ...]) + names_closure = attr.ib(type=List[str]) + name2fixturedefs = attr.ib(type=Dict[str, Sequence["FixtureDef[Any]"]]) - def prune_dependency_tree(self): - """Recompute names_closure from initialnames and name2fixturedefs + def prune_dependency_tree(self) -> None: + """Recompute names_closure from initialnames and name2fixturedefs. Can only reduce names_closure, which means that the new closure will always be a subset of the old one. The order is preserved. @@ -322,11 +405,11 @@ def prune_dependency_tree(self): tree. In this way the dependency tree can get pruned, and the closure of argnames may get reduced. """ - closure = set() + closure: Set[str] = set() working_set = set(self.initialnames) while working_set: argname = working_set.pop() - # argname may be smth not included in the original names_closure, + # Argname may be smth not included in the original names_closure, # in which case we ignore it. This currently happens with pseudo # FixtureDefs which wrap 'get_direct_param_fixture_func(request)'. # So they introduce the new dependency 'request' which might have @@ -340,53 +423,51 @@ def prune_dependency_tree(self): class FixtureRequest: - """ A request for a fixture from a test or fixture function. + """A request for a fixture from a test or fixture function. - A request object gives access to the requesting test context - and has an optional ``param`` attribute in case - the fixture is parametrized indirectly. + A request object gives access to the requesting test context and has + an optional ``param`` attribute in case the fixture is parametrized + indirectly. """ - def __init__(self, pyfuncitem): + def __init__(self, pyfuncitem, *, _ispytest: bool = False) -> None: + check_ispytest(_ispytest) self._pyfuncitem = pyfuncitem - #: fixture for which this request is being performed - self.fixturename = None - #: Scope string, one of "function", "class", "module", "session" - self.scope = "function" - self._fixture_defs = {} # type: Dict[str, FixtureDef] - fixtureinfo = pyfuncitem._fixtureinfo + #: Fixture for which this request is being performed. + self.fixturename: Optional[str] = None + #: Scope string, one of "function", "class", "module", "session". + self.scope: _Scope = "function" + self._fixture_defs: Dict[str, FixtureDef[Any]] = {} + fixtureinfo: FuncFixtureInfo = pyfuncitem._fixtureinfo self._arg2fixturedefs = fixtureinfo.name2fixturedefs.copy() - self._arg2index = {} - self._fixturemanager = pyfuncitem.session._fixturemanager + self._arg2index: Dict[str, int] = {} + self._fixturemanager: FixtureManager = (pyfuncitem.session._fixturemanager) @property - def fixturenames(self): - """names of all active fixtures in this request""" + def fixturenames(self) -> List[str]: + """Names of all active fixtures in this request.""" result = list(self._pyfuncitem._fixtureinfo.names_closure) result.extend(set(self._fixture_defs).difference(result)) return result - @property - def funcargnames(self): - """ alias attribute for ``fixturenames`` for pre-2.3 compatibility""" - warnings.warn(FUNCARGNAMES, stacklevel=2) - return self.fixturenames - @property def node(self): - """ underlying collection node (depends on current request scope)""" + """Underlying collection node (depends on current request scope).""" return self._getscopeitem(self.scope) - def _getnextfixturedef(self, argname): + def _getnextfixturedef(self, argname: str) -> "FixtureDef[Any]": fixturedefs = self._arg2fixturedefs.get(argname, None) if fixturedefs is None: - # we arrive here because of a dynamic call to + # We arrive here because of a dynamic call to # getfixturevalue(argname) usage which was naturally - # not known at parsing/collection time + # not known at parsing/collection time. + assert self._pyfuncitem.parent is not None parentid = self._pyfuncitem.parent.nodeid fixturedefs = self._fixturemanager.getfixturedefs(argname, parentid) - self._arg2fixturedefs[argname] = fixturedefs - # fixturedefs list is immutable so we maintain a decreasing index + # TODO: Fix this type ignore. Either add assert or adjust types. + # Can this be None here? + self._arg2fixturedefs[argname] = fixturedefs # type: ignore[assignment] + # fixturedefs list is immutable so we maintain a decreasing index. index = self._arg2index.get(argname, 0) - 1 if fixturedefs is None or (-index > len(fixturedefs)): raise FixtureLookupError(argname, self) @@ -394,98 +475,116 @@ def _getnextfixturedef(self, argname): return fixturedefs[index] @property - def config(self): - """ the pytest config object associated with this request. """ - return self._pyfuncitem.config + def config(self) -> Config: + """The pytest config object associated with this request.""" + return self._pyfuncitem.config # type: ignore[no-any-return] - @scopeproperty() + @property def function(self): - """ test function object if the request has a per-function scope. """ + """Test function object if the request has a per-function scope.""" + if self.scope != "function": + raise AttributeError( + f"function not available in {self.scope}-scoped context" + ) return self._pyfuncitem.obj - @scopeproperty("class") + @property def cls(self): - """ class (can be None) where the test function was collected. """ + """Class (can be None) where the test function was collected.""" + if self.scope not in ("class", "function"): + raise AttributeError(f"cls not available in {self.scope}-scoped context") clscol = self._pyfuncitem.getparent(_pytest.python.Class) if clscol: return clscol.obj @property def instance(self): - """ instance (can be None) on which test function was collected. """ - # unittest support hack, see _pytest.unittest.TestCaseFunction + """Instance (can be None) on which test function was collected.""" + # unittest support hack, see _pytest.unittest.TestCaseFunction. try: return self._pyfuncitem._testcase except AttributeError: function = getattr(self, "function", None) return getattr(function, "__self__", None) - @scopeproperty() + @property def module(self): - """ python module object where the test function was collected. """ + """Python module object where the test function was collected.""" + if self.scope not in ("function", "class", "module"): + raise AttributeError(f"module not available in {self.scope}-scoped context") return self._pyfuncitem.getparent(_pytest.python.Module).obj - @scopeproperty() + @property def fspath(self) -> py.path.local: - """ the file system path of the test module which collected this test. """ + """The file system path of the test module which collected this test.""" + if self.scope not in ("function", "class", "module", "package"): + raise AttributeError(f"module not available in {self.scope}-scoped context") # TODO: Remove ignore once _pyfuncitem is properly typed. return self._pyfuncitem.fspath # type: ignore @property def keywords(self): - """ keywords/markers dictionary for the underlying node. """ + """Keywords/markers dictionary for the underlying node.""" return self.node.keywords @property - def session(self): - """ pytest session object. """ - return self._pyfuncitem.session - - def addfinalizer(self, finalizer): - """ add finalizer/teardown function to be called after the - last test within the requesting test context finished - execution. """ - # XXX usually this method is shadowed by fixturedef specific ones + def session(self) -> "Session": + """Pytest session object.""" + return self._pyfuncitem.session # type: ignore[no-any-return] + + def addfinalizer(self, finalizer: Callable[[], object]) -> None: + """Add finalizer/teardown function to be called 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, scope): + def _addfinalizer(self, finalizer: Callable[[], object], scope) -> None: colitem = self._getscopeitem(scope) self._pyfuncitem.session._setupstate.addfinalizer( finalizer=finalizer, colitem=colitem ) - def applymarker(self, marker): - """ Apply a marker to a single test function invocation. + def applymarker(self, marker: Union[str, MarkDecorator]) -> None: + """Apply a marker to a single test function invocation. + This method is useful if you don't want to have a keyword/marker on all function invocations. - :arg marker: a :py:class:`_pytest.mark.MarkDecorator` object - created by a call to ``pytest.mark.NAME(...)``. + :param marker: + A :py:class:`_pytest.mark.MarkDecorator` object created by a call + to ``pytest.mark.NAME(...)``. """ self.node.add_marker(marker) - def raiseerror(self, msg): - """ raise a FixtureLookupError with the given message. """ + def raiseerror(self, msg: Optional[str]) -> "NoReturn": + """Raise a FixtureLookupError with the given message.""" raise self._fixturemanager.FixtureLookupError(None, self, msg) - def _fillfixtures(self): + def _fillfixtures(self) -> None: item = self._pyfuncitem fixturenames = getattr(item, "fixturenames", self.fixturenames) for argname in fixturenames: if argname not in item.funcargs: item.funcargs[argname] = self.getfixturevalue(argname) - def getfixturevalue(self, argname): - """ Dynamically run a named fixture function. + def getfixturevalue(self, argname: str) -> Any: + """Dynamically run a named fixture function. Declaring fixtures via function argument is recommended where possible. But if you can only decide whether to use another fixture at test setup time, you may use this function to retrieve it inside a fixture or test function body. + + :raises pytest.FixtureLookupError: + If the given fixture could not be found. """ - return self._get_active_fixturedef(argname).cached_result[0] + fixturedef = self._get_active_fixturedef(argname) + assert fixturedef.cached_result is not None + return fixturedef.cached_result[0] - def _get_active_fixturedef(self, argname): + def _get_active_fixturedef( + self, argname: str + ) -> Union["FixtureDef[object]", PseudoFixtureDef[object]]: try: return self._fixture_defs[argname] except KeyError: @@ -494,31 +593,34 @@ def _get_active_fixturedef(self, argname): except FixtureLookupError: if argname == "request": cached_result = (self, [0], None) - scope = "function" + scope: _Scope = "function" return PseudoFixtureDef(cached_result, scope) raise - # remove indent to prevent the python3 exception - # from leaking into the call + # Remove indent to prevent the python3 exception + # from leaking into the call. self._compute_fixture_value(fixturedef) self._fixture_defs[argname] = fixturedef return fixturedef - def _get_fixturestack(self): + def _get_fixturestack(self) -> List["FixtureDef[Any]"]: current = self - values = [] + values: List[FixtureDef[Any]] = [] while 1: fixturedef = getattr(current, "_fixturedef", None) if fixturedef is None: values.reverse() return values values.append(fixturedef) + assert isinstance(current, SubRequest) current = current._parent_request - def _compute_fixture_value(self, fixturedef: "FixtureDef") -> None: - """ - Creates a SubRequest based on "self" and calls the execute method of the given fixturedef object. This will - force the FixtureDef object to throw away any previous results and compute a new fixture value, which - will be stored into the FixtureDef object itself. + def _compute_fixture_value(self, fixturedef: "FixtureDef[object]") -> None: + """Create a SubRequest based on "self" and call the execute method + of the given FixtureDef object. + + This will force the FixtureDef object to throw away any previous + results and compute a new fixture value, which will be stored into + the FixtureDef object itself. """ # prepare a subrequest object before calling fixture function # (latter managed by fixturedef) @@ -568,33 +670,39 @@ def _compute_fixture_value(self, fixturedef: "FixtureDef") -> None: fail(msg, pytrace=False) else: param_index = funcitem.callspec.indices[argname] - # if a parametrize invocation set a scope it will override - # the static scope defined with the fixture function + # If a parametrize invocation set a scope it will override + # the static scope defined with the fixture function. paramscopenum = funcitem.callspec._arg2scopenum.get(argname) if paramscopenum is not None: scope = scopes[paramscopenum] - subrequest = SubRequest(self, scope, param, param_index, fixturedef) + subrequest = SubRequest( + self, scope, param, param_index, fixturedef, _ispytest=True + ) - # check if a higher-level scoped fixture accesses a lower level one + # Check if a higher-level scoped fixture accesses a lower level one. subrequest._check_scope(argname, self.scope, scope) try: - # call the fixture function + # Call the fixture function. fixturedef.execute(request=subrequest) finally: self._schedule_finalizers(fixturedef, subrequest) - def _schedule_finalizers(self, fixturedef, subrequest): - # if fixture function failed it might have registered finalizers + def _schedule_finalizers( + self, fixturedef: "FixtureDef[object]", subrequest: "SubRequest" + ) -> None: + # If fixture function failed it might have registered finalizers. self.session._setupstate.addfinalizer( functools.partial(fixturedef.finish, request=subrequest), subrequest.node ) - def _check_scope(self, argname, invoking_scope, requested_scope): + def _check_scope( + self, argname: str, invoking_scope: "_Scope", requested_scope: "_Scope", + ) -> None: if argname == "request": return if scopemismatch(invoking_scope, requested_scope): - # try to report something helpful + # Try to report something helpful. lines = self._factorytraceback() fail( "ScopeMismatch: You tried to access the %r scoped " @@ -604,7 +712,7 @@ def _check_scope(self, argname, invoking_scope, requested_scope): pytrace=False, ) - def _factorytraceback(self): + def _factorytraceback(self) -> List[str]: lines = [] for fixturedef in self._get_fixturestack(): factory = fixturedef.func @@ -614,31 +722,43 @@ def _factorytraceback(self): lines.append("%s:%d: def %s%s" % (p, lineno + 1, factory.__name__, args)) return lines - def _getscopeitem(self, scope): + def _getscopeitem(self, scope: "_Scope") -> Union[nodes.Item, nodes.Collector]: if scope == "function": - # this might also be a non-function Item despite its attribute name - return self._pyfuncitem - if scope == "package": - node = get_scope_package(self._pyfuncitem, self._fixturedef) + # This might also be a non-function Item despite its attribute name. + node: Optional[Union[nodes.Item, nodes.Collector]] = self._pyfuncitem + elif 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 == "class": - # fallback to function item itself + # 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): + def __repr__(self) -> str: return "" % (self.node) +@final class SubRequest(FixtureRequest): - """ a sub request for handling getting a fixture from a - test function/fixture. """ + """A sub request for handling getting a fixture from a test function/fixture.""" - def __init__(self, request, scope, param, param_index, fixturedef): + def __init__( + self, + request: "FixtureRequest", + scope: "_Scope", + param, + param_index: int, + fixturedef: "FixtureDef[object]", + *, + _ispytest: bool = False, + ) -> None: + check_ispytest(_ispytest) self._parent_request = request self.fixturename = fixturedef.argname if param is not NOTSET: @@ -652,16 +772,20 @@ def __init__(self, request, scope, param, param_index, fixturedef): self._arg2index = request._arg2index self._fixturemanager = request._fixturemanager - def __repr__(self): - return "".format(self.fixturename, self._pyfuncitem) + def __repr__(self) -> str: + return f"" - def addfinalizer(self, finalizer): + def addfinalizer(self, finalizer: Callable[[], object]) -> None: + """Add finalizer/teardown function to be called after the last test + within the requesting test context finished execution.""" self._fixturedef.addfinalizer(finalizer) - def _schedule_finalizers(self, fixturedef, subrequest): - # if the executing fixturedef was not explicitly requested in the argument list (via + def _schedule_finalizers( + self, fixturedef: "FixtureDef[object]", subrequest: "SubRequest" + ) -> None: + # If the executing fixturedef was not explicitly requested in the argument list (via # getfixturevalue inside the fixture call) then ensure this fixture def will be finished - # first + # first. if fixturedef.argname not in self.fixturenames: fixturedef.addfinalizer( functools.partial(self._fixturedef.finish, request=self) @@ -669,53 +793,56 @@ def _schedule_finalizers(self, fixturedef, subrequest): super()._schedule_finalizers(fixturedef, subrequest) -scopes = "session package module class function".split() +scopes: List["_Scope"] = ["session", "package", "module", "class", "function"] scopenum_function = scopes.index("function") -def scopemismatch(currentscope, newscope): +def scopemismatch(currentscope: "_Scope", newscope: "_Scope") -> bool: return scopes.index(newscope) > scopes.index(currentscope) -def scope2index(scope, descr, where=None): +def scope2index(scope: str, descr: str, where: Optional[str] = None) -> int: """Look up the index of ``scope`` and raise a descriptive value error - if not defined. - """ + if not defined.""" + strscopes: Sequence[str] = scopes try: - return scopes.index(scope) + return strscopes.index(scope) except ValueError: fail( "{} {}got an unexpected scope value '{}'".format( - descr, "from {} ".format(where) if where else "", scope + descr, f"from {where} " if where else "", scope ), pytrace=False, ) +@final class FixtureLookupError(LookupError): - """ could not return a requested Fixture (missing or invalid). """ + """Could not return a requested fixture (missing or invalid).""" - def __init__(self, argname, request, msg=None): + def __init__( + self, argname: Optional[str], request: FixtureRequest, msg: Optional[str] = None + ) -> None: self.argname = argname self.request = request self.fixturestack = request._get_fixturestack() self.msg = msg def formatrepr(self) -> "FixtureLookupErrorRepr": - tblines = [] # type: List[str] + tblines: List[str] = [] addline = tblines.append stack = [self.request._pyfuncitem.obj] stack.extend(map(lambda x: x.func, self.fixturestack)) msg = self.msg if msg is not None: - # the last fixture raise an error, let's present - # it at the requesting side + # The last fixture raise an error, let's present + # it at the requesting side. stack = stack[:-1] for function in stack: fspath, lineno = getfslineno(function) try: lines, _ = inspect.getsourcelines(get_real_func(function)) - except (IOError, IndexError, TypeError): + except (OSError, IndexError, TypeError): error_msg = "file %s, line %s: source code not available" addline(error_msg % (fspath, lineno + 1)) else: @@ -739,7 +866,7 @@ def formatrepr(self) -> "FixtureLookupErrorRepr": self.argname ) else: - msg = "fixture '{}' not found".format(self.argname) + msg = f"fixture '{self.argname}' not found" msg += "\n available fixtures: {}".format(", ".join(sorted(available))) msg += "\n use 'pytest --fixtures [testpath]' for help on them." @@ -747,7 +874,14 @@ def formatrepr(self) -> "FixtureLookupErrorRepr": class FixtureLookupErrorRepr(TerminalRepr): - def __init__(self, filename, firstlineno, tblines, errorstring, argname): + def __init__( + self, + filename: Union[str, py.path.local], + firstlineno: int, + tblines: Sequence[str], + errorstring: str, + argname: Optional[str], + ) -> None: self.tblines = tblines self.errorstring = errorstring self.filename = filename @@ -766,55 +900,67 @@ def toterminal(self, tw: TerminalWriter) -> None: ) for line in lines[1:]: tw.line( - "{} {}".format(FormattedExcinfo.flow_marker, line.strip()), - red=True, + f"{FormattedExcinfo.flow_marker} {line.strip()}", red=True, ) tw.line() tw.line("%s:%d" % (self.filename, self.firstlineno + 1)) -def fail_fixturefunc(fixturefunc, msg): +def fail_fixturefunc(fixturefunc, msg: str) -> "NoReturn": fs, lineno = getfslineno(fixturefunc) location = "{}:{}".format(fs, lineno + 1) source = _pytest._code.Source(fixturefunc) fail(msg + ":\n\n" + str(source.indent()) + "\n" + location, pytrace=False) -def call_fixture_func(fixturefunc, request, kwargs): - yieldctx = is_generator(fixturefunc) - if yieldctx: - it = fixturefunc(**kwargs) - res = next(it) - finalizer = functools.partial(_teardown_yield_fixture, fixturefunc, it) +def call_fixture_func( + fixturefunc: "_FixtureFunc[_FixtureValue]", request: FixtureRequest, kwargs +) -> _FixtureValue: + if is_generator(fixturefunc): + fixturefunc = cast( + Callable[..., Generator[_FixtureValue, None, None]], fixturefunc + ) + generator = fixturefunc(**kwargs) + try: + fixture_result = next(generator) + except StopIteration: + raise ValueError(f"{request.fixturename} did not yield a value") from None + finalizer = functools.partial(_teardown_yield_fixture, fixturefunc, generator) request.addfinalizer(finalizer) else: - res = fixturefunc(**kwargs) - return res + fixturefunc = cast(Callable[..., _FixtureValue], fixturefunc) + fixture_result = fixturefunc(**kwargs) + return fixture_result -def _teardown_yield_fixture(fixturefunc, it): - """Executes the teardown of a fixture function by advancing the iterator after the - yield and ensure the iteration ends (if not it means there is more than one yield in the function)""" +def _teardown_yield_fixture(fixturefunc, it) -> None: + """Execute the teardown of a fixture function by advancing the iterator + after the yield and ensure the iteration ends (if not it means there is + more than one yield in the function).""" try: next(it) except StopIteration: pass else: - fail_fixturefunc( - fixturefunc, "yield_fixture function has more than one 'yield'" - ) + fail_fixturefunc(fixturefunc, "fixture function has more than one 'yield'") -def _eval_scope_callable(scope_callable, fixture_name, config): +def _eval_scope_callable( + scope_callable: "Callable[[str, Config], _Scope]", + fixture_name: str, + config: Config, +) -> "_Scope": try: - result = scope_callable(fixture_name=fixture_name, config=config) - except Exception: + # Type ignored because there is no typing mechanism to specify + # keyword arguments, currently. + result = scope_callable(fixture_name=fixture_name, config=config) # type: ignore[call-arg] + except Exception as e: raise TypeError( "Error evaluating {} while defining fixture '{}'.\n" "Expected a function with the signature (*, fixture_name, config)".format( scope_callable, fixture_name ) - ) + ) from e if not isinstance(result, str): fail( "Expected {} to return a 'str' while defining fixture '{}', but it returned:\n" @@ -824,44 +970,55 @@ def _eval_scope_callable(scope_callable, fixture_name, config): return result -class FixtureDef: - """ A container for a factory definition. """ +@final +class FixtureDef(Generic[_FixtureValue]): + """A container for a factory definition.""" def __init__( self, - fixturemanager, - baseid, - argname, - func, - scope, - params, - unittest=False, - ids=None, - ): + fixturemanager: "FixtureManager", + baseid: Optional[str], + argname: str, + func: "_FixtureFunc[_FixtureValue]", + scope: "Union[_Scope, Callable[[str, Config], _Scope]]", + params: Optional[Sequence[object]], + unittest: bool = False, + ids: Optional[ + Union[ + Tuple[Union[None, str, float, int, bool], ...], + Callable[[Any], Optional[object]], + ] + ] = None, + ) -> None: self._fixturemanager = fixturemanager self.baseid = baseid or "" self.has_location = baseid is not None self.func = func self.argname = argname if callable(scope): - scope = _eval_scope_callable(scope, argname, fixturemanager.config) - self.scope = scope + scope_ = _eval_scope_callable(scope, argname, fixturemanager.config) + else: + scope_ = scope self.scopenum = scope2index( - scope or "function", - descr="Fixture '{}'".format(func.__name__), + # TODO: Check if the `or` here is really necessary. + scope_ or "function", # type: ignore[unreachable] + descr=f"Fixture '{func.__name__}'", where=baseid, ) - self.params = params - self.argnames = getfuncargnames(func, name=argname, is_method=unittest) + self.scope = scope_ + self.params: Optional[Sequence[object]] = params + self.argnames: Tuple[str, ...] = getfuncargnames( + func, name=argname, is_method=unittest + ) self.unittest = unittest self.ids = ids - self.cached_result = None - self._finalizers = [] + self.cached_result: Optional[_FixtureCachedResult[_FixtureValue]] = None + self._finalizers: List[Callable[[], object]] = [] - def addfinalizer(self, finalizer): + def addfinalizer(self, finalizer: Callable[[], object]) -> None: self._finalizers.append(finalizer) - def finish(self, request): + def finish(self, request: SubRequest) -> None: exc = None try: while self._finalizers: @@ -878,77 +1035,83 @@ def finish(self, request): finally: hook = self._fixturemanager.session.gethookproxy(request.node.fspath) hook.pytest_fixture_post_finalizer(fixturedef=self, request=request) - # even if finalization fails, we invalidate - # the cached fixture value and remove - # all finalizers because they may be bound methods which will - # keep instances alive + # Even if finalization fails, we invalidate the cached fixture + # value and remove all finalizers because they may be bound methods + # which will keep instances alive. self.cached_result = None self._finalizers = [] - def execute(self, request): - # get required arguments and register our own finish() - # with their finalization + def execute(self, request: SubRequest) -> _FixtureValue: + # Get required arguments and register our own finish() + # with their finalization. for argname in self.argnames: fixturedef = request._get_active_fixturedef(argname) if argname != "request": + # PseudoFixtureDef is only for "request". + assert isinstance(fixturedef, FixtureDef) fixturedef.addfinalizer(functools.partial(self.finish, request=request)) my_cache_key = self.cache_key(request) if self.cached_result is not None: - result, cache_key, err = self.cached_result # note: comparison with `==` can fail (or be expensive) for e.g. - # numpy arrays (#6497) + # numpy arrays (#6497). + cache_key = self.cached_result[1] if my_cache_key is cache_key: - if err is not None: - _, val, tb = err + if self.cached_result[2] is not None: + _, val, tb = self.cached_result[2] raise val.with_traceback(tb) else: + result = self.cached_result[0] return result - # we have a previous but differently parametrized fixture instance - # so we need to tear it down before creating a new one + # We have a previous but differently parametrized fixture instance + # so we need to tear it down before creating a new one. self.finish(request) assert self.cached_result is None hook = self._fixturemanager.session.gethookproxy(request.node.fspath) - return hook.pytest_fixture_setup(fixturedef=self, request=request) + result = hook.pytest_fixture_setup(fixturedef=self, request=request) + return result - def cache_key(self, request): + def cache_key(self, request: SubRequest) -> object: return request.param_index if not hasattr(request, "param") else request.param - def __repr__(self): + def __repr__(self) -> str: return "".format( self.argname, self.scope, self.baseid ) -def resolve_fixture_function(fixturedef, request): - """Gets the actual callable that can be called to obtain the fixture value, dealing with unittest-specific - instances and bound methods. - """ +def resolve_fixture_function( + fixturedef: FixtureDef[_FixtureValue], request: FixtureRequest +) -> "_FixtureFunc[_FixtureValue]": + """Get the actual callable that can be called to obtain the fixture + value, dealing with unittest-specific instances and bound methods.""" fixturefunc = fixturedef.func if fixturedef.unittest: if request.instance is not None: - # bind the unbound method to the TestCase instance - fixturefunc = fixturedef.func.__get__(request.instance) + # Bind the unbound method to the TestCase instance. + fixturefunc = fixturedef.func.__get__(request.instance) # type: ignore[union-attr] else: - # the fixture function needs to be bound to the actual + # The fixture function needs to be bound to the actual # request.instance so that code working with "fixturedef" behaves # as expected. if request.instance is not None: - # handle the case where fixture is defined not in a test class, but some other class - # (for example a plugin class with a fixture), see #2270 + # Handle the case where fixture is defined not in a test class, but some other class + # (for example a plugin class with a fixture), see #2270. if hasattr(fixturefunc, "__self__") and not isinstance( - request.instance, fixturefunc.__self__.__class__ + request.instance, fixturefunc.__self__.__class__ # type: ignore[union-attr] ): return fixturefunc fixturefunc = getimfunc(fixturedef.func) if fixturefunc != fixturedef.func: - fixturefunc = fixturefunc.__get__(request.instance) + fixturefunc = fixturefunc.__get__(request.instance) # type: ignore[union-attr] return fixturefunc -def pytest_fixture_setup(fixturedef, request): - """ Execution of fixture setup. """ +def pytest_fixture_setup( + fixturedef: FixtureDef[_FixtureValue], request: SubRequest +) -> _FixtureValue: + """Execution of fixture setup.""" kwargs = {} for argname in fixturedef.argnames: fixdef = request._get_active_fixturedef(argname) @@ -962,52 +1125,80 @@ def pytest_fixture_setup(fixturedef, request): try: result = call_fixture_func(fixturefunc, request, kwargs) except TEST_OUTCOME: - fixturedef.cached_result = (None, my_cache_key, sys.exc_info()) + exc_info = sys.exc_info() + assert exc_info[0] is not None + fixturedef.cached_result = (None, my_cache_key, exc_info) raise fixturedef.cached_result = (result, my_cache_key, None) return result -def _ensure_immutable_ids(ids): +def _ensure_immutable_ids( + ids: Optional[ + Union[ + Iterable[Union[None, str, float, int, bool]], + Callable[[Any], Optional[object]], + ] + ], +) -> Optional[ + Union[ + Tuple[Union[None, str, float, int, bool], ...], + Callable[[Any], Optional[object]], + ] +]: if ids is None: - return + return None if callable(ids): return ids return tuple(ids) -def wrap_function_to_error_out_if_called_directly(function, fixture_marker): +def _params_converter( + params: Optional[Iterable[object]], +) -> Optional[Tuple[object, ...]]: + return tuple(params) if params is not None else None + + +def wrap_function_to_error_out_if_called_directly( + function: _FixtureFunction, fixture_marker: "FixtureFunctionMarker", +) -> _FixtureFunction: """Wrap the given fixture function so we can raise an error about it being called directly, - instead of used as an argument in a test function. - """ + instead of used as an argument in a test function.""" message = ( 'Fixture "{name}" called directly. Fixtures are not meant to be called directly,\n' "but are created automatically when test functions request them as parameters.\n" - "See https://docs.pytest.org/en/latest/fixture.html for more information about fixtures, and\n" - "https://docs.pytest.org/en/latest/deprecations.html#calling-fixtures-directly about how to update your code." + "See https://docs.pytest.org/en/stable/fixture.html for more information about fixtures, and\n" + "https://docs.pytest.org/en/stable/deprecations.html#calling-fixtures-directly about how to update your code." ).format(name=fixture_marker.name or function.__name__) @functools.wraps(function) def result(*args, **kwargs): fail(message, pytrace=False) - # keep reference to the original function in our own custom attribute so we don't unwrap - # further than this point and lose useful wrappings like @mock.patch (#3774) - result.__pytest_wrapped__ = _PytestWrapper(function) + # Keep reference to the original function in our own custom attribute so we don't unwrap + # further than this point and lose useful wrappings like @mock.patch (#3774). + result.__pytest_wrapped__ = _PytestWrapper(function) # type: ignore[attr-defined] - return result + return cast(_FixtureFunction, result) +@final @attr.s(frozen=True) class FixtureFunctionMarker: - scope = attr.ib() - params = attr.ib(converter=attr.converters.optional(tuple)) - autouse = attr.ib(default=False) - # Ignore type because of https://github.com/python/mypy/issues/6172. - ids = attr.ib(default=None, converter=_ensure_immutable_ids) # type: ignore - name = attr.ib(default=None) - - def __call__(self, function): + scope = attr.ib(type="Union[_Scope, Callable[[str, Config], _Scope]]") + params = attr.ib(type=Optional[Tuple[object, ...]], converter=_params_converter) + autouse = attr.ib(type=bool, default=False) + ids = attr.ib( + type=Union[ + Tuple[Union[None, str, float, int, bool], ...], + Callable[[Any], Optional[object]], + ], + default=None, + converter=_ensure_immutable_ids, + ) + name = attr.ib(type=Optional[str], default=None) + + def __call__(self, function: _FixtureFunction) -> _FixtureFunction: if inspect.isclass(function): raise ValueError("class fixtures not supported (maybe in the future)") @@ -1027,153 +1218,140 @@ def __call__(self, function): ), pytrace=False, ) - function._pytestfixturefunction = self - return function - - -FIXTURE_ARGS_ORDER = ("scope", "params", "autouse", "ids", "name") - - -def _parse_fixture_args(callable_or_scope, *args, **kwargs): - arguments = { - "scope": "function", - "params": None, - "autouse": False, - "ids": None, - "name": None, - } - kwargs = { - key: value for key, value in kwargs.items() if arguments.get(key) != value - } - - fixture_function = None - if isinstance(callable_or_scope, str): - args = list(args) - args.insert(0, callable_or_scope) - else: - fixture_function = callable_or_scope - - positionals = set() - for positional, argument_name in zip(args, FIXTURE_ARGS_ORDER): - arguments[argument_name] = positional - positionals.add(argument_name) - - duplicated_kwargs = {kwarg for kwarg in kwargs.keys() if kwarg in positionals} - if duplicated_kwargs: - raise TypeError( - "The fixture arguments are defined as positional and keyword: {}. " - "Use only keyword arguments.".format(", ".join(duplicated_kwargs)) - ) - if positionals: - warnings.warn(FIXTURE_POSITIONAL_ARGUMENTS, stacklevel=2) + # Type ignored because https://github.com/python/mypy/issues/2087. + function._pytestfixturefunction = self # type: ignore[attr-defined] + return function - arguments.update(kwargs) - return fixture_function, arguments +@overload +def fixture( + fixture_function: _FixtureFunction, + *, + scope: "Union[_Scope, Callable[[str, Config], _Scope]]" = ..., + params: Optional[Iterable[object]] = ..., + autouse: bool = ..., + ids: Optional[ + Union[ + Iterable[Union[None, str, float, int, bool]], + Callable[[Any], Optional[object]], + ] + ] = ..., + name: Optional[str] = ..., +) -> _FixtureFunction: + ... + + +@overload +def fixture( + fixture_function: None = ..., + *, + scope: "Union[_Scope, Callable[[str, Config], _Scope]]" = ..., + params: Optional[Iterable[object]] = ..., + autouse: bool = ..., + ids: Optional[ + Union[ + Iterable[Union[None, str, float, int, bool]], + Callable[[Any], Optional[object]], + ] + ] = ..., + name: Optional[str] = None, +) -> FixtureFunctionMarker: + ... def fixture( - callable_or_scope=None, - *args, - scope="function", - params=None, - autouse=False, - ids=None, - name=None -): + fixture_function: Optional[_FixtureFunction] = None, + *, + scope: "Union[_Scope, Callable[[str, Config], _Scope]]" = "function", + params: Optional[Iterable[object]] = None, + autouse: bool = False, + ids: Optional[ + Union[ + Iterable[Union[None, str, float, int, bool]], + Callable[[Any], Optional[object]], + ] + ] = None, + name: Optional[str] = None, +) -> Union[FixtureFunctionMarker, _FixtureFunction]: """Decorator to mark a fixture factory function. This decorator can be used, with or without parameters, to define a fixture function. The name of the fixture function can later be referenced to cause its - invocation ahead of running tests: test - modules or classes can use the ``pytest.mark.usefixtures(fixturename)`` - marker. - - Test functions can directly use fixture names as input - arguments in which case the fixture instance returned from the fixture - function will be injected. - - Fixtures can provide their values to test functions using ``return`` or ``yield`` - statements. When using ``yield`` the code block after the ``yield`` statement is executed - as teardown code regardless of the test outcome, and must yield exactly once. - - :arg scope: the scope for which this fixture is shared, one of - ``"function"`` (default), ``"class"``, ``"module"``, - ``"package"`` or ``"session"`` (``"package"`` is considered **experimental** - at this time). - - This parameter may also be a callable which receives ``(fixture_name, config)`` - as parameters, and must return a ``str`` with one of the values mentioned above. - - See :ref:`dynamic scope` in the docs for more information. - - :arg params: an optional list of parameters which will cause multiple - invocations of the fixture function and all of the tests - using it. - The current parameter is available in ``request.param``. - - :arg autouse: if True, the fixture func is activated for all tests that - can see it. If False (the default) then an explicit - reference is needed to activate the fixture. - - :arg ids: list of string ids each corresponding to the params - so that they are part of the test id. If no ids are provided - they will be generated automatically from the params. - - :arg name: the name of the fixture. This defaults to the name of the - decorated function. If a fixture is used in the same module in - which it is defined, the function name of the fixture will be - shadowed by the function arg that requests the fixture; one way - to resolve this is to name the decorated function - ``fixture_`` and then use - ``@pytest.fixture(name='')``. + invocation ahead of running tests: test modules or classes can use the + ``pytest.mark.usefixtures(fixturename)`` marker. + + Test functions can directly use fixture names as input arguments in which + case the fixture instance returned from the fixture function will be + injected. + + Fixtures can provide their values to test functions using ``return`` or + ``yield`` statements. When using ``yield`` the code block after the + ``yield`` statement is executed as teardown code regardless of the test + outcome, and must yield exactly once. + + :param scope: + The scope for which this fixture is shared; one of ``"function"`` + (default), ``"class"``, ``"module"``, ``"package"`` or ``"session"``. + + This parameter may also be a callable which receives ``(fixture_name, config)`` + as parameters, and must return a ``str`` with one of the values mentioned above. + + See :ref:`dynamic scope` in the docs for more information. + + :param params: + An optional list of parameters which will cause multiple invocations + of the fixture function and all of the tests using it. The current + parameter is available in ``request.param``. + + :param autouse: + If True, the fixture func is activated for all tests that can see it. + If False (the default), an explicit reference is needed to activate + the fixture. + + :param ids: + List of string ids each corresponding to the params so that they are + part of the test id. If no ids are provided they will be generated + automatically from the params. + + :param name: + The name of the fixture. This defaults to the name of the decorated + function. If a fixture is used in the same module in which it is + defined, the function name of the fixture will be shadowed by the + function arg that requests the fixture; one way to resolve this is to + name the decorated function ``fixture_`` and then use + ``@pytest.fixture(name='')``. """ - if params is not None: - params = list(params) - - fixture_function, arguments = _parse_fixture_args( - callable_or_scope, - *args, - scope=scope, - params=params, - autouse=autouse, - ids=ids, - name=name, + fixture_marker = FixtureFunctionMarker( + scope=scope, params=params, autouse=autouse, ids=ids, name=name, ) - scope = arguments.get("scope") - params = arguments.get("params") - autouse = arguments.get("autouse") - ids = arguments.get("ids") - name = arguments.get("name") - - if fixture_function and params is None and autouse is False: - # direct decoration - return FixtureFunctionMarker(scope, params, autouse, name=name)( - fixture_function - ) - return FixtureFunctionMarker(scope, params, autouse, ids=ids, name=name) + # Direct decoration. + if fixture_function: + return fixture_marker(fixture_function) + + return fixture_marker def yield_fixture( - callable_or_scope=None, + fixture_function=None, *args, scope="function", params=None, autouse=False, ids=None, - name=None + name=None, ): - """ (return a) decorator to mark a yield-fixture factory function. + """(Return a) decorator to mark a yield-fixture factory function. .. deprecated:: 3.0 Use :py:func:`pytest.fixture` directly instead. """ + warnings.warn(YIELD_FIXTURE, stacklevel=2) return fixture( - callable_or_scope, + fixture_function, *args, scope=scope, params=params, @@ -1183,11 +1361,8 @@ def yield_fixture( ) -defaultfuncargprefixmarker = fixture() - - @fixture(scope="session") -def pytestconfig(request): +def pytestconfig(request: FixtureRequest) -> Config: """Session-scoped fixture that returns the :class:`_pytest.config.Config` object. Example:: @@ -1200,7 +1375,7 @@ def test_foo(pytestconfig): return request.config -def pytest_addoption(parser): +def pytest_addoption(parser: Parser) -> None: parser.addini( "usefixtures", type="args", @@ -1210,8 +1385,7 @@ def pytest_addoption(parser): class FixtureManager: - """ - pytest fixtures definitions and information is stored and managed + """pytest fixture definitions and information is stored and managed from this class. During collection fm.parsefactories() is called multiple times to parse @@ -1224,7 +1398,7 @@ class FixtureManager: which themselves offer a fixturenames attribute. The FuncFixtureInfo object holds information about fixtures and FixtureDefs - relevant for a particular function. An initial list of fixtures is + relevant for a particular function. An initial list of fixtures is assembled like this: - ini-defined usefixtures @@ -1234,7 +1408,7 @@ class FixtureManager: Subsequently the funcfixtureinfo.fixturenames attribute is computed as the closure of the fixtures needed to setup the initial fixtures, - i. e. fixtures needed by fixture functions themselves are appended + i.e. fixtures needed by fixture functions themselves are appended to the fixturenames list. Upon the test-setup phases all fixturenames are instantiated, retrieved @@ -1244,24 +1418,27 @@ class FixtureManager: FixtureLookupError = FixtureLookupError FixtureLookupErrorRepr = FixtureLookupErrorRepr - def __init__(self, session): + def __init__(self, session: "Session") -> None: self.session = session - self.config = session.config - self._arg2fixturedefs = {} - self._holderobjseen = set() - self._nodeid_and_autousenames = [("", self.config.getini("usefixtures"))] + self.config: Config = session.config + self._arg2fixturedefs: Dict[str, List[FixtureDef[Any]]] = {} + self._holderobjseen: Set[object] = set() + # A mapping from a nodeid to a list of autouse fixtures it defines. + self._nodeid_autousenames: Dict[str, List[str]] = { + "": self.config.getini("usefixtures"), + } session.config.pluginmanager.register(self, "funcmanage") - def _get_direct_parametrize_args(self, node): - """This function returns all the direct parametrization - arguments of a node, so we don't mistake them for fixtures + def _get_direct_parametrize_args(self, node: nodes.Node) -> List[str]: + """Return all direct parametrization arguments of a node, so we don't + mistake them for fixtures. - Check https://github.com/pytest-dev/pytest/issues/5036 + Check https://github.com/pytest-dev/pytest/issues/5036. - This things are done later as well when dealing with parametrization - so this could be improved + These things are done later as well when dealing with parametrization + so this could be improved. """ - parametrize_argnames = [] + parametrize_argnames: List[str] = [] for marker in node.iter_markers(name="parametrize"): if not marker.kwargs.get("indirect", False): p_argnames, _ = ParameterSet._parse_parametrize_args( @@ -1271,13 +1448,17 @@ def _get_direct_parametrize_args(self, node): return parametrize_argnames - def getfixtureinfo(self, node, func, cls, funcargs=True): + def getfixtureinfo( + self, node: nodes.Node, func, cls, funcargs: bool = True + ) -> FuncFixtureInfo: if funcargs and not getattr(node, "nofuncargs", False): argnames = getfuncargnames(func, name=node.name, cls=cls) else: argnames = () - usefixtures = get_use_fixtures_for_node(node) + usefixtures = tuple( + arg for mark in node.iter_markers(name="usefixtures") for arg in mark.args + ) initialnames = usefixtures + argnames fm = node.session._fixturemanager initialnames, names_closure, arg2fixturedefs = fm.getfixtureclosure( @@ -1285,62 +1466,64 @@ def getfixtureinfo(self, node, func, cls, funcargs=True): ) return FuncFixtureInfo(argnames, initialnames, names_closure, arg2fixturedefs) - def pytest_plugin_registered(self, plugin): + def pytest_plugin_registered(self, plugin: _PluggyPlugin) -> None: nodeid = None try: - p = py.path.local(plugin.__file__).realpath() + p = absolutepath(plugin.__file__) # type: ignore[attr-defined] except AttributeError: pass else: - from _pytest import nodes - - # construct the base nodeid which is later used to check + # Construct the base nodeid which is later used to check # what fixtures are visible for particular tests (as denoted - # by their test id) - if p.basename.startswith("conftest.py"): - nodeid = p.dirpath().relto(self.config.rootdir) - if p.sep != nodes.SEP: - nodeid = nodeid.replace(p.sep, nodes.SEP) + # by their test id). + if p.name.startswith("conftest.py"): + try: + nodeid = str(p.parent.relative_to(self.config.rootpath)) + except ValueError: + nodeid = "" + if nodeid == ".": + nodeid = "" + if os.sep != nodes.SEP: + nodeid = nodeid.replace(os.sep, nodes.SEP) self.parsefactories(plugin, nodeid) - def _getautousenames(self, nodeid): - """ return a tuple of fixture names to be used. """ - autousenames = [] - for baseid, basenames in self._nodeid_and_autousenames: - if nodeid.startswith(baseid): - if baseid: - i = len(baseid) - nextchar = nodeid[i : i + 1] - if nextchar and nextchar not in ":/": - continue - autousenames.extend(basenames) - return autousenames - - def getfixtureclosure(self, fixturenames, parentnode, ignore_args=()): - # collect the closure of all fixtures , starting with the given + def _getautousenames(self, nodeid: str) -> Iterator[str]: + """Return the names of autouse fixtures applicable to nodeid.""" + for parentnodeid in nodes.iterparentnodeids(nodeid): + basenames = self._nodeid_autousenames.get(parentnodeid) + if basenames: + yield from basenames + + def getfixtureclosure( + self, + fixturenames: Tuple[str, ...], + parentnode: nodes.Node, + ignore_args: Sequence[str] = (), + ) -> Tuple[Tuple[str, ...], List[str], Dict[str, Sequence[FixtureDef[Any]]]]: + # Collect the closure of all fixtures, starting with the given # fixturenames as the initial set. As we have to visit all # factory definitions anyway, we also return an arg2fixturedefs # mapping so that the caller can reuse it and does not have # to re-discover fixturedefs again for each fixturename - # (discovering matching fixtures for a given name/node is expensive) + # (discovering matching fixtures for a given name/node is expensive). parentid = parentnode.nodeid - fixturenames_closure = self._getautousenames(parentid) + fixturenames_closure = list(self._getautousenames(parentid)) - def merge(otherlist): + def merge(otherlist: Iterable[str]) -> None: for arg in otherlist: if arg not in fixturenames_closure: fixturenames_closure.append(arg) merge(fixturenames) - # at this point, fixturenames_closure contains what we call "initialnames", + # At this point, fixturenames_closure contains what we call "initialnames", # which is a set of fixturenames the function immediately requests. We # need to return it as well, so save this. initialnames = tuple(fixturenames_closure) - arg2fixturedefs = {} + arg2fixturedefs: Dict[str, Sequence[FixtureDef[Any]]] = {} lastlen = -1 while lastlen != len(fixturenames_closure): lastlen = len(fixturenames_closure) @@ -1354,7 +1537,7 @@ def merge(otherlist): arg2fixturedefs[argname] = fixturedefs merge(fixturedefs[-1].argnames) - def sort_by_scope(arg_name): + def sort_by_scope(arg_name: str) -> int: try: fixturedefs = arg2fixturedefs[arg_name] except KeyError: @@ -1365,41 +1548,58 @@ def sort_by_scope(arg_name): fixturenames_closure.sort(key=sort_by_scope) return initialnames, fixturenames_closure, arg2fixturedefs - def pytest_generate_tests(self, metafunc): + def pytest_generate_tests(self, metafunc: "Metafunc") -> None: + """Generate new tests based on parametrized fixtures used by the given metafunc""" + + def get_parametrize_mark_argnames(mark: Mark) -> Sequence[str]: + args, _ = ParameterSet._parse_parametrize_args(*mark.args, **mark.kwargs) + return args + for argname in metafunc.fixturenames: - faclist = metafunc._arg2fixturedefs.get(argname) - if faclist: - fixturedef = faclist[-1] + # Get the FixtureDefs for the argname. + fixture_defs = metafunc._arg2fixturedefs.get(argname) + if not fixture_defs: + # Will raise FixtureLookupError at setup time if not parametrized somewhere + # else (e.g @pytest.mark.parametrize) + continue + + # If the test itself parametrizes using this argname, give it + # precedence. + if any( + argname in get_parametrize_mark_argnames(mark) + for mark in metafunc.definition.iter_markers("parametrize") + ): + continue + + # In the common case we only look at the fixture def with the + # closest scope (last in the list). But if the fixture overrides + # another fixture, while requesting the super fixture, keep going + # in case the super fixture is parametrized (#1953). + for fixturedef in reversed(fixture_defs): + # Fixture is parametrized, apply it and stop. if fixturedef.params is not None: - markers = list(metafunc.definition.iter_markers("parametrize")) - for parametrize_mark in markers: - if "argnames" in parametrize_mark.kwargs: - argnames = parametrize_mark.kwargs["argnames"] - else: - argnames = parametrize_mark.args[0] - - if not isinstance(argnames, (tuple, list)): - argnames = [ - x.strip() for x in argnames.split(",") if x.strip() - ] - if argname in argnames: - break - else: - metafunc.parametrize( - argname, - fixturedef.params, - indirect=True, - scope=fixturedef.scope, - ids=fixturedef.ids, - ) - else: - continue # will raise FixtureLookupError at setup time + metafunc.parametrize( + argname, + fixturedef.params, + indirect=True, + scope=fixturedef.scope, + ids=fixturedef.ids, + ) + break - def pytest_collection_modifyitems(self, items): - # separate parametrized setups + # Not requesting the overridden super fixture, stop. + if argname not in fixturedef.argnames: + break + + # Try next super fixture, if any. + + def pytest_collection_modifyitems(self, items: List[nodes.Item]) -> None: + # Separate parametrized setups. items[:] = reorder_items(items) - def parsefactories(self, node_or_obj, nodeid=NOTSET, unittest=False): + def parsefactories( + self, node_or_obj, nodeid=NOTSET, unittest: bool = False + ) -> None: if nodeid is not NOTSET: holderobj = node_or_obj else: @@ -1416,25 +1616,26 @@ def parsefactories(self, node_or_obj, nodeid=NOTSET, unittest=False): obj = safe_getattr(holderobj, name, None) marker = getfixturemarker(obj) if not isinstance(marker, FixtureFunctionMarker): - # magic globals with __getattr__ might have got us a wrong - # fixture attribute + # Magic globals with __getattr__ might have got us a wrong + # fixture attribute. continue if marker.name: name = marker.name - # during fixture definition we wrap the original fixture function - # to issue a warning if called directly, so here we unwrap it in order to not emit the warning - # when pytest itself calls the fixture function + # During fixture definition we wrap the original fixture function + # to issue a warning if called directly, so here we unwrap it in + # order to not emit the warning when pytest itself calls the + # fixture function. obj = get_real_method(obj, holderobj) fixture_def = FixtureDef( - self, - nodeid, - name, - obj, - marker.scope, - marker.params, + fixturemanager=self, + baseid=nodeid, + argname=name, + func=obj, + scope=marker.scope, + params=marker.params, unittest=unittest, ids=marker.ids, ) @@ -1453,15 +1654,16 @@ def parsefactories(self, node_or_obj, nodeid=NOTSET, unittest=False): autousenames.append(name) if autousenames: - self._nodeid_and_autousenames.append((nodeid or "", autousenames)) + self._nodeid_autousenames.setdefault(nodeid or "", []).extend(autousenames) - def getfixturedefs(self, argname, nodeid): - """ - Gets a list of fixtures which are applicable to the given node id. + def getfixturedefs( + self, argname: str, nodeid: str + ) -> Optional[Sequence[FixtureDef[Any]]]: + """Get a list of fixtures which are applicable to the given node id. - :param str argname: name of the fixture to search for - :param str nodeid: full node id of the requesting test. - :return: list[FixtureDef] + :param str argname: Name of the fixture to search for. + :param str nodeid: Full node id of the requesting test. + :rtype: Sequence[FixtureDef] """ try: fixturedefs = self._arg2fixturedefs[argname] @@ -1469,18 +1671,10 @@ def getfixturedefs(self, argname, nodeid): return None return tuple(self._matchfactories(fixturedefs, nodeid)) - def _matchfactories(self, fixturedefs, nodeid): - from _pytest import nodes - + def _matchfactories( + self, fixturedefs: Iterable[FixtureDef[Any]], nodeid: str + ) -> Iterator[FixtureDef[Any]]: + parentnodeids = set(nodes.iterparentnodeids(nodeid)) for fixturedef in fixturedefs: - if nodes.ischildnode(fixturedef.baseid, nodeid): + if fixturedef.baseid in parentnodeids: yield fixturedef - - -def get_use_fixtures_for_node(node) -> Tuple[str, ...]: - """Returns the names of all the usefixtures() marks on the given node""" - return tuple( - str(name) - for mark in node.iter_markers(name="usefixtures") - for name in mark.args - ) diff --git a/src/_pytest/freeze_support.py b/src/_pytest/freeze_support.py index f9d613a2b64..8b93ed5f7f8 100644 --- a/src/_pytest/freeze_support.py +++ b/src/_pytest/freeze_support.py @@ -1,14 +1,14 @@ -""" -Provides a function to report all internal modules for using freezing tools -pytest -""" +"""Provides a function to report all internal modules for using freezing +tools.""" +import types +from typing import Iterator +from typing import List +from typing import Union -def freeze_includes(): - """ - Returns a list of module names used by pytest that should be - included by cx_freeze. - """ +def freeze_includes() -> List[str]: + """Return a list of module names used by pytest that should be + included by cx_freeze.""" import py import _pytest @@ -17,25 +17,26 @@ def freeze_includes(): return result -def _iter_all_modules(package, prefix=""): - """ - Iterates over the names of all modules that can be found in the given +def _iter_all_modules( + package: Union[str, types.ModuleType], prefix: str = "", +) -> Iterator[str]: + """Iterate over the names of all modules that can be found in the given package, recursively. - Example: - _iter_all_modules(_pytest) -> - ['_pytest.assertion.newinterpret', - '_pytest.capture', - '_pytest.core', - ... - ] + + >>> import _pytest + >>> list(_iter_all_modules(_pytest)) + ['_pytest._argcomplete', '_pytest._code.code', ...] """ import os import pkgutil - if type(package) is not str: - path, prefix = package.__path__[0], package.__name__ + "." - else: + if isinstance(package, str): path = package + else: + # Type ignored because typeshed doesn't define ModuleType.__path__ + # (only defined on packages). + package_path = package.__path__ # type: ignore[attr-defined] + path, prefix = package_path[0], package.__name__ + "." for _, name, is_package in pkgutil.iter_modules([path]): if is_package: for m in _iter_all_modules(os.path.join(path, name), prefix=name + "."): diff --git a/src/_pytest/helpconfig.py b/src/_pytest/helpconfig.py index ae37fdea45b..4384d07b261 100644 --- a/src/_pytest/helpconfig.py +++ b/src/_pytest/helpconfig.py @@ -1,17 +1,24 @@ -""" version info, help messages, tracing configuration. """ +"""Version info, help messages, tracing configuration.""" import os import sys from argparse import Action +from typing import List +from typing import Optional +from typing import Union import py import pytest +from _pytest.config import Config +from _pytest.config import ExitCode from _pytest.config import PrintHelp +from _pytest.config.argparsing import Parser class HelpAction(Action): - """This is an argparse Action that will raise an exception in - order to skip the rest of the argument parsing when --help is passed. + """An argparse Action that will raise an exception in order to skip the + rest of the argument parsing when --help is passed. + This prevents argparse from quitting due to missing required arguments when any are defined, for example by ``pytest_addoption``. This is similar to the way that the builtin argparse --help option is @@ -31,18 +38,21 @@ def __init__(self, option_strings, dest=None, default=False, help=None): def __call__(self, parser, namespace, values, option_string=None): setattr(namespace, self.dest, self.const) - # We should only skip the rest of the parsing after preparse is done + # We should only skip the rest of the parsing after preparse is done. if getattr(parser._parser, "after_preparse", False): raise PrintHelp -def pytest_addoption(parser): +def pytest_addoption(parser: Parser) -> None: group = parser.getgroup("debugconfig") group.addoption( "--version", "-V", - action="store_true", - help="display pytest version and information about plugins.", + action="count", + default=0, + dest="version", + help="display pytest version and information about plugins." + "When given twice, also display information about plugins.", ) group._addoption( "-h", @@ -57,7 +67,7 @@ def pytest_addoption(parser): dest="plugins", default=[], metavar="name", - help="early-load given plugin module name or entry point (multi-allowed). " + help="early-load given plugin module name or entry point (multi-allowed).\n" "To avoid loading of plugins, use the `no:` prefix, e.g. " "`no:doctest`.", ) @@ -87,7 +97,7 @@ def pytest_addoption(parser): @pytest.hookimpl(hookwrapper=True) def pytest_cmdline_parse(): outcome = yield - config = outcome.get_result() + config: Config = outcome.get_result() if config.option.debug: path = os.path.abspath("pytestdebug.log") debugfile = open(path, "w") @@ -106,7 +116,7 @@ def pytest_cmdline_parse(): undo_tracing = config.pluginmanager.enable_tracing() sys.stderr.write("writing pytestdebug information to %s\n" % path) - def unset_tracing(): + def unset_tracing() -> None: debugfile.close() sys.stderr.write("wrote pytestdebug information to %s\n" % debugfile.name) config.trace.root.setwriter(None) @@ -115,20 +125,23 @@ def unset_tracing(): config.add_cleanup(unset_tracing) -def showversion(config): - sys.stderr.write( - "This is pytest version {}, imported from {}\n".format( - pytest.__version__, pytest.__file__ +def showversion(config: Config) -> None: + if config.option.version > 1: + sys.stderr.write( + "This is pytest version {}, imported from {}\n".format( + pytest.__version__, pytest.__file__ + ) ) - ) - plugininfo = getpluginversioninfo(config) - if plugininfo: - for line in plugininfo: - sys.stderr.write(line + "\n") + plugininfo = getpluginversioninfo(config) + if plugininfo: + for line in plugininfo: + sys.stderr.write(line + "\n") + else: + sys.stderr.write(f"pytest {pytest.__version__}\n") -def pytest_cmdline_main(config): - if config.option.version: +def pytest_cmdline_main(config: Config) -> Optional[Union[int, ExitCode]]: + if config.option.version > 0: showversion(config) return 0 elif config.option.help: @@ -136,9 +149,10 @@ def pytest_cmdline_main(config): showhelp(config) config._ensure_unconfigure() return 0 + return None -def showhelp(config): +def showhelp(config: Config) -> None: import textwrap reporter = config.pluginmanager.get_plugin("terminalreporter") @@ -157,7 +171,9 @@ def showhelp(config): help, type, default = config._parser._inidict[name] if type is None: type = "string" - spec = "{} ({}):".format(name, type) + if help is None: + raise TypeError(f"help argument cannot be None for {name}") + spec = f"{name} ({type}):" tw.write(" %s" % spec) spec_len = len(spec) if spec_len > (indent_len - 3): @@ -178,9 +194,10 @@ def showhelp(config): tw.write(" " * (indent_len - spec_len - 2)) wrapped = textwrap.wrap(help, columns - indent_len, break_on_hyphens=False) - tw.line(wrapped[0]) - for line in wrapped[1:]: - tw.line(indent + line) + if wrapped: + tw.line(wrapped[0]) + for line in wrapped[1:]: + tw.line(indent + line) tw.line() tw.line("environment variables:") @@ -191,7 +208,7 @@ def showhelp(config): ("PYTEST_DEBUG", "set to enable debug tracing of pytest's internals"), ] for name, help in vars: - tw.line(" {:<24} {}".format(name, help)) + tw.line(f" {name:<24} {help}") tw.line() tw.line() @@ -211,24 +228,22 @@ def showhelp(config): conftest_options = [("pytest_plugins", "list of plugin names to load")] -def getpluginversioninfo(config): +def getpluginversioninfo(config: Config) -> List[str]: lines = [] plugininfo = config.pluginmanager.list_plugin_distinfo() if plugininfo: lines.append("setuptools registered plugins:") for plugin, dist in plugininfo: loc = getattr(plugin, "__file__", repr(plugin)) - content = "{}-{} at {}".format(dist.project_name, dist.version, loc) + content = f"{dist.project_name}-{dist.version} at {loc}" lines.append(" " + content) return lines -def pytest_report_header(config): +def pytest_report_header(config: Config) -> List[str]: lines = [] if config.option.debug or config.option.traceconfig: - lines.append( - "using: pytest-{} pylib-{}".format(pytest.__version__, py.__version__) - ) + lines.append(f"using: pytest-{pytest.__version__} pylib-{py.__version__}") verinfo = getpluginversioninfo(config) if verinfo: @@ -242,5 +257,5 @@ def pytest_report_header(config): r = plugin.__file__ else: r = repr(plugin) - lines.append(" {:<20}: {}".format(name, r)) + lines.append(f" {name:<20}: {r}") return lines diff --git a/src/_pytest/hookspec.py b/src/_pytest/hookspec.py index 1e16d092d0b..e499b742c7e 100644 --- a/src/_pytest/hookspec.py +++ b/src/_pytest/hookspec.py @@ -1,14 +1,46 @@ -""" hook specifications for pytest plugins, invoked from main.py and builtin plugins. """ +"""Hook specifications for pytest plugins which are invoked by pytest itself +and by builtin plugins.""" from typing import Any +from typing import Dict +from typing import List +from typing import Mapping from typing import Optional +from typing import Sequence +from typing import Tuple +from typing import TYPE_CHECKING +from typing import Union +import py.path from pluggy import HookspecMarker -from .deprecated import COLLECT_DIRECTORY_HOOK -from _pytest.compat import TYPE_CHECKING +from _pytest.deprecated import WARNING_CAPTURED_HOOK if TYPE_CHECKING: + import pdb + import warnings + from typing_extensions import Literal + + from _pytest._code.code import ExceptionRepr + from _pytest.code import ExceptionInfo + from _pytest.config import Config + from _pytest.config import ExitCode + from _pytest.config import PytestPluginManager + from _pytest.config import _PluggyPlugin + from _pytest.config.argparsing import Parser + from _pytest.fixtures import FixtureDef + from _pytest.fixtures import SubRequest from _pytest.main import Session + from _pytest.nodes import Collector + from _pytest.nodes import Item + from _pytest.outcomes import Exit + from _pytest.python import Function + from _pytest.python import Metafunc + from _pytest.python import Module + from _pytest.python import PyCollector + from _pytest.reports import CollectReport + from _pytest.reports import TestReport + from _pytest.runner import CallInfo + from _pytest.terminal import TerminalReporter hookspec = HookspecMarker("pytest") @@ -19,12 +51,11 @@ @hookspec(historic=True) -def pytest_addhooks(pluginmanager): - """called at plugin registration time to allow adding new hooks via a call to +def pytest_addhooks(pluginmanager: "PytestPluginManager") -> None: + """Called at plugin registration time to allow adding new hooks via a call to ``pluginmanager.add_hookspecs(module_or_class, prefix)``. - - :param _pytest.config.PytestPluginManager pluginmanager: pytest plugin manager + :param _pytest.config.PytestPluginManager pluginmanager: pytest plugin manager. .. note:: This hook is incompatible with ``hookwrapper=True``. @@ -32,11 +63,13 @@ def pytest_addhooks(pluginmanager): @hookspec(historic=True) -def pytest_plugin_registered(plugin, manager): - """ a new pytest plugin got registered. +def pytest_plugin_registered( + plugin: "_PluggyPlugin", manager: "PytestPluginManager" +) -> None: + """A new pytest plugin got registered. - :param plugin: the plugin module or instance - :param _pytest.config.PytestPluginManager manager: pytest plugin manager + :param plugin: The plugin module or instance. + :param _pytest.config.PytestPluginManager manager: pytest plugin manager. .. note:: This hook is incompatible with ``hookwrapper=True``. @@ -44,8 +77,8 @@ def pytest_plugin_registered(plugin, manager): @hookspec(historic=True) -def pytest_addoption(parser, pluginmanager): - """register argparse-style options and ini-style config values, +def pytest_addoption(parser: "Parser", pluginmanager: "PytestPluginManager") -> None: + """Register argparse-style options and ini-style config values, called once at the beginning of a test run. .. note:: @@ -54,15 +87,16 @@ def pytest_addoption(parser, pluginmanager): files situated at the tests root directory due to how pytest :ref:`discovers plugins during startup `. - :arg _pytest.config.argparsing.Parser parser: To add command line options, call + :param _pytest.config.argparsing.Parser parser: + To add command line options, call :py:func:`parser.addoption(...) <_pytest.config.argparsing.Parser.addoption>`. To add ini-file values call :py:func:`parser.addini(...) <_pytest.config.argparsing.Parser.addini>`. - :arg _pytest.config.PytestPluginManager pluginmanager: pytest plugin manager, - which can be used to install :py:func:`hookspec`'s or :py:func:`hookimpl`'s - and allow one plugin to call another plugin's hooks to change how - command line options are added. + :param _pytest.config.PytestPluginManager pluginmanager: + pytest plugin manager, which can be used to install :py:func:`hookspec`'s + or :py:func:`hookimpl`'s and allow one plugin to call another plugin's hooks + to change how command line options are added. Options can later be accessed through the :py:class:`config <_pytest.config.Config>` object, respectively: @@ -82,9 +116,8 @@ def pytest_addoption(parser, pluginmanager): @hookspec(historic=True) -def pytest_configure(config): - """ - Allows plugins and conftest files to perform initial configuration. +def pytest_configure(config: "Config") -> None: + """Allow plugins and conftest files to perform initial configuration. This hook is called for every plugin and initial conftest file after command line options have been parsed. @@ -95,7 +128,7 @@ def pytest_configure(config): .. note:: This hook is incompatible with ``hookwrapper=True``. - :arg _pytest.config.Config config: pytest config object + :param _pytest.config.Config config: The pytest config object. """ @@ -106,21 +139,24 @@ def pytest_configure(config): @hookspec(firstresult=True) -def pytest_cmdline_parse(pluginmanager, args): - """return initialized config object, parsing the specified args. +def pytest_cmdline_parse( + pluginmanager: "PytestPluginManager", args: List[str] +) -> Optional["Config"]: + """Return an initialized config object, parsing the specified args. - Stops at first non-None result, see :ref:`firstresult` + Stops at first non-None result, see :ref:`firstresult`. .. note:: - This hook will only be called for plugin classes passed to the ``plugins`` arg when using `pytest.main`_ to - perform an in-process test run. + This hook will only be called for plugin classes passed to the + ``plugins`` arg when using `pytest.main`_ to perform an in-process + test run. - :param _pytest.config.PytestPluginManager pluginmanager: pytest plugin manager - :param list[str] args: list of arguments passed on the command line + :param _pytest.config.PytestPluginManager pluginmanager: Pytest plugin manager. + :param List[str] args: List of arguments passed on the command line. """ -def pytest_cmdline_preparse(config, args): +def pytest_cmdline_preparse(config: "Config", args: List[str]) -> None: """(**Deprecated**) modify command line arguments before option parsing. This hook is considered deprecated and will be removed in a future pytest version. Consider @@ -129,35 +165,37 @@ def pytest_cmdline_preparse(config, args): .. note:: This hook will not be called for ``conftest.py`` files, only for setuptools plugins. - :param _pytest.config.Config config: pytest config object - :param list[str] args: list of arguments passed on the command line + :param _pytest.config.Config config: The pytest config object. + :param List[str] args: Arguments passed on the command line. """ @hookspec(firstresult=True) -def pytest_cmdline_main(config): - """ called for performing the main command line action. The default +def pytest_cmdline_main(config: "Config") -> Optional[Union["ExitCode", int]]: + """Called for performing the main command line action. The default implementation will invoke the configure hooks and runtest_mainloop. .. note:: This hook will not be called for ``conftest.py`` files, only for setuptools plugins. - Stops at first non-None result, see :ref:`firstresult` + Stops at first non-None result, see :ref:`firstresult`. - :param _pytest.config.Config config: pytest config object + :param _pytest.config.Config config: The pytest config object. """ -def pytest_load_initial_conftests(early_config, parser, args): - """ implements the loading of initial conftest files ahead +def pytest_load_initial_conftests( + early_config: "Config", parser: "Parser", args: List[str] +) -> None: + """Called to implement the loading of initial conftest files ahead of command line option parsing. .. note:: This hook will not be called for ``conftest.py`` files, only for setuptools plugins. - :param _pytest.config.Config early_config: pytest config object - :param list[str] args: list of arguments passed on the command line - :param _pytest.config.argparsing.Parser parser: to add command line options + :param _pytest.config.Config early_config: The pytest config object. + :param List[str] args: Arguments passed on the command line. + :param _pytest.config.argparsing.Parser parser: To add command line options. """ @@ -167,87 +205,114 @@ def pytest_load_initial_conftests(early_config, parser, args): @hookspec(firstresult=True) -def pytest_collection(session: "Session") -> Optional[Any]: - """Perform the collection protocol for the given session. +def pytest_collection(session: "Session") -> Optional[object]: + """Perform the collection phase for the given session. Stops at first non-None result, see :ref:`firstresult`. + The return value is not used, but only stops further processing. + + The default collection phase is this (see individual hooks for full details): + + 1. Starting from ``session`` as the initial collector: + + 1. ``pytest_collectstart(collector)`` + 2. ``report = pytest_make_collect_report(collector)`` + 3. ``pytest_exception_interact(collector, call, report)`` if an interactive exception occurred + 4. For each collected node: + + 1. If an item, ``pytest_itemcollected(item)`` + 2. If a collector, recurse into it. + + 5. ``pytest_collectreport(report)`` + + 2. ``pytest_collection_modifyitems(session, config, items)`` + + 1. ``pytest_deselected(items)`` for any deselected items (may be called multiple times) + + 3. ``pytest_collection_finish(session)`` + 4. Set ``session.items`` to the list of collected items + 5. Set ``session.testscollected`` to the number of collected items + + You can implement this hook to only perform some action before collection, + for example the terminal plugin uses it to start displaying the collection + counter (and returns `None`). - :param _pytest.main.Session session: the pytest session object + :param pytest.Session session: The pytest session object. """ -def pytest_collection_modifyitems(session, config, items): - """ called after collection has been performed, may filter or re-order +def pytest_collection_modifyitems( + session: "Session", config: "Config", items: List["Item"] +) -> None: + """Called after collection has been performed. May filter or re-order the items in-place. - :param _pytest.main.Session session: the pytest session object - :param _pytest.config.Config config: pytest config object - :param List[_pytest.nodes.Item] items: list of item objects + :param pytest.Session session: The pytest session object. + :param _pytest.config.Config config: The pytest config object. + :param List[pytest.Item] items: List of item objects. """ -def pytest_collection_finish(session): - """ called after collection has been performed and modified. +def pytest_collection_finish(session: "Session") -> None: + """Called after collection has been performed and modified. - :param _pytest.main.Session session: the pytest session object + :param pytest.Session session: The pytest session object. """ @hookspec(firstresult=True) -def pytest_ignore_collect(path, config): - """ return True to prevent considering this path for collection. +def pytest_ignore_collect(path: py.path.local, config: "Config") -> Optional[bool]: + """Return True to prevent considering this path for collection. + This hook is consulted for all files and directories prior to calling more specific hooks. - Stops at first non-None result, see :ref:`firstresult` + Stops at first non-None result, see :ref:`firstresult`. - :param path: a :py:class:`py.path.local` - the path to analyze - :param _pytest.config.Config config: pytest config object + :param py.path.local path: The path to analyze. + :param _pytest.config.Config config: The pytest config object. """ -@hookspec(firstresult=True, warn_on_impl=COLLECT_DIRECTORY_HOOK) -def pytest_collect_directory(path, parent): - """ called before traversing a directory for collection files. +def pytest_collect_file( + path: py.path.local, parent: "Collector" +) -> "Optional[Collector]": + """Create a Collector for the given path, or None if not relevant. - Stops at first non-None result, see :ref:`firstresult` + The new node needs to have the specified ``parent`` as a parent. - :param path: a :py:class:`py.path.local` - the path to analyze - """ - - -def pytest_collect_file(path, parent): - """ return collection Node or None for the given path. Any new node - needs to have the specified ``parent`` as a parent. - - :param path: a :py:class:`py.path.local` - the path to collect + :param py.path.local path: The path to collect. """ # logging hooks for collection -def pytest_collectstart(collector): - """ collector starts collecting. """ +def pytest_collectstart(collector: "Collector") -> None: + """Collector starts collecting.""" -def pytest_itemcollected(item): - """ we just collected a test item. """ +def pytest_itemcollected(item: "Item") -> None: + """We just collected a test item.""" -def pytest_collectreport(report): - """ collector finished collecting. """ +def pytest_collectreport(report: "CollectReport") -> None: + """Collector finished collecting.""" -def pytest_deselected(items): - """ called for test items deselected, e.g. by keyword. """ +def pytest_deselected(items: Sequence["Item"]) -> None: + """Called for deselected test items, e.g. by keyword. + + May be called multiple times. + """ @hookspec(firstresult=True) -def pytest_make_collect_report(collector): - """ perform ``collector.collect()`` and return a CollectReport. +def pytest_make_collect_report(collector: "Collector") -> "Optional[CollectReport]": + """Perform ``collector.collect()`` and return a CollectReport. - Stops at first non-None result, see :ref:`firstresult` """ + Stops at first non-None result, see :ref:`firstresult`. + """ # ------------------------------------------------------------------------- @@ -256,165 +321,232 @@ def pytest_make_collect_report(collector): @hookspec(firstresult=True) -def pytest_pycollect_makemodule(path, parent): - """ return a Module collector or None for the given path. +def pytest_pycollect_makemodule(path: py.path.local, parent) -> Optional["Module"]: + """Return a Module collector or None for the given path. + This hook will be called for each matching test module path. The pytest_collect_file hook needs to be used if you want to create test modules for files that do not match as a test module. - Stops at first non-None result, see :ref:`firstresult` + Stops at first non-None result, see :ref:`firstresult`. - :param path: a :py:class:`py.path.local` - the path of module to collect + :param py.path.local path: The path of module to collect. """ @hookspec(firstresult=True) -def pytest_pycollect_makeitem(collector, name, obj): - """ return custom item/collector for a python object in a module, or None. +def pytest_pycollect_makeitem( + collector: "PyCollector", name: str, obj: object +) -> Union[None, "Item", "Collector", List[Union["Item", "Collector"]]]: + """Return a custom item/collector for a Python object in a module, or None. - Stops at first non-None result, see :ref:`firstresult` """ + Stops at first non-None result, see :ref:`firstresult`. + """ @hookspec(firstresult=True) -def pytest_pyfunc_call(pyfuncitem): - """ call underlying test function. +def pytest_pyfunc_call(pyfuncitem: "Function") -> Optional[object]: + """Call underlying test function. - Stops at first non-None result, see :ref:`firstresult` """ + Stops at first non-None result, see :ref:`firstresult`. + """ -def pytest_generate_tests(metafunc): - """ generate (multiple) parametrized calls to a test function.""" +def pytest_generate_tests(metafunc: "Metafunc") -> None: + """Generate (multiple) parametrized calls to a test function.""" @hookspec(firstresult=True) -def pytest_make_parametrize_id(config, val, argname): - """Return a user-friendly string representation of the given ``val`` that will be used - by @pytest.mark.parametrize calls. Return None if the hook doesn't know about ``val``. +def pytest_make_parametrize_id( + config: "Config", val: object, argname: str +) -> Optional[str]: + """Return a user-friendly string representation of the given ``val`` + that will be used by @pytest.mark.parametrize calls, or None if the hook + doesn't know about ``val``. + The parameter name is available as ``argname``, if required. - Stops at first non-None result, see :ref:`firstresult` + Stops at first non-None result, see :ref:`firstresult`. - :param _pytest.config.Config config: pytest config object - :param val: the parametrized value - :param str argname: the automatic parameter name produced by pytest + :param _pytest.config.Config config: The pytest config object. + :param val: The parametrized value. + :param str argname: The automatic parameter name produced by pytest. """ # ------------------------------------------------------------------------- -# generic runtest related hooks +# runtest related hooks # ------------------------------------------------------------------------- @hookspec(firstresult=True) -def pytest_runtestloop(session): - """ called for performing the main runtest loop - (after collection finished). +def pytest_runtestloop(session: "Session") -> Optional[object]: + """Perform the main runtest loop (after collection finished). + + The default hook implementation performs the runtest protocol for all items + collected in the session (``session.items``), unless the collection failed + or the ``collectonly`` pytest option is set. + + If at any point :py:func:`pytest.exit` is called, the loop is + terminated immediately. - Stops at first non-None result, see :ref:`firstresult` + If at any point ``session.shouldfail`` or ``session.shouldstop`` are set, the + loop is terminated after the runtest protocol for the current item is finished. - :param _pytest.main.Session session: the pytest session object + :param pytest.Session session: The pytest session object. + + Stops at first non-None result, see :ref:`firstresult`. + The return value is not used, but only stops further processing. """ @hookspec(firstresult=True) -def pytest_runtest_protocol(item, nextitem): - """ implements the runtest_setup/call/teardown protocol for - the given test item, including capturing exceptions and calling - reporting hooks. - - :arg item: test item for which the runtest protocol is performed. +def pytest_runtest_protocol( + item: "Item", nextitem: "Optional[Item]" +) -> Optional[object]: + """Perform the runtest protocol for a single test item. - :arg nextitem: the scheduled-to-be-next test item (or None if this - is the end my friend). This argument is passed on to - :py:func:`pytest_runtest_teardown`. + The default runtest protocol is this (see individual hooks for full details): - :return boolean: True if no further hook implementations should be invoked. + - ``pytest_runtest_logstart(nodeid, location)`` + - Setup phase: + - ``call = pytest_runtest_setup(item)`` (wrapped in ``CallInfo(when="setup")``) + - ``report = pytest_runtest_makereport(item, call)`` + - ``pytest_runtest_logreport(report)`` + - ``pytest_exception_interact(call, report)`` if an interactive exception occurred - Stops at first non-None result, see :ref:`firstresult` """ + - Call phase, if the the setup passed and the ``setuponly`` pytest option is not set: + - ``call = pytest_runtest_call(item)`` (wrapped in ``CallInfo(when="call")``) + - ``report = pytest_runtest_makereport(item, call)`` + - ``pytest_runtest_logreport(report)`` + - ``pytest_exception_interact(call, report)`` if an interactive exception occurred + - Teardown phase: + - ``call = pytest_runtest_teardown(item, nextitem)`` (wrapped in ``CallInfo(when="teardown")``) + - ``report = pytest_runtest_makereport(item, call)`` + - ``pytest_runtest_logreport(report)`` + - ``pytest_exception_interact(call, report)`` if an interactive exception occurred -def pytest_runtest_logstart(nodeid, location): - """ signal the start of running a single test item. + - ``pytest_runtest_logfinish(nodeid, location)`` - This hook will be called **before** :func:`pytest_runtest_setup`, :func:`pytest_runtest_call` and - :func:`pytest_runtest_teardown` hooks. + :param item: Test item for which the runtest protocol is performed. + :param nextitem: The scheduled-to-be-next test item (or None if this is the end my friend). - :param str nodeid: full id of the item - :param location: a triple of ``(filename, linenum, testname)`` + Stops at first non-None result, see :ref:`firstresult`. + The return value is not used, but only stops further processing. """ -def pytest_runtest_logfinish(nodeid, location): - """ signal the complete finish of running a single test item. +def pytest_runtest_logstart( + nodeid: str, location: Tuple[str, Optional[int], str] +) -> None: + """Called at the start of running the runtest protocol for a single item. - This hook will be called **after** :func:`pytest_runtest_setup`, :func:`pytest_runtest_call` and - :func:`pytest_runtest_teardown` hooks. + See :func:`pytest_runtest_protocol` for a description of the runtest protocol. - :param str nodeid: full id of the item - :param location: a triple of ``(filename, linenum, testname)`` + :param str nodeid: Full node ID of the item. + :param location: A tuple of ``(filename, lineno, testname)``. """ -def pytest_runtest_setup(item): - """ called before ``pytest_runtest_call(item)``. """ +def pytest_runtest_logfinish( + nodeid: str, location: Tuple[str, Optional[int], str] +) -> None: + """Called at the end of running the runtest protocol for a single item. + See :func:`pytest_runtest_protocol` for a description of the runtest protocol. -def pytest_runtest_call(item): - """ called to execute the test ``item``. """ + :param str nodeid: Full node ID of the item. + :param location: A tuple of ``(filename, lineno, testname)``. + """ -def pytest_runtest_teardown(item, nextitem): - """ called after ``pytest_runtest_call``. +def pytest_runtest_setup(item: "Item") -> None: + """Called to perform the setup phase for a test item. - :arg nextitem: the scheduled-to-be-next test item (None if no further - test item is scheduled). This argument can be used to - perform exact teardowns, i.e. calling just enough finalizers - so that nextitem only needs to call setup-functions. + The default implementation runs ``setup()`` on ``item`` and all of its + parents (which haven't been setup yet). This includes obtaining the + values of fixtures required by the item (which haven't been obtained + yet). """ -@hookspec(firstresult=True) -def pytest_runtest_makereport(item, call): - """ return a :py:class:`_pytest.runner.TestReport` object - for the given :py:class:`pytest.Item <_pytest.main.Item>` and - :py:class:`_pytest.runner.CallInfo`. +def pytest_runtest_call(item: "Item") -> None: + """Called to run the test for test item (the call phase). - Stops at first non-None result, see :ref:`firstresult` """ + The default implementation calls ``item.runtest()``. + """ -def pytest_runtest_logreport(report): - """ process a test setup/call/teardown report relating to - the respective phase of executing a test. """ +def pytest_runtest_teardown(item: "Item", nextitem: Optional["Item"]) -> None: + """Called to perform the teardown phase for a test item. + The default implementation runs the finalizers and calls ``teardown()`` + on ``item`` and all of its parents (which need to be torn down). This + includes running the teardown phase of fixtures required by the item (if + they go out of scope). -@hookspec(firstresult=True) -def pytest_report_to_serializable(config, report): - """ - Serializes the given report object into a data structure suitable for sending - over the wire, e.g. converted to JSON. + :param nextitem: + The scheduled-to-be-next test item (None if no further test item is + scheduled). This argument can be used to perform exact teardowns, + i.e. calling just enough finalizers so that nextitem only needs to + call setup-functions. """ @hookspec(firstresult=True) -def pytest_report_from_serializable(config, data): +def pytest_runtest_makereport( + item: "Item", call: "CallInfo[None]" +) -> Optional["TestReport"]: + """Called to create a :py:class:`_pytest.reports.TestReport` for each of + the setup, call and teardown runtest phases of a test item. + + See :func:`pytest_runtest_protocol` for a description of the runtest protocol. + + :param CallInfo[None] call: The ``CallInfo`` for the phase. + + Stops at first non-None result, see :ref:`firstresult`. """ - Restores a report object previously serialized with pytest_report_to_serializable(). + + +def pytest_runtest_logreport(report: "TestReport") -> None: + """Process the :py:class:`_pytest.reports.TestReport` produced for each + of the setup, call and teardown runtest phases of an item. + + See :func:`pytest_runtest_protocol` for a description of the runtest protocol. """ +@hookspec(firstresult=True) +def pytest_report_to_serializable( + config: "Config", report: Union["CollectReport", "TestReport"], +) -> Optional[Dict[str, Any]]: + """Serialize the given report object into a data structure suitable for + sending over the wire, e.g. converted to JSON.""" + + +@hookspec(firstresult=True) +def pytest_report_from_serializable( + config: "Config", data: Dict[str, Any], +) -> Optional[Union["CollectReport", "TestReport"]]: + """Restore a report object previously serialized with pytest_report_to_serializable().""" + + # ------------------------------------------------------------------------- # Fixture related hooks # ------------------------------------------------------------------------- @hookspec(firstresult=True) -def pytest_fixture_setup(fixturedef, request): - """ performs fixture setup execution. +def pytest_fixture_setup( + fixturedef: "FixtureDef[Any]", request: "SubRequest" +) -> Optional[object]: + """Perform fixture setup execution. - :return: The return value of the call to the fixture function + :returns: The return value of the call to the fixture function. - Stops at first non-None result, see :ref:`firstresult` + Stops at first non-None result, see :ref:`firstresult`. .. note:: If the fixture function returns None, other implementations of @@ -423,7 +555,9 @@ def pytest_fixture_setup(fixturedef, request): """ -def pytest_fixture_post_finalizer(fixturedef, request): +def pytest_fixture_post_finalizer( + fixturedef: "FixtureDef[Any]", request: "SubRequest" +) -> None: """Called after fixture teardown, but before the cache is cleared, so the fixture result ``fixturedef.cached_result`` is still available (not ``None``).""" @@ -434,26 +568,28 @@ def pytest_fixture_post_finalizer(fixturedef, request): # ------------------------------------------------------------------------- -def pytest_sessionstart(session): - """ called after the ``Session`` object has been created and before performing collection +def pytest_sessionstart(session: "Session") -> None: + """Called after the ``Session`` object has been created and before performing collection and entering the run test loop. - :param _pytest.main.Session session: the pytest session object + :param pytest.Session session: The pytest session object. """ -def pytest_sessionfinish(session, exitstatus): - """ called after whole test run finished, right before returning the exit status to the system. +def pytest_sessionfinish( + session: "Session", exitstatus: Union[int, "ExitCode"], +) -> None: + """Called after whole test run finished, right before returning the exit status to the system. - :param _pytest.main.Session session: the pytest session object - :param int exitstatus: the status which pytest will return to the system + :param pytest.Session session: The pytest session object. + :param int exitstatus: The status which pytest will return to the system. """ -def pytest_unconfigure(config): - """ called before test process is exited. +def pytest_unconfigure(config: "Config") -> None: + """Called before test process is exited. - :param _pytest.config.Config config: pytest config object + :param _pytest.config.Config config: The pytest config object. """ @@ -462,26 +598,25 @@ def pytest_unconfigure(config): # ------------------------------------------------------------------------- -def pytest_assertrepr_compare(config, op, left, right): - """return explanation for comparisons in failing assert expressions. +def pytest_assertrepr_compare( + config: "Config", op: str, left: object, right: object +) -> Optional[List[str]]: + """Return explanation for comparisons in failing assert expressions. Return None for no custom explanation, otherwise return a list - of strings. The strings will be joined by newlines but any newlines - *in* a string will be escaped. Note that all but the first line will + of strings. The strings will be joined by newlines but any newlines + *in* a string will be escaped. Note that all but the first line will be indented slightly, the intention is for the first line to be a summary. - :param _pytest.config.Config config: pytest config object + :param _pytest.config.Config config: The pytest config object. """ -def pytest_assertion_pass(item, lineno, orig, expl): - """ - **(Experimental)** +def pytest_assertion_pass(item: "Item", lineno: int, orig: str, expl: str) -> None: + """**(Experimental)** Called whenever an assertion passes. .. versionadded:: 5.0 - Hook called whenever an assertion *passes*. - Use this hook to do some processing after a passing assertion. The original assertion information is available in the `orig` string and the pytest introspected assertion information is available in the @@ -498,30 +633,39 @@ def pytest_assertion_pass(item, lineno, orig, expl): You need to **clean the .pyc** files in your project directory and interpreter libraries when enabling this option, as assertions will require to be re-written. - :param _pytest.nodes.Item item: pytest item object of current test - :param int lineno: line number of the assert statement - :param string orig: string with original assertion - :param string expl: string with assert explanation + :param pytest.Item item: pytest item object of current test. + :param int lineno: Line number of the assert statement. + :param str orig: String with the original assertion. + :param str expl: String with the assert explanation. .. note:: This hook is **experimental**, so its parameters or even the hook itself might be changed/removed without warning in any future pytest release. - If you find this hook useful, please share your feedback opening an issue. + If you find this hook useful, please share your feedback in an issue. """ # ------------------------------------------------------------------------- -# hooks for influencing reporting (invoked from _pytest_terminal) +# Hooks for influencing reporting (invoked from _pytest_terminal). # ------------------------------------------------------------------------- -def pytest_report_header(config, startdir): - """ return a string or list of strings to be displayed as header info for terminal reporting. +def pytest_report_header( + config: "Config", startdir: py.path.local +) -> Union[str, List[str]]: + """Return a string or list of strings to be displayed as header info for terminal reporting. - :param _pytest.config.Config config: pytest config object - :param startdir: py.path object with the starting dir + :param _pytest.config.Config config: The pytest config object. + :param py.path.local startdir: The starting dir. + + .. note:: + + Lines returned by a plugin are displayed before those of plugins which + ran before it. + If you want to have your line(s) displayed first, use + :ref:`trylast=True `. .. note:: @@ -531,45 +675,85 @@ def pytest_report_header(config, startdir): """ -def pytest_report_collectionfinish(config, startdir, items): - """ +def pytest_report_collectionfinish( + config: "Config", startdir: py.path.local, items: Sequence["Item"], +) -> Union[str, List[str]]: + """Return a string or list of strings to be displayed after collection + has finished successfully. + + These strings will be displayed after the standard "collected X items" message. + .. versionadded:: 3.2 - return a string or list of strings to be displayed after collection has finished successfully. + :param _pytest.config.Config config: The pytest config object. + :param py.path.local startdir: The starting dir. + :param items: List of pytest items that are going to be executed; this list should not be modified. - This strings will be displayed after the standard "collected X items" message. + .. note:: - :param _pytest.config.Config config: pytest config object - :param startdir: py.path object with the starting dir - :param items: list of pytest items that are going to be executed; this list should not be modified. + Lines returned by a plugin are displayed before those of plugins which + ran before it. + If you want to have your line(s) displayed first, use + :ref:`trylast=True `. """ @hookspec(firstresult=True) -def pytest_report_teststatus(report, config): - """ return result-category, shortletter and verbose word for reporting. +def pytest_report_teststatus( + report: Union["CollectReport", "TestReport"], config: "Config" +) -> Tuple[ + str, str, Union[str, Mapping[str, bool]], +]: + """Return result-category, shortletter and verbose word for status + reporting. - :param _pytest.config.Config config: pytest config object + The result-category is a category in which to count the result, for + example "passed", "skipped", "error" or the empty string. - Stops at first non-None result, see :ref:`firstresult` """ + The shortletter is shown as testing progresses, for example ".", "s", + "E" or the empty string. + The verbose word is shown as testing progresses in verbose mode, for + example "PASSED", "SKIPPED", "ERROR" or the empty string. -def pytest_terminal_summary(terminalreporter, exitstatus, config): + pytest may style these implicitly according to the report outcome. + To provide explicit styling, return a tuple for the verbose word, + for example ``"rerun", "R", ("RERUN", {"yellow": True})``. + + :param report: The report object whose status is to be returned. + :param _pytest.config.Config config: The pytest config object. + + Stops at first non-None result, see :ref:`firstresult`. + """ + + +def pytest_terminal_summary( + terminalreporter: "TerminalReporter", exitstatus: "ExitCode", config: "Config", +) -> None: """Add a section to terminal summary reporting. - :param _pytest.terminal.TerminalReporter terminalreporter: the internal terminal reporter object - :param int exitstatus: the exit status that will be reported back to the OS - :param _pytest.config.Config config: pytest config object + :param _pytest.terminal.TerminalReporter terminalreporter: The internal terminal reporter object. + :param int exitstatus: The exit status that will be reported back to the OS. + :param _pytest.config.Config config: The pytest config object. .. versionadded:: 4.2 The ``config`` parameter. """ -@hookspec(historic=True) -def pytest_warning_captured(warning_message, when, item, location): - """ - Process a warning captured by the internal pytest warnings plugin. +@hookspec(historic=True, warn_on_impl=WARNING_CAPTURED_HOOK) +def pytest_warning_captured( + warning_message: "warnings.WarningMessage", + when: "Literal['config', 'collect', 'runtest']", + item: Optional["Item"], + location: Optional[Tuple[str, int, str]], +) -> None: + """(**Deprecated**) Process a warning captured by the internal pytest warnings plugin. + + .. deprecated:: 6.0 + + This hook is considered deprecated and will be removed in a future pytest version. + Use :func:`pytest_warning_recorded` instead. :param warnings.WarningMessage warning_message: The captured warning. This is the same object produced by :py:func:`warnings.catch_warnings`, and contains @@ -583,27 +767,66 @@ def pytest_warning_captured(warning_message, when, item, location): * ``"runtest"``: during test execution. :param pytest.Item|None item: - **DEPRECATED**: This parameter is incompatible with ``pytest-xdist``, and will always receive ``None`` - in a future release. - The item being executed if ``when`` is ``"runtest"``, otherwise ``None``. :param tuple location: - Holds information about the execution context of the captured warning (filename, linenumber, function). - ``function`` evaluates to when the execution context is at the module level. + When available, holds information about the execution context of the captured + warning (filename, linenumber, function). ``function`` evaluates to + when the execution context is at the module level. + """ + + +@hookspec(historic=True) +def pytest_warning_recorded( + warning_message: "warnings.WarningMessage", + when: "Literal['config', 'collect', 'runtest']", + nodeid: str, + location: Optional[Tuple[str, int, str]], +) -> None: + """Process a warning captured by the internal pytest warnings plugin. + + :param warnings.WarningMessage warning_message: + The captured warning. This is the same object produced by :py:func:`warnings.catch_warnings`, and contains + the same attributes as the parameters of :py:func:`warnings.showwarning`. + + :param str when: + Indicates when the warning was captured. Possible values: + + * ``"config"``: during pytest configuration/initialization stage. + * ``"collect"``: during test collection. + * ``"runtest"``: during test execution. + + :param str nodeid: + Full id of the item. + + :param tuple|None location: + When available, holds information about the execution context of the captured + warning (filename, linenumber, function). ``function`` evaluates to + when the execution context is at the module level. + + .. versionadded:: 6.0 """ # ------------------------------------------------------------------------- -# doctest hooks +# Hooks for influencing skipping # ------------------------------------------------------------------------- -@hookspec(firstresult=True) -def pytest_doctest_prepare_content(content): - """ return processed content for a given doctest +def pytest_markeval_namespace(config: "Config") -> Dict[str, Any]: + """Called when constructing the globals dictionary used for + evaluating string conditions in xfail/skipif markers. + + This is useful when the condition for a marker requires + objects that are expensive or impossible to obtain during + collection time, which is required by normal boolean + conditions. + + .. versionadded:: 6.2 - Stops at first non-None result, see :ref:`firstresult` """ + :param _pytest.config.Config config: The pytest config object. + :returns: A dictionary of additional globals to add. + """ # ------------------------------------------------------------------------- @@ -611,38 +834,58 @@ def pytest_doctest_prepare_content(content): # ------------------------------------------------------------------------- -def pytest_internalerror(excrepr, excinfo): - """ called for internal errors. """ +def pytest_internalerror( + excrepr: "ExceptionRepr", excinfo: "ExceptionInfo[BaseException]", +) -> Optional[bool]: + """Called for internal errors. + + Return True to suppress the fallback handling of printing an + INTERNALERROR message directly to sys.stderr. + """ -def pytest_keyboard_interrupt(excinfo): - """ called for keyboard interrupt. """ +def pytest_keyboard_interrupt( + excinfo: "ExceptionInfo[Union[KeyboardInterrupt, Exit]]", +) -> None: + """Called for keyboard interrupt.""" -def pytest_exception_interact(node, call, report): - """called when an exception was raised which can potentially be +def pytest_exception_interact( + node: Union["Item", "Collector"], + call: "CallInfo[Any]", + report: Union["CollectReport", "TestReport"], +) -> None: + """Called when an exception was raised which can potentially be interactively handled. - This hook is only called if an exception was raised - that is not an internal exception like ``skip.Exception``. + May be called during collection (see :py:func:`pytest_make_collect_report`), + in which case ``report`` is a :py:class:`_pytest.reports.CollectReport`. + + May be called during runtest of an item (see :py:func:`pytest_runtest_protocol`), + in which case ``report`` is a :py:class:`_pytest.reports.TestReport`. + + This hook is not called if the exception that was raised is an internal + exception like ``skip.Exception``. """ -def pytest_enter_pdb(config, pdb): - """ called upon pdb.set_trace(), can be used by plugins to take special - action just before the python debugger enters in interactive mode. +def pytest_enter_pdb(config: "Config", pdb: "pdb.Pdb") -> None: + """Called upon pdb.set_trace(). + + Can be used by plugins to take special action just before the python + debugger enters interactive mode. - :param _pytest.config.Config config: pytest config object - :param pdb.Pdb pdb: Pdb instance + :param _pytest.config.Config config: The pytest config object. + :param pdb.Pdb pdb: The Pdb instance. """ -def pytest_leave_pdb(config, pdb): - """ called when leaving pdb (e.g. with continue after pdb.set_trace()). +def pytest_leave_pdb(config: "Config", pdb: "pdb.Pdb") -> None: + """Called when leaving pdb (e.g. with continue after pdb.set_trace()). Can be used by plugins to take special action just after the python debugger leaves interactive mode. - :param _pytest.config.Config config: pytest config object - :param pdb.Pdb pdb: Pdb instance + :param _pytest.config.Config config: The pytest config object. + :param pdb.Pdb pdb: The Pdb instance. """ diff --git a/src/_pytest/junitxml.py b/src/_pytest/junitxml.py index 77e1843127a..c4761cd3b87 100644 --- a/src/_pytest/junitxml.py +++ b/src/_pytest/junitxml.py @@ -1,71 +1,70 @@ -""" - report test results in JUnit-XML format, - for use with Jenkins and build integration servers. - +"""Report test results in JUnit-XML format, for use with Jenkins and build +integration servers. Based on initial code from Ross Lawley. -Output conforms to https://github.com/jenkinsci/xunit-plugin/blob/master/ -src/main/resources/org/jenkinsci/plugins/xunit/types/model/xsd/junit-10.xsd +Output conforms to +https://github.com/jenkinsci/xunit-plugin/blob/master/src/main/resources/org/jenkinsci/plugins/xunit/types/model/xsd/junit-10.xsd """ import functools import os import platform import re -import sys -import time +import xml.etree.ElementTree as ET from datetime import datetime - -import py +from typing import Callable +from typing import Dict +from typing import List +from typing import Match +from typing import Optional +from typing import Tuple +from typing import Union import pytest -from _pytest import deprecated from _pytest import nodes +from _pytest import timing +from _pytest._code.code import ExceptionRepr +from _pytest._code.code import ReprFileLocation +from _pytest.config import Config from _pytest.config import filename_arg +from _pytest.config.argparsing import Parser +from _pytest.fixtures import FixtureRequest +from _pytest.reports import TestReport from _pytest.store import StoreKey -from _pytest.warnings import _issue_warning_captured +from _pytest.terminal import TerminalReporter xml_key = StoreKey["LogXML"]() -class Junit(py.xml.Namespace): - pass - - -# We need to get the subset of the invalid unicode ranges according to -# XML 1.0 which are valid in this python build. Hence we calculate -# this dynamically instead of hardcoding it. The spec range of valid -# chars is: Char ::= #x9 | #xA | #xD | [#x20-#xD7FF] | [#xE000-#xFFFD] -# | [#x10000-#x10FFFF] -_legal_chars = (0x09, 0x0A, 0x0D) -_legal_ranges = ((0x20, 0x7E), (0x80, 0xD7FF), (0xE000, 0xFFFD), (0x10000, 0x10FFFF)) -_legal_xml_re = [ - "{}-{}".format(chr(low), chr(high)) - for (low, high) in _legal_ranges - if low < sys.maxunicode -] -_legal_xml_re = [chr(x) for x in _legal_chars] + _legal_xml_re -illegal_xml_re = re.compile("[^%s]" % "".join(_legal_xml_re)) -del _legal_chars -del _legal_ranges -del _legal_xml_re - -_py_ext_re = re.compile(r"\.py$") +def bin_xml_escape(arg: object) -> str: + r"""Visually escape invalid XML characters. + For example, transforms + 'hello\aworld\b' + into + 'hello#x07world#x08' + Note that the #xABs are *not* XML escapes - missing the ampersand «. + The idea is to escape visually for the user rather than for XML itself. + """ -def bin_xml_escape(arg): - def repl(matchobj): + def repl(matchobj: Match[str]) -> str: i = ord(matchobj.group()) if i <= 0xFF: return "#x%02X" % i else: return "#x%04X" % i - return py.xml.raw(illegal_xml_re.sub(repl, py.xml.escape(arg))) + # The spec range of valid chars is: + # Char ::= #x9 | #xA | #xD | [#x20-#xD7FF] | [#xE000-#xFFFD] | [#x10000-#x10FFFF] + # For an unknown(?) reason, we disallow #x7F (DEL) as well. + illegal_xml_re = ( + "[^\u0009\u000A\u000D\u0020-\u007E\u0080-\uD7FF\uE000-\uFFFD\u10000-\u10FFFF]" + ) + return re.sub(illegal_xml_re, repl, str(arg)) -def merge_family(left, right): +def merge_family(left, right) -> None: result = {} for kl, vl in left.items(): for kr, vr in right.items(): @@ -79,68 +78,63 @@ def merge_family(left, right): families["_base"] = {"testcase": ["classname", "name"]} families["_base_legacy"] = {"testcase": ["file", "line", "url"]} -# xUnit 1.x inherits legacy attributes +# xUnit 1.x inherits legacy attributes. families["xunit1"] = families["_base"].copy() merge_family(families["xunit1"], families["_base_legacy"]) -# xUnit 2.x uses strict base attributes +# xUnit 2.x uses strict base attributes. families["xunit2"] = families["_base"] class _NodeReporter: - def __init__(self, nodeid, xml): + def __init__(self, nodeid: Union[str, TestReport], xml: "LogXML") -> None: self.id = nodeid self.xml = xml self.add_stats = self.xml.add_stats self.family = self.xml.family self.duration = 0 - self.properties = [] - self.nodes = [] - self.testcase = None - self.attrs = {} + self.properties: List[Tuple[str, str]] = [] + self.nodes: List[ET.Element] = [] + self.attrs: Dict[str, str] = {} - def append(self, node): - self.xml.add_stats(type(node).__name__) + def append(self, node: ET.Element) -> None: + self.xml.add_stats(node.tag) self.nodes.append(node) - def add_property(self, name, value): + def add_property(self, name: str, value: object) -> None: self.properties.append((str(name), bin_xml_escape(value))) - def add_attribute(self, name, value): + def add_attribute(self, name: str, value: object) -> None: self.attrs[str(name)] = bin_xml_escape(value) - def make_properties_node(self): - """Return a Junit node containing custom properties, if any. - """ + def make_properties_node(self) -> Optional[ET.Element]: + """Return a Junit node containing custom properties, if any.""" if self.properties: - return Junit.properties( - [ - Junit.property(name=name, value=value) - for name, value in self.properties - ] - ) - return "" + properties = ET.Element("properties") + for name, value in self.properties: + properties.append(ET.Element("property", name=name, value=value)) + return properties + return None - def record_testreport(self, testreport): - assert not self.testcase + def record_testreport(self, testreport: TestReport) -> None: names = mangle_test_address(testreport.nodeid) existing_attrs = self.attrs classnames = names[:-1] if self.xml.prefix: classnames.insert(0, self.xml.prefix) - attrs = { + attrs: Dict[str, str] = { "classname": ".".join(classnames), "name": bin_xml_escape(names[-1]), "file": testreport.location[0], } if testreport.location[1] is not None: - attrs["line"] = testreport.location[1] + attrs["line"] = str(testreport.location[1]) if hasattr(testreport, "url"): attrs["url"] = testreport.url self.attrs = attrs - self.attrs.update(existing_attrs) # restore any user-defined attributes + self.attrs.update(existing_attrs) # Restore any user-defined attributes. - # Preserve legacy testcase behavior + # Preserve legacy testcase behavior. if self.family == "xunit1": return @@ -152,19 +146,20 @@ def record_testreport(self, testreport): temp_attrs[key] = self.attrs[key] self.attrs = temp_attrs - def to_xml(self): - testcase = Junit.testcase(time="%.3f" % self.duration, **self.attrs) - testcase.append(self.make_properties_node()) - for node in self.nodes: - testcase.append(node) + def to_xml(self) -> ET.Element: + testcase = ET.Element("testcase", self.attrs, time="%.3f" % self.duration) + properties = self.make_properties_node() + if properties is not None: + testcase.append(properties) + testcase.extend(self.nodes) return testcase - def _add_simple(self, kind, message, data=None): - data = bin_xml_escape(data) - node = kind(data, message=message) + def _add_simple(self, tag: str, message: str, data: Optional[str] = None) -> None: + node = ET.Element(tag, message=message) + node.text = bin_xml_escape(data) self.append(node) - def write_captured_output(self, report): + def write_captured_output(self, report: TestReport) -> None: if not self.xml.log_passing_tests and report.passed: return @@ -187,81 +182,89 @@ def write_captured_output(self, report): if content_all: self._write_content(report, content_all, "system-out") - def _prepare_content(self, content, header): + def _prepare_content(self, content: str, header: str) -> str: return "\n".join([header.center(80, "-"), content, ""]) - def _write_content(self, report, content, jheader): - tag = getattr(Junit, jheader) - self.append(tag(bin_xml_escape(content))) + def _write_content(self, report: TestReport, content: str, jheader: str) -> None: + tag = ET.Element(jheader) + tag.text = bin_xml_escape(content) + self.append(tag) - def append_pass(self, report): + def append_pass(self, report: TestReport) -> None: self.add_stats("passed") - def append_failure(self, report): + def append_failure(self, report: TestReport) -> None: # msg = str(report.longrepr.reprtraceback.extraline) if hasattr(report, "wasxfail"): - self._add_simple(Junit.skipped, "xfail-marked test passes unexpectedly") + self._add_simple("skipped", "xfail-marked test passes unexpectedly") else: - if hasattr(report.longrepr, "reprcrash"): - message = report.longrepr.reprcrash.message - elif isinstance(report.longrepr, str): - message = report.longrepr + assert report.longrepr is not None + reprcrash: Optional[ReprFileLocation] = getattr( + report.longrepr, "reprcrash", None + ) + if reprcrash is not None: + message = reprcrash.message else: message = str(report.longrepr) message = bin_xml_escape(message) - fail = Junit.failure(message=message) - fail.append(bin_xml_escape(report.longrepr)) - self.append(fail) + self._add_simple("failure", message, str(report.longrepr)) - def append_collect_error(self, report): + def append_collect_error(self, report: TestReport) -> None: # msg = str(report.longrepr.reprtraceback.extraline) - self.append( - Junit.error(bin_xml_escape(report.longrepr), message="collection failure") - ) + assert report.longrepr is not None + self._add_simple("error", "collection failure", str(report.longrepr)) - def append_collect_skipped(self, report): - self._add_simple(Junit.skipped, "collection skipped", report.longrepr) + def append_collect_skipped(self, report: TestReport) -> None: + self._add_simple("skipped", "collection skipped", str(report.longrepr)) + + def append_error(self, report: TestReport) -> None: + assert report.longrepr is not None + reprcrash: Optional[ReprFileLocation] = getattr( + report.longrepr, "reprcrash", None + ) + if reprcrash is not None: + reason = reprcrash.message + else: + reason = str(report.longrepr) - def append_error(self, report): if report.when == "teardown": - msg = "test teardown failure" + msg = f'failed on teardown with "{reason}"' else: - msg = "test setup failure" - self._add_simple(Junit.error, msg, report.longrepr) + msg = f'failed on setup with "{reason}"' + self._add_simple("error", msg, str(report.longrepr)) - def append_skipped(self, report): + def append_skipped(self, report: TestReport) -> None: if hasattr(report, "wasxfail"): xfailreason = report.wasxfail if xfailreason.startswith("reason: "): xfailreason = xfailreason[8:] - self.append( - Junit.skipped( - "", type="pytest.xfail", message=bin_xml_escape(xfailreason) - ) - ) + xfailreason = bin_xml_escape(xfailreason) + skipped = ET.Element("skipped", type="pytest.xfail", message=xfailreason) + self.append(skipped) else: + assert isinstance(report.longrepr, tuple) filename, lineno, skipreason = report.longrepr if skipreason.startswith("Skipped: "): skipreason = skipreason[9:] - details = "{}:{}: {}".format(filename, lineno, skipreason) + details = f"{filename}:{lineno}: {skipreason}" - self.append( - Junit.skipped( - bin_xml_escape(details), - type="pytest.skip", - message=bin_xml_escape(skipreason), - ) - ) + skipped = ET.Element("skipped", type="pytest.skip", message=skipreason) + skipped.text = bin_xml_escape(details) + self.append(skipped) self.write_captured_output(report) - def finalize(self): - data = self.to_xml().unicode(indent=0) + def finalize(self) -> None: + data = self.to_xml() self.__dict__.clear() - self.to_xml = lambda: py.xml.raw(data) + # Type ignored becuase mypy doesn't like overriding a method. + # Also the return value doesn't match... + self.to_xml = lambda: data # type: ignore[assignment] -def _warn_incompatibility_with_xunit2(request, fixture_name): - """Emits a PytestWarning about the given fixture being incompatible with newer xunit revisions""" +def _warn_incompatibility_with_xunit2( + request: FixtureRequest, fixture_name: str +) -> None: + """Emit a PytestWarning about the given fixture being incompatible with newer xunit revisions.""" from _pytest.warning_types import PytestWarning xml = request.config._store.get(xml_key, None) @@ -276,12 +279,14 @@ def _warn_incompatibility_with_xunit2(request, fixture_name): @pytest.fixture -def record_property(request): - """Add an extra properties the calling test. +def record_property(request: FixtureRequest) -> Callable[[str, object], None]: + """Add extra properties to the calling test. + User properties become part of the test report and are available to the configured reporters, like JUnit XML. - The fixture is callable with ``(name, value)``, with value being automatically - xml-encoded. + + The fixture is callable with ``name, value``. The value is automatically + XML-encoded. Example:: @@ -290,17 +295,18 @@ def test_function(record_property): """ _warn_incompatibility_with_xunit2(request, "record_property") - def append_property(name, value): + def append_property(name: str, value: object) -> None: request.node.user_properties.append((name, value)) return append_property @pytest.fixture -def record_xml_attribute(request): +def record_xml_attribute(request: FixtureRequest) -> Callable[[str, object], None]: """Add extra xml attributes to the tag for the calling test. - The fixture is callable with ``(name, value)``, with value being - automatically xml-encoded + + The fixture is callable with ``name, value``. The value is + automatically XML-encoded. """ from _pytest.warning_types import PytestExperimentalApiWarning @@ -311,7 +317,7 @@ def record_xml_attribute(request): _warn_incompatibility_with_xunit2(request, "record_xml_attribute") # Declare noop - def add_attr_noop(name, value): + def add_attr_noop(name: str, value: object) -> None: pass attr_func = add_attr_noop @@ -324,20 +330,21 @@ def add_attr_noop(name, value): return attr_func -def _check_record_param_type(param, v): +def _check_record_param_type(param: str, v: str) -> None: """Used by record_testsuite_property to check that the given parameter name is of the proper - type""" + type.""" __tracebackhide__ = True if not isinstance(v, str): - msg = "{param} parameter needs to be a string, but {g} given" + msg = "{param} parameter needs to be a string, but {g} given" # type: ignore[unreachable] raise TypeError(msg.format(param=param, g=type(v).__name__)) @pytest.fixture(scope="session") -def record_testsuite_property(request): - """ - Records a new ```` tag as child of the root ````. This is suitable to - writing global information regarding the entire test suite, and is compatible with ``xunit2`` JUnit family. +def record_testsuite_property(request: FixtureRequest) -> Callable[[str, object], None]: + """Record a new ```` tag as child of the root ````. + + This is suitable to writing global information regarding the entire test + suite, and is compatible with ``xunit2`` JUnit family. This is a ``session``-scoped fixture which is called with ``(name, value)``. Example: @@ -348,12 +355,18 @@ def test_foo(record_testsuite_property): record_testsuite_property("STORAGE_TYPE", "CEPH") ``name`` must be a string, ``value`` will be converted to a string and properly xml-escaped. + + .. warning:: + + Currently this fixture **does not work** with the + `pytest-xdist `__ plugin. See issue + `#7767 `__ for details. """ __tracebackhide__ = True - def record_func(name, value): - """noop function in case --junitxml was not passed in the command-line""" + def record_func(name: str, value: object) -> None: + """No-op function in case --junitxml was not passed in the command-line.""" __tracebackhide__ = True _check_record_param_type("name", name) @@ -363,7 +376,7 @@ def record_func(name, value): return record_func -def pytest_addoption(parser): +def pytest_addoption(parser: Parser) -> None: group = parser.getgroup("terminal reporting") group.addoption( "--junitxml", @@ -404,18 +417,17 @@ def pytest_addoption(parser): default="total", ) # choices=['total', 'call']) parser.addini( - "junit_family", "Emit XML for schema: one of legacy|xunit1|xunit2", default=None + "junit_family", + "Emit XML for schema: one of legacy|xunit1|xunit2", + default="xunit2", ) -def pytest_configure(config): +def pytest_configure(config: Config) -> None: xmlpath = config.option.xmlpath - # prevent opening xmllog on slave nodes (xdist) - if xmlpath and not hasattr(config, "slaveinput"): + # Prevent opening xmllog on worker nodes (xdist). + if xmlpath and not hasattr(config, "workerinput"): junit_family = config.getini("junit_family") - if not junit_family: - _issue_warning_captured(deprecated.JUNIT_XML_DEFAULT_FAMILY, config.hook, 2) - junit_family = "xunit1" config._store[xml_key] = LogXML( xmlpath, config.option.junitprefix, @@ -428,24 +440,24 @@ def pytest_configure(config): config.pluginmanager.register(config._store[xml_key]) -def pytest_unconfigure(config): +def pytest_unconfigure(config: Config) -> None: xml = config._store.get(xml_key, None) if xml: del config._store[xml_key] config.pluginmanager.unregister(xml) -def mangle_test_address(address): +def mangle_test_address(address: str) -> List[str]: path, possible_open_bracket, params = address.partition("[") names = path.split("::") try: names.remove("()") except ValueError: pass - # convert file path to dotted path + # Convert file path to dotted path. names[0] = names[0].replace(nodes.SEP, ".") - names[0] = _py_ext_re.sub("", names[0]) - # put any params back + names[0] = re.sub(r"\.py$", "", names[0]) + # Put any params back. names[-1] += possible_open_bracket + params return names @@ -454,13 +466,13 @@ class LogXML: def __init__( self, logfile, - prefix, - suite_name="pytest", - logging="no", - report_duration="total", + prefix: Optional[str], + suite_name: str = "pytest", + logging: str = "no", + report_duration: str = "total", family="xunit1", - log_passing_tests=True, - ): + log_passing_tests: bool = True, + ) -> None: logfile = os.path.expanduser(os.path.expandvars(logfile)) self.logfile = os.path.normpath(os.path.abspath(logfile)) self.prefix = prefix @@ -469,33 +481,37 @@ def __init__( self.log_passing_tests = log_passing_tests self.report_duration = report_duration self.family = family - self.stats = dict.fromkeys(["error", "passed", "failure", "skipped"], 0) - self.node_reporters = {} # nodeid -> _NodeReporter - self.node_reporters_ordered = [] - self.global_properties = [] + self.stats: Dict[str, int] = dict.fromkeys( + ["error", "passed", "failure", "skipped"], 0 + ) + self.node_reporters: Dict[ + Tuple[Union[str, TestReport], object], _NodeReporter + ] = ({}) + self.node_reporters_ordered: List[_NodeReporter] = [] + self.global_properties: List[Tuple[str, str]] = [] # List of reports that failed on call but teardown is pending. - self.open_reports = [] + self.open_reports: List[TestReport] = [] self.cnt_double_fail_tests = 0 - # Replaces convenience family with real family + # Replaces convenience family with real family. if self.family == "legacy": self.family = "xunit1" - def finalize(self, report): + def finalize(self, report: TestReport) -> None: nodeid = getattr(report, "nodeid", report) - # local hack to handle xdist report order - slavenode = getattr(report, "node", None) - reporter = self.node_reporters.pop((nodeid, slavenode)) + # Local hack to handle xdist report order. + workernode = getattr(report, "node", None) + reporter = self.node_reporters.pop((nodeid, workernode)) if reporter is not None: reporter.finalize() - def node_reporter(self, report): - nodeid = getattr(report, "nodeid", report) - # local hack to handle xdist report order - slavenode = getattr(report, "node", None) + def node_reporter(self, report: Union[TestReport, str]) -> _NodeReporter: + nodeid: Union[str, TestReport] = getattr(report, "nodeid", report) + # Local hack to handle xdist report order. + workernode = getattr(report, "node", None) - key = nodeid, slavenode + key = nodeid, workernode if key in self.node_reporters: # TODO: breaks for --dist=each @@ -508,23 +524,23 @@ def node_reporter(self, report): return reporter - def add_stats(self, key): + def add_stats(self, key: str) -> None: if key in self.stats: self.stats[key] += 1 - def _opentestcase(self, report): + def _opentestcase(self, report: TestReport) -> _NodeReporter: reporter = self.node_reporter(report) reporter.record_testreport(report) return reporter - def pytest_runtest_logreport(self, report): - """handle a setup/call/teardown report, generating the appropriate - xml tags as necessary. + def pytest_runtest_logreport(self, report: TestReport) -> None: + """Handle a setup/call/teardown report, generating the appropriate + XML tags as necessary. - note: due to plugins like xdist, this hook may be called in interlaced - order with reports from other nodes. for example: + Note: due to plugins like xdist, this hook may be called in interlaced + order with reports from other nodes. For example: - usual call order: + Usual call order: -> setup node1 -> call node1 -> teardown node1 @@ -532,7 +548,7 @@ def pytest_runtest_logreport(self, report): -> call node2 -> teardown node2 - possible call order in xdist: + Possible call order in xdist: -> setup node1 -> call node1 -> setup node2 @@ -547,7 +563,7 @@ def pytest_runtest_logreport(self, report): reporter.append_pass(report) elif report.failed: if report.when == "teardown": - # The following vars are needed when xdist plugin is used + # The following vars are needed when xdist plugin is used. report_wid = getattr(report, "worker_id", None) report_ii = getattr(report, "item_index", None) close_report = next( @@ -565,7 +581,7 @@ def pytest_runtest_logreport(self, report): if close_report: # We need to open new testcase in case we have failure in # call and error in teardown in order to follow junit - # schema + # schema. self.finalize(close_report) self.cnt_double_fail_tests += 1 reporter = self._opentestcase(report) @@ -585,7 +601,7 @@ def pytest_runtest_logreport(self, report): reporter.write_captured_output(report) for propname, propvalue in report.user_properties: - reporter.add_property(propname, propvalue) + reporter.add_property(propname, str(propvalue)) self.finalize(report) report_wid = getattr(report, "worker_id", None) @@ -605,15 +621,14 @@ def pytest_runtest_logreport(self, report): if close_report: self.open_reports.remove(close_report) - def update_testcase_duration(self, report): - """accumulates total duration for nodeid from given report and updates - the Junit.testcase with the new total if already created. - """ + def update_testcase_duration(self, report: TestReport) -> None: + """Accumulate total duration for nodeid from given report and update + the Junit.testcase with the new total if already created.""" if self.report_duration == "total" or report.when == self.report_duration: reporter = self.node_reporter(report) reporter.duration += getattr(report, "duration", 0.0) - def pytest_collectreport(self, report): + def pytest_collectreport(self, report: TestReport) -> None: if not report.passed: reporter = self._opentestcase(report) if report.failed: @@ -621,20 +636,20 @@ def pytest_collectreport(self, report): else: reporter.append_collect_skipped(report) - def pytest_internalerror(self, excrepr): + def pytest_internalerror(self, excrepr: ExceptionRepr) -> None: reporter = self.node_reporter("internal") reporter.attrs.update(classname="pytest", name="internal") - reporter._add_simple(Junit.error, "internal error", excrepr) + reporter._add_simple("error", "internal error", str(excrepr)) - def pytest_sessionstart(self): - self.suite_start_time = time.time() + def pytest_sessionstart(self) -> None: + self.suite_start_time = timing.time() - def pytest_sessionfinish(self): + def pytest_sessionfinish(self) -> None: dirname = os.path.dirname(os.path.abspath(self.logfile)) if not os.path.isdir(dirname): os.makedirs(dirname) logfile = open(self.logfile, "w", encoding="utf-8") - suite_stop_time = time.time() + suite_stop_time = timing.time() suite_time_delta = suite_stop_time - self.suite_start_time numtests = ( @@ -646,37 +661,40 @@ def pytest_sessionfinish(self): ) logfile.write('') - suite_node = Junit.testsuite( - self._get_global_properties_node(), - [x.to_xml() for x in self.node_reporters_ordered], + suite_node = ET.Element( + "testsuite", name=self.suite_name, - errors=self.stats["error"], - failures=self.stats["failure"], - skipped=self.stats["skipped"], - tests=numtests, + errors=str(self.stats["error"]), + failures=str(self.stats["failure"]), + skipped=str(self.stats["skipped"]), + tests=str(numtests), time="%.3f" % suite_time_delta, timestamp=datetime.fromtimestamp(self.suite_start_time).isoformat(), hostname=platform.node(), ) - logfile.write(Junit.testsuites([suite_node]).unicode(indent=0)) + global_properties = self._get_global_properties_node() + if global_properties is not None: + suite_node.append(global_properties) + for node_reporter in self.node_reporters_ordered: + suite_node.append(node_reporter.to_xml()) + testsuites = ET.Element("testsuites") + testsuites.append(suite_node) + logfile.write(ET.tostring(testsuites, encoding="unicode")) logfile.close() - def pytest_terminal_summary(self, terminalreporter): - terminalreporter.write_sep("-", "generated xml file: %s" % (self.logfile)) + def pytest_terminal_summary(self, terminalreporter: TerminalReporter) -> None: + terminalreporter.write_sep("-", f"generated xml file: {self.logfile}") - def add_global_property(self, name, value): + def add_global_property(self, name: str, value: object) -> None: __tracebackhide__ = True _check_record_param_type("name", name) self.global_properties.append((name, bin_xml_escape(value))) - def _get_global_properties_node(self): - """Return a Junit node containing custom properties, if any. - """ + def _get_global_properties_node(self) -> Optional[ET.Element]: + """Return a Junit node containing custom properties, if any.""" if self.global_properties: - return Junit.properties( - [ - Junit.property(name=name, value=value) - for name, value in self.global_properties - ] - ) - return "" + properties = ET.Element("properties") + for name, value in self.global_properties: + properties.append(ET.Element("property", name=name, value=value)) + return properties + return None diff --git a/src/_pytest/logging.py b/src/_pytest/logging.py index 5e60a232172..2e4847328ab 100644 --- a/src/_pytest/logging.py +++ b/src/_pytest/logging.py @@ -1,38 +1,56 @@ -""" Access and control log capturing. """ +"""Access and control log capturing.""" import logging +import os import re +import sys from contextlib import contextmanager from io import StringIO +from pathlib import Path from typing import AbstractSet from typing import Dict from typing import Generator from typing import List from typing import Mapping from typing import Optional +from typing import Tuple +from typing import TypeVar +from typing import Union -import pytest from _pytest import nodes +from _pytest._io import TerminalWriter +from _pytest.capture import CaptureManager +from _pytest.compat import final from _pytest.compat import nullcontext from _pytest.config import _strtobool from _pytest.config import Config from _pytest.config import create_terminal_writer -from _pytest.pathlib import Path +from _pytest.config import hookimpl +from _pytest.config import UsageError +from _pytest.config.argparsing import Parser +from _pytest.deprecated import check_ispytest +from _pytest.fixtures import fixture +from _pytest.fixtures import FixtureRequest +from _pytest.main import Session +from _pytest.store import StoreKey +from _pytest.terminal import TerminalReporter + DEFAULT_LOG_FORMAT = "%(levelname)-8s %(name)s:%(filename)s:%(lineno)d %(message)s" DEFAULT_LOG_DATE_FORMAT = "%H:%M:%S" _ANSI_ESCAPE_SEQ = re.compile(r"\x1b\[[\d;]+m") +caplog_handler_key = StoreKey["LogCaptureHandler"]() +caplog_records_key = StoreKey[Dict[str, List[logging.LogRecord]]]() -def _remove_ansi_escape_sequences(text): +def _remove_ansi_escape_sequences(text: str) -> str: return _ANSI_ESCAPE_SEQ.sub("", text) class ColoredLevelFormatter(logging.Formatter): - """ - Colorize the %(levelname)..s part of the log format passed to __init__. - """ + """A logging formatter which colorizes the %(levelname)..s part of the + log format passed to __init__.""" - LOGLEVEL_COLOROPTS = { + LOGLEVEL_COLOROPTS: Mapping[int, AbstractSet[str]] = { logging.CRITICAL: {"red"}, logging.ERROR: {"red", "bold"}, logging.WARNING: {"yellow"}, @@ -40,13 +58,13 @@ class ColoredLevelFormatter(logging.Formatter): logging.INFO: {"green"}, logging.DEBUG: {"purple"}, logging.NOTSET: set(), - } # type: Mapping[int, AbstractSet[str]] + } LEVELNAME_FMT_REGEX = re.compile(r"%\(levelname\)([+-.]?\d*s)") - def __init__(self, terminalwriter, *args, **kwargs) -> None: + def __init__(self, terminalwriter: TerminalWriter, *args, **kwargs) -> None: super().__init__(*args, **kwargs) self._original_fmt = self._style._fmt - self._level_to_fmt_mapping = {} # type: Dict[int, str] + self._level_to_fmt_mapping: Dict[int, str] = {} assert self._fmt is not None levelname_fmt_match = self.LEVELNAME_FMT_REGEX.search(self._fmt) @@ -68,7 +86,7 @@ def __init__(self, terminalwriter, *args, **kwargs) -> None: colorized_formatted_levelname, self._fmt ) - def format(self, record): + def format(self, record: logging.LogRecord) -> str: fmt = self._level_to_fmt_mapping.get(record.levelno, self._original_fmt) self._style._fmt = fmt return super().format(record) @@ -81,19 +99,21 @@ class PercentStyleMultiline(logging.PercentStyle): formats the message as if each line were logged separately. """ - def __init__(self, fmt, auto_indent): + def __init__(self, fmt: str, auto_indent: Union[int, str, bool, None]) -> None: super().__init__(fmt) self._auto_indent = self._get_auto_indent(auto_indent) @staticmethod - def _update_message(record_dict, message): + def _update_message( + record_dict: Dict[str, object], message: str + ) -> Dict[str, object]: tmp = record_dict.copy() tmp["message"] = message return tmp @staticmethod - def _get_auto_indent(auto_indent_option) -> int: - """Determines the current auto indentation setting + def _get_auto_indent(auto_indent_option: Union[int, str, bool, None]) -> int: + """Determine the current auto indentation setting. Specify auto indent behavior (on/off/fixed) by passing in extra={"auto_indent": [value]} to the call to logging.log() or @@ -111,20 +131,29 @@ def _get_auto_indent(auto_indent_option) -> int: Any other values for the option are invalid, and will silently be converted to the default. - :param any auto_indent_option: User specified option for indentation - from command line, config or extra kwarg. Accepts int, bool or str. - str option accepts the same range of values as boolean config options, - as well as positive integers represented in str form. + :param None|bool|int|str auto_indent_option: + User specified option for indentation from command line, config + or extra kwarg. Accepts int, bool or str. str option accepts the + same range of values as boolean config options, as well as + positive integers represented in str form. - :returns: indentation value, which can be + :returns: + Indentation value, which can be -1 (automatically determine indentation) or 0 (auto-indent turned off) or >0 (explicitly set indentation position). """ - if type(auto_indent_option) is int: + if auto_indent_option is None: + return 0 + elif isinstance(auto_indent_option, bool): + if auto_indent_option: + return -1 + else: + return 0 + elif isinstance(auto_indent_option, int): return int(auto_indent_option) - elif type(auto_indent_option) is str: + elif isinstance(auto_indent_option, str): try: return int(auto_indent_option) except ValueError: @@ -134,17 +163,14 @@ def _get_auto_indent(auto_indent_option) -> int: return -1 except ValueError: return 0 - elif type(auto_indent_option) is bool: - if auto_indent_option: - return -1 return 0 - def format(self, record): + def format(self, record: logging.LogRecord) -> str: if "\n" in record.message: if hasattr(record, "auto_indent"): - # passed in from the "extra={}" kwarg on the call to logging.log() - auto_indent = self._get_auto_indent(record.auto_indent) + # Passed in from the "extra={}" kwarg on the call to logging.log(). + auto_indent = self._get_auto_indent(record.auto_indent) # type: ignore[attr-defined] else: auto_indent = self._auto_indent @@ -157,14 +183,14 @@ def format(self, record): lines[0] ) else: - # optimizes logging by allowing a fixed indentation + # Optimizes logging by allowing a fixed indentation. indentation = auto_indent lines[0] = formatted return ("\n" + " " * indentation).join(lines) return self._fmt % record.__dict__ -def get_option_ini(config, *names): +def get_option_ini(config: Config, *names: str): for name in names: ret = config.getoption(name) # 'default' arg won't work as expected if ret is None: @@ -173,7 +199,7 @@ def get_option_ini(config, *names): return ret -def pytest_addoption(parser): +def pytest_addoption(parser: Parser) -> None: """Add options to control log capturing.""" group = parser.getgroup("logging") @@ -183,15 +209,6 @@ def add_option_ini(option, dest, default=None, type=None, **kwargs): ) group.addoption(option, dest=dest, **kwargs) - add_option_ini( - "--no-print-logs", - dest="log_print", - action="store_const", - const=False, - default=True, - type="bool", - help="disable printing caught logs on failed tests.", - ) add_option_ini( "--log-level", dest="log_level", @@ -268,109 +285,121 @@ def add_option_ini(option, dest, default=None, type=None, **kwargs): ) -@contextmanager -def catching_logs(handler, formatter=None, level=None): +_HandlerType = TypeVar("_HandlerType", bound=logging.Handler) + + +# Not using @contextmanager for performance reasons. +class catching_logs: """Context manager that prepares the whole logging machinery properly.""" - root_logger = logging.getLogger() - - if formatter is not None: - handler.setFormatter(formatter) - if level is not None: - handler.setLevel(level) - - # Adding the same handler twice would confuse logging system. - # Just don't do that. - add_new_handler = handler not in root_logger.handlers - - if add_new_handler: - root_logger.addHandler(handler) - if level is not None: - orig_level = root_logger.level - root_logger.setLevel(min(orig_level, level)) - try: - yield handler - finally: - if level is not None: - root_logger.setLevel(orig_level) - if add_new_handler: - root_logger.removeHandler(handler) + + __slots__ = ("handler", "level", "orig_level") + + def __init__(self, handler: _HandlerType, level: Optional[int] = None) -> None: + self.handler = handler + self.level = level + + def __enter__(self): + root_logger = logging.getLogger() + if self.level is not None: + self.handler.setLevel(self.level) + root_logger.addHandler(self.handler) + if self.level is not None: + self.orig_level = root_logger.level + root_logger.setLevel(min(self.orig_level, self.level)) + return self.handler + + def __exit__(self, type, value, traceback): + root_logger = logging.getLogger() + if self.level is not None: + root_logger.setLevel(self.orig_level) + root_logger.removeHandler(self.handler) class LogCaptureHandler(logging.StreamHandler): """A logging handler that stores log records and the log text.""" + stream: StringIO + def __init__(self) -> None: - """Creates a new log handler.""" - logging.StreamHandler.__init__(self, StringIO()) - self.records = [] # type: List[logging.LogRecord] + """Create a new log handler.""" + super().__init__(StringIO()) + self.records: List[logging.LogRecord] = [] def emit(self, record: logging.LogRecord) -> None: """Keep the log records in a list in addition to the log text.""" self.records.append(record) - logging.StreamHandler.emit(self, record) + super().emit(record) def reset(self) -> None: self.records = [] self.stream = StringIO() + def handleError(self, record: logging.LogRecord) -> None: + if logging.raiseExceptions: + # Fail the test if the log message is bad (emit failed). + # The default behavior of logging is to print "Logging error" + # to stderr with the call stack and some extra details. + # pytest wants to make such mistakes visible during testing. + raise + +@final class LogCaptureFixture: """Provides access and control of log capturing.""" - def __init__(self, item) -> None: - """Creates a new funcarg.""" + def __init__(self, item: nodes.Node, *, _ispytest: bool = False) -> None: + check_ispytest(_ispytest) self._item = item - # dict of log name -> log level - self._initial_log_levels = {} # type: Dict[str, int] + self._initial_handler_level: Optional[int] = None + # Dict of log name -> log level. + self._initial_logger_levels: Dict[Optional[str], int] = {} def _finalize(self) -> None: - """Finalizes the fixture. + """Finalize the fixture. This restores the log levels changed by :meth:`set_level`. """ - # restore log levels - for logger_name, level in self._initial_log_levels.items(): + # Restore log levels. + if self._initial_handler_level is not None: + self.handler.setLevel(self._initial_handler_level) + for logger_name, level in self._initial_logger_levels.items(): logger = logging.getLogger(logger_name) logger.setLevel(level) @property def handler(self) -> LogCaptureHandler: - """ + """Get the logging handler used by the fixture. + :rtype: LogCaptureHandler """ - return self._item.catch_log_handler # type: ignore[no-any-return] # noqa: F723 + return self._item._store[caplog_handler_key] def get_records(self, when: str) -> List[logging.LogRecord]: - """ - Get the logging records for one of the possible test phases. + """Get the logging records for one of the possible test phases. :param str when: Which test phase to obtain the records from. Valid values are: "setup", "call" and "teardown". + :returns: The list of captured records at the given stage. :rtype: List[logging.LogRecord] - :return: the list of captured records at the given stage .. versionadded:: 3.4 """ - handler = self._item.catch_log_handlers.get(when) - if handler: - return handler.records # type: ignore[no-any-return] # noqa: F723 - else: - return [] + return self._item._store[caplog_records_key].get(when, []) @property - def text(self): - """Returns the formatted log text.""" + def text(self) -> str: + """The formatted log text.""" return _remove_ansi_escape_sequences(self.handler.stream.getvalue()) @property - def records(self): - """Returns the list of log records.""" + def records(self) -> List[logging.LogRecord]: + """The list of log records.""" return self.handler.records @property - def record_tuples(self): - """Returns a list of a stripped down version of log records intended + def record_tuples(self) -> List[Tuple[str, int, str]]: + """A list of a stripped down version of log records intended for use in assertion comparison. The format of the tuple is: @@ -380,61 +409,71 @@ def record_tuples(self): return [(r.name, r.levelno, r.getMessage()) for r in self.records] @property - def messages(self): - """Returns a list of format-interpolated log messages. + def messages(self) -> List[str]: + """A list of format-interpolated log messages. - Unlike 'records', which contains the format string and parameters for interpolation, log messages in this list - are all interpolated. - Unlike 'text', which contains the output from the handler, log messages in this list are unadorned with - levels, timestamps, etc, making exact comparisons more reliable. + Unlike 'records', which contains the format string and parameters for + interpolation, log messages in this list are all interpolated. - Note that traceback or stack info (from :func:`logging.exception` or the `exc_info` or `stack_info` arguments - to the logging functions) is not included, as this is added by the formatter in the handler. + Unlike 'text', which contains the output from the handler, log + messages in this list are unadorned with levels, timestamps, etc, + making exact comparisons more reliable. + + Note that traceback or stack info (from :func:`logging.exception` or + the `exc_info` or `stack_info` arguments to the logging functions) is + not included, as this is added by the formatter in the handler. .. versionadded:: 3.7 """ return [r.getMessage() for r in self.records] - def clear(self): + def clear(self) -> None: """Reset the list of log records and the captured log text.""" self.handler.reset() - def set_level(self, level, logger=None): - """Sets the level for capturing of logs. The level will be restored to its previous value at the end of - the test. - - :param int level: the logger to level. - :param str logger: the logger to update the level. If not given, the root logger level is updated. + 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. .. versionchanged:: 3.4 - The levels of the loggers changed by this function will be restored to their initial values at the - end of the test. + The levels of the loggers changed by this function will be + restored to their initial values at the end of the test. + + :param int level: The level. + :param str logger: The logger to update. If not given, the root logger. """ - logger_name = logger - logger = logging.getLogger(logger_name) - # save the original log-level to restore it during teardown - self._initial_log_levels.setdefault(logger_name, logger.level) - logger.setLevel(level) + logger_obj = logging.getLogger(logger) + # Save the original log-level to restore it during teardown. + self._initial_logger_levels.setdefault(logger, logger_obj.level) + logger_obj.setLevel(level) + if self._initial_handler_level is None: + self._initial_handler_level = self.handler.level + self.handler.setLevel(level) @contextmanager - def at_level(self, level, logger=None): - """Context manager that sets the level for capturing of logs. After the end of the 'with' statement the - level is restored to its original value. + def at_level( + self, level: int, logger: Optional[str] = None + ) -> Generator[None, None, None]: + """Context manager that sets the level for capturing of logs. After + the end of the 'with' statement the level is restored to its original + value. - :param int level: the logger to level. - :param str logger: the logger to update the level. If not given, the root logger level is updated. + :param int level: The level. + :param str logger: The logger to update. If not given, the root logger. """ - logger = logging.getLogger(logger) - orig_level = logger.level - logger.setLevel(level) + logger_obj = logging.getLogger(logger) + orig_level = logger_obj.level + logger_obj.setLevel(level) + handler_orig_level = self.handler.level + self.handler.setLevel(level) try: yield finally: - logger.setLevel(orig_level) + logger_obj.setLevel(orig_level) + self.handler.setLevel(handler_orig_level) -@pytest.fixture -def caplog(request): +@fixture +def caplog(request: FixtureRequest) -> Generator[LogCaptureFixture, None, None]: """Access and control log capturing. Captured logs are available through the following properties/methods:: @@ -445,7 +484,7 @@ def caplog(request): * caplog.record_tuples -> list of (logger_name, level, message) tuples * caplog.clear() -> clear captured records and formatted log output string """ - result = LogCaptureFixture(request.node) + result = LogCaptureFixture(request.node, _ispytest=True) yield result result._finalize() @@ -464,84 +503,92 @@ def get_log_level_for_setting(config: Config, *setting_names: str) -> Optional[i log_level = log_level.upper() try: return int(getattr(logging, log_level, log_level)) - except ValueError: + except ValueError as e: # Python logging does not recognise this as a logging level - raise pytest.UsageError( + raise UsageError( "'{}' is not recognized as a logging level name for " "'{}'. Please consider passing the " "logging level num instead.".format(log_level, setting_name) - ) + ) from e # run after terminalreporter/capturemanager are configured -@pytest.hookimpl(trylast=True) -def pytest_configure(config): +@hookimpl(trylast=True) +def pytest_configure(config: Config) -> None: config.pluginmanager.register(LoggingPlugin(config), "logging-plugin") class LoggingPlugin: - """Attaches to the logging module and captures log messages for each test. - """ + """Attaches to the logging module and captures log messages for each test.""" def __init__(self, config: Config) -> None: - """Creates a new plugin to capture log messages. + """Create a new plugin to capture log messages. The formatter can be safely shared across all handlers so create a single one for the entire test session here. """ self._config = config - self.print_logs = get_option_ini(config, "log_print") - if not self.print_logs: - from _pytest.warnings import _issue_warning_captured - from _pytest.deprecated import NO_PRINT_LOGS - - _issue_warning_captured(NO_PRINT_LOGS, self._config.hook, stacklevel=2) - + # Report logging. self.formatter = self._create_formatter( get_option_ini(config, "log_format"), get_option_ini(config, "log_date_format"), get_option_ini(config, "log_auto_indent"), ) self.log_level = get_log_level_for_setting(config, "log_level") + self.caplog_handler = LogCaptureHandler() + self.caplog_handler.setFormatter(self.formatter) + self.report_handler = LogCaptureHandler() + self.report_handler.setFormatter(self.formatter) + # File logging. self.log_file_level = get_log_level_for_setting(config, "log_file_level") - self.log_file_format = get_option_ini(config, "log_file_format", "log_format") - self.log_file_date_format = get_option_ini( + log_file = get_option_ini(config, "log_file") or os.devnull + if log_file != os.devnull: + directory = os.path.dirname(os.path.abspath(log_file)) + if not os.path.isdir(directory): + os.makedirs(directory) + + self.log_file_handler = _FileHandler(log_file, mode="w", encoding="UTF-8") + log_file_format = get_option_ini(config, "log_file_format", "log_format") + log_file_date_format = get_option_ini( config, "log_file_date_format", "log_date_format" ) - self.log_file_formatter = logging.Formatter( - self.log_file_format, datefmt=self.log_file_date_format - ) - log_file = get_option_ini(config, "log_file") - if log_file: - self.log_file_handler = logging.FileHandler( - log_file, mode="w", encoding="UTF-8" - ) # type: Optional[logging.FileHandler] - self.log_file_handler.setFormatter(self.log_file_formatter) - else: - self.log_file_handler = None - - self.log_cli_handler = None - - self.live_logs_context = lambda: nullcontext() - # Note that the lambda for the live_logs_context is needed because - # live_logs_context can otherwise not be entered multiple times due - # to limitations of contextlib.contextmanager. + log_file_formatter = logging.Formatter( + log_file_format, datefmt=log_file_date_format + ) + self.log_file_handler.setFormatter(log_file_formatter) + # CLI/live logging. + self.log_cli_level = get_log_level_for_setting( + config, "log_cli_level", "log_level" + ) if self._log_cli_enabled(): - self._setup_cli_logging() + terminal_reporter = config.pluginmanager.get_plugin("terminalreporter") + capture_manager = config.pluginmanager.get_plugin("capturemanager") + # if capturemanager plugin is disabled, live logging still works. + self.log_cli_handler: Union[ + _LiveLoggingStreamHandler, _LiveLoggingNullHandler + ] = _LiveLoggingStreamHandler(terminal_reporter, capture_manager) + else: + self.log_cli_handler = _LiveLoggingNullHandler() + log_cli_formatter = self._create_formatter( + get_option_ini(config, "log_cli_format", "log_format"), + get_option_ini(config, "log_cli_date_format", "log_date_format"), + get_option_ini(config, "log_auto_indent"), + ) + self.log_cli_handler.setFormatter(log_cli_formatter) def _create_formatter(self, log_format, log_date_format, auto_indent): - # color option doesn't exist if terminal plugin is disabled + # Color option doesn't exist if terminal plugin is disabled. color = getattr(self._config.option, "color", "no") if color != "no" and ColoredLevelFormatter.LEVELNAME_FMT_REGEX.search( log_format ): - formatter = ColoredLevelFormatter( + formatter: logging.Formatter = ColoredLevelFormatter( create_terminal_writer(self._config), log_format, log_date_format - ) # type: logging.Formatter + ) else: formatter = logging.Formatter(log_format, log_date_format) @@ -551,223 +598,192 @@ def _create_formatter(self, log_format, log_date_format, auto_indent): return formatter - def _setup_cli_logging(self): - config = self._config - terminal_reporter = config.pluginmanager.get_plugin("terminalreporter") - if terminal_reporter is None: - # terminal reporter is disabled e.g. by pytest-xdist. - return - - capture_manager = config.pluginmanager.get_plugin("capturemanager") - # if capturemanager plugin is disabled, live logging still works. - log_cli_handler = _LiveLoggingStreamHandler(terminal_reporter, capture_manager) - - log_cli_formatter = self._create_formatter( - get_option_ini(config, "log_cli_format", "log_format"), - get_option_ini(config, "log_cli_date_format", "log_date_format"), - get_option_ini(config, "log_auto_indent"), - ) - - log_cli_level = get_log_level_for_setting(config, "log_cli_level", "log_level") - self.log_cli_handler = log_cli_handler - self.live_logs_context = lambda: catching_logs( - log_cli_handler, formatter=log_cli_formatter, level=log_cli_level - ) + def set_log_path(self, fname: str) -> None: + """Set the filename parameter for Logging.FileHandler(). - def set_log_path(self, fname): - """Public method, which can set filename parameter for - Logging.FileHandler(). Also creates parent directory if - it does not exist. + Creates parent directory if it does not exist. .. warning:: - Please considered as an experimental API. + This is an experimental API. """ - fname = Path(fname) + fpath = Path(fname) - if not fname.is_absolute(): - fname = Path(self._config.rootdir, fname) + if not fpath.is_absolute(): + fpath = self._config.rootpath / fpath - if not fname.parent.exists(): - fname.parent.mkdir(exist_ok=True, parents=True) + if not fpath.parent.exists(): + fpath.parent.mkdir(exist_ok=True, parents=True) - self.log_file_handler = logging.FileHandler( - str(fname), mode="w", encoding="UTF-8" - ) - self.log_file_handler.setFormatter(self.log_file_formatter) + stream = fpath.open(mode="w", encoding="UTF-8") + if sys.version_info >= (3, 7): + old_stream = self.log_file_handler.setStream(stream) + else: + old_stream = self.log_file_handler.stream + self.log_file_handler.acquire() + try: + self.log_file_handler.flush() + self.log_file_handler.stream = stream + finally: + self.log_file_handler.release() + if old_stream: + old_stream.close() def _log_cli_enabled(self): - """Return True if log_cli should be considered enabled, either explicitly - or because --log-cli-level was given in the command-line. - """ - return self._config.getoption( + """Return whether live logging is enabled.""" + enabled = self._config.getoption( "--log-cli-level" ) is not None or self._config.getini("log_cli") + if not enabled: + return False - @pytest.hookimpl(hookwrapper=True, tryfirst=True) - def pytest_collection(self) -> Generator[None, None, None]: - with self.live_logs_context(): - if self.log_cli_handler: - self.log_cli_handler.set_when("collection") + terminal_reporter = self._config.pluginmanager.get_plugin("terminalreporter") + if terminal_reporter is None: + # terminal reporter is disabled e.g. by pytest-xdist. + return False - if self.log_file_handler is not None: - with catching_logs(self.log_file_handler, level=self.log_file_level): - yield - else: + return True + + @hookimpl(hookwrapper=True, tryfirst=True) + def pytest_sessionstart(self) -> Generator[None, None, None]: + self.log_cli_handler.set_when("sessionstart") + + with catching_logs(self.log_cli_handler, level=self.log_cli_level): + with catching_logs(self.log_file_handler, level=self.log_file_level): yield - @contextmanager - def _runtest_for(self, item, when): - with self._runtest_for_main(item, when): - if self.log_file_handler is not None: - with catching_logs(self.log_file_handler, level=self.log_file_level): - yield - else: + @hookimpl(hookwrapper=True, tryfirst=True) + def pytest_collection(self) -> Generator[None, None, None]: + self.log_cli_handler.set_when("collection") + + with catching_logs(self.log_cli_handler, level=self.log_cli_level): + with catching_logs(self.log_file_handler, level=self.log_file_level): yield - @contextmanager - def _runtest_for_main( - self, item: nodes.Item, when: str - ) -> Generator[None, None, None]: - """Implements the internals of pytest_runtest_xxx() hook.""" - with catching_logs( - LogCaptureHandler(), formatter=self.formatter, level=self.log_level - ) as log_handler: - if self.log_cli_handler: - self.log_cli_handler.set_when(when) - - if item is None: - yield # run the test - return - - if not hasattr(item, "catch_log_handlers"): - item.catch_log_handlers = {} # type: ignore[attr-defined] # noqa: F821 - item.catch_log_handlers[when] = log_handler # type: ignore[attr-defined] # noqa: F821 - item.catch_log_handler = log_handler # type: ignore[attr-defined] # noqa: F821 - try: - yield # run test - finally: - if when == "teardown": - del item.catch_log_handler # type: ignore[attr-defined] # noqa: F821 - del item.catch_log_handlers # type: ignore[attr-defined] # noqa: F821 - - if self.print_logs: - # Add a captured log section to the report. - log = log_handler.stream.getvalue().strip() - item.add_report_section(when, "log", log) - - @pytest.hookimpl(hookwrapper=True) - def pytest_runtest_setup(self, item): - with self._runtest_for(item, "setup"): + @hookimpl(hookwrapper=True) + def pytest_runtestloop(self, session: Session) -> Generator[None, None, None]: + if session.config.option.collectonly: yield + return - @pytest.hookimpl(hookwrapper=True) - def pytest_runtest_call(self, item): - with self._runtest_for(item, "call"): - yield + if self._log_cli_enabled() and self._config.getoption("verbose") < 1: + # The verbose flag is needed to avoid messy test progress output. + self._config.option.verbose = 1 - @pytest.hookimpl(hookwrapper=True) - def pytest_runtest_teardown(self, item): - with self._runtest_for(item, "teardown"): - yield + with catching_logs(self.log_cli_handler, level=self.log_cli_level): + with catching_logs(self.log_file_handler, level=self.log_file_level): + yield # Run all the tests. - @pytest.hookimpl(hookwrapper=True) - def pytest_runtest_logstart(self): - if self.log_cli_handler: - self.log_cli_handler.reset() - with self._runtest_for(None, "start"): - yield + @hookimpl + def pytest_runtest_logstart(self) -> None: + self.log_cli_handler.reset() + self.log_cli_handler.set_when("start") - @pytest.hookimpl(hookwrapper=True) - def pytest_runtest_logfinish(self): - with self._runtest_for(None, "finish"): - yield + @hookimpl + def pytest_runtest_logreport(self) -> None: + self.log_cli_handler.set_when("logreport") + + def _runtest_for(self, item: nodes.Item, when: str) -> Generator[None, None, None]: + """Implement the internals of the pytest_runtest_xxx() hooks.""" + with catching_logs( + self.caplog_handler, level=self.log_level, + ) as caplog_handler, catching_logs( + self.report_handler, level=self.log_level, + ) as report_handler: + caplog_handler.reset() + report_handler.reset() + item._store[caplog_records_key][when] = caplog_handler.records + item._store[caplog_handler_key] = caplog_handler - @pytest.hookimpl(hookwrapper=True) - def pytest_runtest_logreport(self): - with self._runtest_for(None, "logreport"): yield - @pytest.hookimpl(hookwrapper=True, tryfirst=True) - def pytest_sessionfinish(self): - with self.live_logs_context(): - if self.log_cli_handler: - self.log_cli_handler.set_when("sessionfinish") - if self.log_file_handler is not None: - try: - with catching_logs( - self.log_file_handler, level=self.log_file_level - ): - yield - finally: - # Close the FileHandler explicitly. - # (logging.shutdown might have lost the weakref?!) - self.log_file_handler.close() - else: - yield + log = report_handler.stream.getvalue().strip() + item.add_report_section(when, "log", log) - @pytest.hookimpl(hookwrapper=True, tryfirst=True) - def pytest_sessionstart(self): - with self.live_logs_context(): - if self.log_cli_handler: - self.log_cli_handler.set_when("sessionstart") - if self.log_file_handler is not None: - with catching_logs(self.log_file_handler, level=self.log_file_level): - yield - else: + @hookimpl(hookwrapper=True) + def pytest_runtest_setup(self, item: nodes.Item) -> Generator[None, None, None]: + self.log_cli_handler.set_when("setup") + + empty: Dict[str, List[logging.LogRecord]] = {} + item._store[caplog_records_key] = empty + yield from self._runtest_for(item, "setup") + + @hookimpl(hookwrapper=True) + def pytest_runtest_call(self, item: nodes.Item) -> Generator[None, None, None]: + self.log_cli_handler.set_when("call") + + yield from self._runtest_for(item, "call") + + @hookimpl(hookwrapper=True) + def pytest_runtest_teardown(self, item: nodes.Item) -> Generator[None, None, None]: + self.log_cli_handler.set_when("teardown") + + yield from self._runtest_for(item, "teardown") + del item._store[caplog_records_key] + del item._store[caplog_handler_key] + + @hookimpl + def pytest_runtest_logfinish(self) -> None: + self.log_cli_handler.set_when("finish") + + @hookimpl(hookwrapper=True, tryfirst=True) + def pytest_sessionfinish(self) -> Generator[None, None, None]: + self.log_cli_handler.set_when("sessionfinish") + + with catching_logs(self.log_cli_handler, level=self.log_cli_level): + with catching_logs(self.log_file_handler, level=self.log_file_level): yield - @pytest.hookimpl(hookwrapper=True) - def pytest_runtestloop(self, session): - """Runs all collected test items.""" + @hookimpl + def pytest_unconfigure(self) -> None: + # Close the FileHandler explicitly. + # (logging.shutdown might have lost the weakref?!) + self.log_file_handler.close() - if session.config.option.collectonly: - yield - return - if self._log_cli_enabled() and self._config.getoption("verbose") < 1: - # setting verbose flag is needed to avoid messy test progress output - self._config.option.verbose = 1 +class _FileHandler(logging.FileHandler): + """A logging FileHandler with pytest tweaks.""" - with self.live_logs_context(): - if self.log_file_handler is not None: - with catching_logs(self.log_file_handler, level=self.log_file_level): - yield # run all the tests - else: - yield # run all the tests + def handleError(self, record: logging.LogRecord) -> None: + # Handled by LogCaptureHandler. + pass class _LiveLoggingStreamHandler(logging.StreamHandler): - """ - Custom StreamHandler used by the live logging feature: it will write a newline before the first log message - in each test. + """A logging StreamHandler used by the live logging feature: it will + write a newline before the first log message in each test. - During live logging we must also explicitly disable stdout/stderr capturing otherwise it will get captured - and won't appear in the terminal. + During live logging we must also explicitly disable stdout/stderr + capturing otherwise it will get captured and won't appear in the + terminal. """ - def __init__(self, terminal_reporter, capture_manager): - """ - :param _pytest.terminal.TerminalReporter terminal_reporter: - :param _pytest.capture.CaptureManager capture_manager: - """ - logging.StreamHandler.__init__(self, stream=terminal_reporter) + # Officially stream needs to be a IO[str], but TerminalReporter + # isn't. So force it. + stream: TerminalReporter = None # type: ignore + + def __init__( + self, + terminal_reporter: TerminalReporter, + capture_manager: Optional[CaptureManager], + ) -> None: + logging.StreamHandler.__init__(self, stream=terminal_reporter) # type: ignore[arg-type] self.capture_manager = capture_manager self.reset() self.set_when(None) self._test_outcome_written = False - def reset(self): - """Reset the handler; should be called before the start of each test""" + def reset(self) -> None: + """Reset the handler; should be called before the start of each test.""" self._first_record_emitted = False - def set_when(self, when): - """Prepares for the given test phase (setup/call/teardown)""" + def set_when(self, when: Optional[str]) -> None: + """Prepare for the given test phase (setup/call/teardown).""" self._when = when self._section_name_shown = False if when == "start": self._test_outcome_written = False - def emit(self, record): + def emit(self, record: logging.LogRecord) -> None: ctx_manager = ( self.capture_manager.global_and_fixture_disabled() if self.capture_manager @@ -784,4 +800,22 @@ def emit(self, record): if not self._section_name_shown and self._when: self.stream.section("live log " + self._when, sep="-", bold=True) self._section_name_shown = True - logging.StreamHandler.emit(self, record) + super().emit(record) + + def handleError(self, record: logging.LogRecord) -> None: + # Handled by LogCaptureHandler. + pass + + +class _LiveLoggingNullHandler(logging.NullHandler): + """A logging handler used when live logging is disabled.""" + + def reset(self) -> None: + pass + + def set_when(self, when: str) -> None: + pass + + def handleError(self, record: logging.LogRecord) -> None: + # Handled by LogCaptureHandler. + pass diff --git a/src/_pytest/main.py b/src/_pytest/main.py index 61eb7ca74c2..41a33d4494c 100644 --- a/src/_pytest/main.py +++ b/src/_pytest/main.py @@ -1,16 +1,23 @@ -""" core implementation of testing process: init, session, runtest loop. """ +"""Core implementation of the testing process: init, session, runtest loop.""" +import argparse import fnmatch import functools import importlib import os import sys +from pathlib import Path from typing import Callable from typing import Dict from typing import FrozenSet +from typing import Iterator from typing import List from typing import Optional +from typing import overload from typing import Sequence +from typing import Set from typing import Tuple +from typing import Type +from typing import TYPE_CHECKING from typing import Union import attr @@ -18,32 +25,45 @@ import _pytest._code from _pytest import nodes -from _pytest.compat import TYPE_CHECKING +from _pytest.compat import final from _pytest.config import Config from _pytest.config import directory_arg from _pytest.config import ExitCode from _pytest.config import hookimpl +from _pytest.config import PytestPluginManager from _pytest.config import UsageError +from _pytest.config.argparsing import Parser from _pytest.fixtures import FixtureManager from _pytest.outcomes import exit +from _pytest.pathlib import absolutepath +from _pytest.pathlib import bestrelpath +from _pytest.pathlib import visit from _pytest.reports import CollectReport +from _pytest.reports import TestReport from _pytest.runner import collect_one_node from _pytest.runner import SetupState if TYPE_CHECKING: - from typing import Type from typing_extensions import Literal - from _pytest.python import Package - -def pytest_addoption(parser): +def pytest_addoption(parser: Parser) -> None: parser.addini( "norecursedirs", "directory patterns to avoid for recursion", type="args", - default=[".*", "build", "dist", "CVS", "_darcs", "{arch}", "*.egg", "venv"], + default=[ + "*.egg", + ".*", + "_darcs", + "build", + "CVS", + "dist", + "node_modules", + "venv", + "{arch}", + ], ) parser.addini( "testpaths", @@ -61,6 +81,20 @@ def pytest_addoption(parser): const=1, help="exit instantly on first error or failed test.", ) + group = parser.getgroup("pytest-warnings") + group.addoption( + "-W", + "--pythonwarnings", + action="append", + help="set which warnings to report, see -W option of python itself.", + ) + parser.addini( + "filterwarnings", + type="linelist", + help="Each line specifies a pattern for " + "warnings.filterwarnings. " + "Processed after -W/--pythonwarnings.", + ) group._addoption( "--maxfail", metavar="num", @@ -70,12 +104,19 @@ def pytest_addoption(parser): default=0, help="exit after first num failures or errors.", ) + group._addoption( + "--strict-config", + action="store_true", + help="any warnings encountered while parsing the `pytest` section of the configuration file raise errors.", + ) group._addoption( "--strict-markers", - "--strict", action="store_true", help="markers not registered in the `markers` section of the configuration file raise errors.", ) + group._addoption( + "--strict", action="store_true", help="(deprecated) alias to --strict-markers.", + ) group._addoption( "-c", metavar="file", @@ -161,12 +202,21 @@ def pytest_addoption(parser): default=False, help="Don't ignore tests in a local virtualenv directory", ) + group.addoption( + "--import-mode", + default="prepend", + choices=["prepend", "append", "importlib"], + dest="importmode", + help="prepend/append to sys.path when importing test modules and conftest files, " + "default is to prepend.", + ) group = parser.getgroup("debugconfig", "test session debugging and configuration") group.addoption( "--basetemp", dest="basetemp", default=None, + type=validate_basetemp, metavar="dir", help=( "base temporary directory for this test run." @@ -175,10 +225,38 @@ def pytest_addoption(parser): ) +def validate_basetemp(path: str) -> str: + # GH 7119 + msg = "basetemp must not be empty, the current working directory or any parent directory of it" + + # empty path + if not path: + raise argparse.ArgumentTypeError(msg) + + def is_ancestor(base: Path, query: Path) -> bool: + """Return whether query is an ancestor of base.""" + if base == query: + return True + for parent in base.parents: + if parent == query: + return True + return False + + # check if path is an ancestor of cwd + if is_ancestor(Path.cwd(), Path(path).absolute()): + raise argparse.ArgumentTypeError(msg) + + # check symlinks for ancestors + if is_ancestor(Path.cwd().resolve(), Path(path).resolve()): + raise argparse.ArgumentTypeError(msg) + + return path + + def wrap_session( config: Config, doit: Callable[[Config, "Session"], Optional[Union[int, ExitCode]]] ) -> Union[int, ExitCode]: - """Skeleton command line program""" + """Skeleton command line program.""" session = Session.from_config(config) session.exitstatus = ExitCode.OK initstate = 0 @@ -196,17 +274,15 @@ def wrap_session( session.exitstatus = ExitCode.TESTS_FAILED except (KeyboardInterrupt, exit.Exception): excinfo = _pytest._code.ExceptionInfo.from_current() - exitstatus = ExitCode.INTERRUPTED # type: Union[int, ExitCode] + exitstatus: Union[int, ExitCode] = ExitCode.INTERRUPTED if isinstance(excinfo.value, exit.Exception): if excinfo.value.returncode is not None: exitstatus = excinfo.value.returncode if initstate < 2: - sys.stderr.write( - "{}: {}\n".format(excinfo.typename, excinfo.value.msg) - ) + sys.stderr.write(f"{excinfo.typename}: {excinfo.value.msg}\n") config.hook.pytest_keyboard_interrupt(excinfo=excinfo) session.exitstatus = exitstatus - except: # noqa + except BaseException: session.exitstatus = ExitCode.INTERNAL_ERROR excinfo = _pytest._code.ExceptionInfo.from_current() try: @@ -216,7 +292,7 @@ def wrap_session( session.exitstatus = exc.returncode sys.stderr.write("{}: {}\n".format(type(exc).__name__, exc)) else: - if excinfo.errisinstance(SystemExit): + if isinstance(excinfo.value, SystemExit): sys.stderr.write("mainloop: caught unexpected SystemExit!\n") finally: @@ -236,13 +312,13 @@ def wrap_session( return session.exitstatus -def pytest_cmdline_main(config): +def pytest_cmdline_main(config: Config) -> Union[int, ExitCode]: return wrap_session(config, _main) def _main(config: Config, session: "Session") -> Optional[Union[int, ExitCode]]: - """ default command line protocol for initialization, session, - running tests and reporting. """ + """Default command line protocol for initialization, session, + running tests and reporting.""" config.hook.pytest_collection(session=session) config.hook.pytest_runtestloop(session=session) @@ -253,11 +329,11 @@ def _main(config: Config, session: "Session") -> Optional[Union[int, ExitCode]]: return None -def pytest_collection(session): - return session.perform_collect() +def pytest_collection(session: "Session") -> None: + session.perform_collect() -def pytest_runtestloop(session): +def pytest_runtestloop(session: "Session") -> bool: if session.testsfailed and not session.config.option.continue_on_collection_errors: raise session.Interrupted( "%d error%s during collection" @@ -277,9 +353,9 @@ def pytest_runtestloop(session): return True -def _in_venv(path): - """Attempts to detect if ``path`` is the root of a Virtual Environment by - checking for the existence of the appropriate activate script""" +def _in_venv(path: py.path.local) -> bool: + """Attempt to detect if ``path`` is the root of a Virtual Environment by + checking for the existence of the appropriate activate script.""" bindir = path.join("Scripts" if sys.platform.startswith("win") else "bin") if not bindir.isdir(): return False @@ -294,9 +370,7 @@ def _in_venv(path): return any([fname.basename in activates for fname in bindir.listdir()]) -def pytest_ignore_collect( - path: py.path.local, config: Config -) -> "Optional[Literal[True]]": +def pytest_ignore_collect(path: py.path.local, config: Config) -> Optional[bool]: ignore_paths = config._getconftest_pathlist("collect_ignore", path=path.dirpath()) ignore_paths = ignore_paths or [] excludeopt = config.getoption("ignore") @@ -323,7 +397,7 @@ def pytest_ignore_collect( return None -def pytest_collection_modifyitems(items, config): +def pytest_collection_modifyitems(items: List[nodes.Item], config: Config) -> None: deselect_prefixes = tuple(config.getoption("deselect") or []) if not deselect_prefixes: return @@ -341,76 +415,69 @@ def pytest_collection_modifyitems(items, config): items[:] = remaining -class NoMatch(Exception): - """ raised if matching cannot locate a matching names. """ +class FSHookProxy: + def __init__(self, pm: PytestPluginManager, remove_mods) -> None: + self.pm = pm + self.remove_mods = remove_mods + + def __getattr__(self, name: str): + x = self.pm.subset_hook_caller(name, remove_plugins=self.remove_mods) + self.__dict__[name] = x + return x class Interrupted(KeyboardInterrupt): - """ signals an interrupted test run. """ + """Signals that the test run was interrupted.""" - __module__ = "builtins" # for py3 + __module__ = "builtins" # For py3. class Failed(Exception): - """ signals a stop as failed test run. """ + """Signals a stop as failed test run.""" @attr.s -class _bestrelpath_cache(dict): - path = attr.ib(type=py.path.local) +class _bestrelpath_cache(Dict[Path, str]): + path = attr.ib(type=Path) - def __missing__(self, path: py.path.local) -> str: - r = self.path.bestrelpath(path) # type: str + def __missing__(self, path: Path) -> str: + r = bestrelpath(self.path, path) self[path] = r return r +@final class Session(nodes.FSCollector): Interrupted = Interrupted Failed = Failed # Set on the session by runner.pytest_sessionstart. - _setupstate = None # type: SetupState + _setupstate: SetupState # Set on the session by fixtures.pytest_sessionstart. - _fixturemanager = None # type: FixtureManager - exitstatus = None # type: Union[int, ExitCode] + _fixturemanager: FixtureManager + exitstatus: Union[int, ExitCode] def __init__(self, config: Config) -> None: - nodes.FSCollector.__init__( - self, config.rootdir, parent=None, config=config, session=self, nodeid="" + super().__init__( + config.rootdir, parent=None, config=config, session=self, nodeid="" ) self.testsfailed = 0 self.testscollected = 0 - self.shouldstop = False - self.shouldfail = False + self.shouldstop: Union[bool, str] = False + self.shouldfail: Union[bool, str] = False self.trace = config.trace.root.get("collection") self.startdir = config.invocation_dir - self._initialpaths = frozenset() # type: FrozenSet[py.path.local] - - # Keep track of any collected nodes in here, so we don't duplicate fixtures - self._collection_node_cache1 = ( - {} - ) # type: Dict[py.path.local, Sequence[nodes.Collector]] - self._collection_node_cache2 = ( - {} - ) # type: Dict[Tuple[Type[nodes.Collector], py.path.local], nodes.Collector] - self._collection_node_cache3 = ( - {} - ) # type: Dict[Tuple[Type[nodes.Collector], str], CollectReport] - - # Dirnames of pkgs with dunder-init files. - self._collection_pkg_roots = {} # type: Dict[py.path.local, Package] + self._initialpaths: FrozenSet[py.path.local] = frozenset() - self._bestrelpathcache = _bestrelpath_cache( - config.rootdir - ) # type: Dict[py.path.local, str] + self._bestrelpathcache: Dict[Path, str] = _bestrelpath_cache(config.rootpath) self.config.pluginmanager.register(self, name="session") @classmethod - def from_config(cls, config): - return cls._create(config) + def from_config(cls, config: Config) -> "Session": + session: Session = cls._create(config) + return session - def __repr__(self): + def __repr__(self) -> str: return "<%s %s exitstatus=%r testsfailed=%d testscollected=%d>" % ( self.__class__.__name__, self.name, @@ -419,19 +486,21 @@ def __repr__(self): self.testscollected, ) - def _node_location_to_relpath(self, node_path: py.path.local) -> str: - # bestrelpath is a quite slow function + def _node_location_to_relpath(self, node_path: Path) -> str: + # bestrelpath is a quite slow function. return self._bestrelpathcache[node_path] @hookimpl(tryfirst=True) - def pytest_collectstart(self): + def pytest_collectstart(self) -> None: if self.shouldfail: raise self.Failed(self.shouldfail) if self.shouldstop: raise self.Interrupted(self.shouldstop) @hookimpl(tryfirst=True) - def pytest_runtest_logreport(self, report): + def pytest_runtest_logreport( + self, report: Union[TestReport, CollectReport] + ) -> None: if report.failed and not hasattr(report, "wasxfail"): self.testsfailed += 1 maxfail = self.config.getvalue("maxfail") @@ -440,238 +509,296 @@ def pytest_runtest_logreport(self, report): pytest_collectreport = pytest_runtest_logreport - def isinitpath(self, path): + def isinitpath(self, path: py.path.local) -> bool: return path in self._initialpaths def gethookproxy(self, fspath: py.path.local): - return super()._gethookproxy(fspath) + # Check if we have the common case of running + # hooks with all conftest.py files. + pm = self.config.pluginmanager + my_conftestmodules = pm._getconftestmodules( + fspath, self.config.getoption("importmode") + ) + remove_mods = pm._conftest_plugins.difference(my_conftestmodules) + if remove_mods: + # One or more conftests are not in use at this fspath. + proxy = FSHookProxy(pm, remove_mods) + else: + # All plugins are active for this fspath. + proxy = self.config.hook + return proxy + + def _recurse(self, direntry: "os.DirEntry[str]") -> bool: + if direntry.name == "__pycache__": + return False + path = py.path.local(direntry.path) + ihook = self.gethookproxy(path.dirpath()) + if ihook.pytest_ignore_collect(path=path, config=self.config): + return False + norecursepatterns = self.config.getini("norecursedirs") + if any(path.check(fnmatch=pat) for pat in norecursepatterns): + return False + return True + + def _collectfile( + self, path: py.path.local, handle_dupes: bool = True + ) -> Sequence[nodes.Collector]: + assert ( + path.isfile() + ), "{!r} is not a file (isdir={!r}, exists={!r}, islink={!r})".format( + path, path.isdir(), path.exists(), path.islink() + ) + ihook = self.gethookproxy(path) + if not self.isinitpath(path): + if ihook.pytest_ignore_collect(path=path, config=self.config): + return () + + if handle_dupes: + keepduplicates = self.config.getoption("keepduplicates") + if not keepduplicates: + duplicate_paths = self.config.pluginmanager._duplicatepaths + if path in duplicate_paths: + return () + else: + duplicate_paths.add(path) + + return ihook.pytest_collect_file(path=path, parent=self) # type: ignore[no-any-return] + + @overload + def perform_collect( + self, args: Optional[Sequence[str]] = ..., genitems: "Literal[True]" = ... + ) -> Sequence[nodes.Item]: + ... + + @overload + def perform_collect( + self, args: Optional[Sequence[str]] = ..., genitems: bool = ... + ) -> Sequence[Union[nodes.Item, nodes.Collector]]: + ... + + def perform_collect( + self, args: Optional[Sequence[str]] = None, genitems: bool = True + ) -> Sequence[Union[nodes.Item, nodes.Collector]]: + """Perform the collection phase for this session. + + This is called by the default + :func:`pytest_collection <_pytest.hookspec.pytest_collection>` hook + implementation; see the documentation of this hook for more details. + For testing purposes, it may also be called directly on a fresh + ``Session``. + + This function normally recursively expands any collectors collected + from the session to their items, and only items are returned. For + testing purposes, this may be suppressed by passing ``genitems=False``, + in which case the return value contains these collectors unexpanded, + and ``session.items`` is empty. + """ + if args is None: + args = self.config.args + + self.trace("perform_collect", self, args) + self.trace.root.indent += 1 + + self._notfound: List[Tuple[str, Sequence[nodes.Collector]]] = [] + self._initial_parts: List[Tuple[py.path.local, List[str]]] = [] + self.items: List[nodes.Item] = [] - def perform_collect(self, args=None, genitems=True): hook = self.config.hook + + items: Sequence[Union[nodes.Item, nodes.Collector]] = self.items try: - items = self._perform_collect(args, genitems) + initialpaths: List[py.path.local] = [] + for arg in args: + fspath, parts = resolve_collection_argument( + self.config.invocation_params.dir, + arg, + as_pypath=self.config.option.pyargs, + ) + self._initial_parts.append((fspath, parts)) + initialpaths.append(fspath) + self._initialpaths = frozenset(initialpaths) + rep = collect_one_node(self) + self.ihook.pytest_collectreport(report=rep) + self.trace.root.indent -= 1 + if self._notfound: + errors = [] + for arg, cols in self._notfound: + line = f"(no name {arg!r} in any of {cols!r})" + errors.append(f"not found: {arg}\n{line}") + raise UsageError(*errors) + if not genitems: + items = rep.result + else: + if rep.passed: + for node in rep.result: + self.items.extend(self.genitems(node)) + self.config.pluginmanager.check_pending() hook.pytest_collection_modifyitems( session=self, config=self.config, items=items ) finally: hook.pytest_collection_finish(session=self) + self.testscollected = len(items) return items - def _perform_collect(self, args, genitems): - if args is None: - args = self.config.args - self.trace("perform_collect", self, args) - self.trace.root.indent += 1 - self._notfound = [] - initialpaths = [] # type: List[py.path.local] - self._initial_parts = [] # type: List[Tuple[py.path.local, List[str]]] - self.items = items = [] - for arg in args: - fspath, parts = self._parsearg(arg) - self._initial_parts.append((fspath, parts)) - initialpaths.append(fspath) - self._initialpaths = frozenset(initialpaths) - rep = collect_one_node(self) - self.ihook.pytest_collectreport(report=rep) - self.trace.root.indent -= 1 - if self._notfound: - errors = [] - for arg, exc in self._notfound: - line = "(no name {!r} in any of {!r})".format(arg, exc.args[0]) - errors.append("not found: {}\n{}".format(arg, line)) - raise UsageError(*errors) - if not genitems: - return rep.result - else: - if rep.passed: - for node in rep.result: - self.items.extend(self.genitems(node)) - return items + def collect(self) -> Iterator[Union[nodes.Item, nodes.Collector]]: + from _pytest.python import Package - def collect(self): - for fspath, parts in self._initial_parts: - self.trace("processing argument", (fspath, parts)) - self.trace.root.indent += 1 - try: - yield from self._collect(fspath, parts) - except NoMatch as exc: - report_arg = "::".join((str(fspath), *parts)) - # we are inside a make_report hook so - # we cannot directly pass through the exception - self._notfound.append((report_arg, exc)) + # Keep track of any collected nodes in here, so we don't duplicate fixtures. + node_cache1: Dict[py.path.local, Sequence[nodes.Collector]] = {} + node_cache2: Dict[ + Tuple[Type[nodes.Collector], py.path.local], nodes.Collector + ] = ({}) - self.trace.root.indent -= 1 - self._collection_node_cache1.clear() - self._collection_node_cache2.clear() - self._collection_node_cache3.clear() - self._collection_pkg_roots.clear() + # Keep track of any collected collectors in matchnodes paths, so they + # are not collected more than once. + matchnodes_cache: Dict[Tuple[Type[nodes.Collector], str], CollectReport] = ({}) - def _collect(self, argpath, names): - from _pytest.python import Package + # Dirnames of pkgs with dunder-init files. + pkg_roots: Dict[str, Package] = {} + + for argpath, names in self._initial_parts: + self.trace("processing argument", (argpath, names)) + self.trace.root.indent += 1 - # Start with a Session root, and delve to argpath item (dir or file) - # and stack all Packages found on the way. - # No point in finding packages when collecting doctests - if not self.config.getoption("doctestmodules", False): - pm = self.config.pluginmanager - for parent in reversed(argpath.parts()): - if pm._confcutdir and pm._confcutdir.relto(parent): - break - - if parent.isdir(): - pkginit = parent.join("__init__.py") - if pkginit.isfile(): - if pkginit not in self._collection_node_cache1: + # Start with a Session root, and delve to argpath item (dir or file) + # and stack all Packages found on the way. + # No point in finding packages when collecting doctests. + if not self.config.getoption("doctestmodules", False): + pm = self.config.pluginmanager + for parent in reversed(argpath.parts()): + if pm._confcutdir and pm._confcutdir.relto(parent): + break + + if parent.isdir(): + pkginit = parent.join("__init__.py") + if pkginit.isfile() and pkginit not in node_cache1: col = self._collectfile(pkginit, handle_dupes=False) if col: if isinstance(col[0], Package): - self._collection_pkg_roots[parent] = col[0] - # always store a list in the cache, matchnodes expects it - self._collection_node_cache1[col[0].fspath] = [col[0]] - - # If it's a directory argument, recurse and look for any Subpackages. - # Let the Package collector deal with subnodes, don't collect here. - if argpath.check(dir=1): - assert not names, "invalid arg {!r}".format((argpath, names)) - - seen_dirs = set() - for path in argpath.visit( - fil=self._visit_filter, rec=self._recurse, bf=True, sort=True - ): - dirpath = path.dirpath() - if dirpath not in seen_dirs: - # Collect packages first. - seen_dirs.add(dirpath) - pkginit = dirpath.join("__init__.py") - if pkginit.exists(): - for x in self._collectfile(pkginit): + pkg_roots[str(parent)] = col[0] + node_cache1[col[0].fspath] = [col[0]] + + # If it's a directory argument, recurse and look for any Subpackages. + # Let the Package collector deal with subnodes, don't collect here. + if argpath.check(dir=1): + assert not names, "invalid arg {!r}".format((argpath, names)) + + seen_dirs: Set[py.path.local] = set() + for direntry in visit(str(argpath), self._recurse): + if not direntry.is_file(): + continue + + path = py.path.local(direntry.path) + dirpath = path.dirpath() + + if dirpath not in seen_dirs: + # Collect packages first. + seen_dirs.add(dirpath) + pkginit = dirpath.join("__init__.py") + if pkginit.exists(): + for x in self._collectfile(pkginit): + yield x + if isinstance(x, Package): + pkg_roots[str(dirpath)] = x + if str(dirpath) in pkg_roots: + # Do not collect packages here. + continue + + for x in self._collectfile(path): + key = (type(x), x.fspath) + if key in node_cache2: + yield node_cache2[key] + else: + node_cache2[key] = x yield x - if isinstance(x, Package): - self._collection_pkg_roots[dirpath] = x - if dirpath in self._collection_pkg_roots: - # Do not collect packages here. + else: + assert argpath.check(file=1) + + if argpath in node_cache1: + col = node_cache1[argpath] + else: + collect_root = pkg_roots.get(argpath.dirname, self) + col = collect_root._collectfile(argpath, handle_dupes=False) + if col: + node_cache1[argpath] = col + + matching = [] + work: List[ + Tuple[Sequence[Union[nodes.Item, nodes.Collector]], Sequence[str]] + ] = [(col, names)] + while work: + self.trace("matchnodes", col, names) + self.trace.root.indent += 1 + + matchnodes, matchnames = work.pop() + for node in matchnodes: + if not matchnames: + matching.append(node) + continue + if not isinstance(node, nodes.Collector): + continue + key = (type(node), node.nodeid) + if key in matchnodes_cache: + rep = matchnodes_cache[key] + else: + rep = collect_one_node(node) + matchnodes_cache[key] = rep + if rep.passed: + submatchnodes = [] + for r in rep.result: + # TODO: Remove parametrized workaround once collection structure contains + # parametrization. + if ( + r.name == matchnames[0] + or r.name.split("[")[0] == matchnames[0] + ): + submatchnodes.append(r) + if submatchnodes: + work.append((submatchnodes, matchnames[1:])) + # XXX Accept IDs that don't have "()" for class instances. + elif len(rep.result) == 1 and rep.result[0].name == "()": + work.append((rep.result, matchnames)) + else: + # Report collection failures here to avoid failing to run some test + # specified in the command line because the module could not be + # imported (#134). + node.ihook.pytest_collectreport(report=rep) + + self.trace("matchnodes finished -> ", len(matching), "nodes") + self.trace.root.indent -= 1 + + if not matching: + report_arg = "::".join((str(argpath), *names)) + self._notfound.append((report_arg, col)) continue - for x in self._collectfile(path): - key = (type(x), x.fspath) - if key in self._collection_node_cache2: - yield self._collection_node_cache2[key] - else: - self._collection_node_cache2[key] = x - yield x - else: - assert argpath.check(file=1) + # If __init__.py was the only file requested, then the matched + # node will be the corresponding Package (by default), and the + # first yielded item will be the __init__ Module itself, so + # just use that. If this special case isn't taken, then all the + # files in the package will be yielded. + if argpath.basename == "__init__.py" and isinstance( + matching[0], Package + ): + try: + yield next(iter(matching[0].collect())) + except StopIteration: + # The package collects nothing with only an __init__.py + # file in it, which gets ignored by the default + # "python_files" option. + pass + continue - if argpath in self._collection_node_cache1: - col = self._collection_node_cache1[argpath] - else: - collect_root = self._collection_pkg_roots.get(argpath.dirname, self) - col = collect_root._collectfile(argpath, handle_dupes=False) - if col: - self._collection_node_cache1[argpath] = col - m = self.matchnodes(col, names) - # If __init__.py was the only file requested, then the matched node will be - # the corresponding Package, and the first yielded item will be the __init__ - # Module itself, so just use that. If this special case isn't taken, then all - # the files in the package will be yielded. - if argpath.basename == "__init__.py": - try: - yield next(m[0].collect()) - except StopIteration: - # The package collects nothing with only an __init__.py - # file in it, which gets ignored by the default - # "python_files" option. - pass - return - yield from m - - @staticmethod - def _visit_filter(f): - return f.check(file=1) - - def _tryconvertpyarg(self, x): - """Convert a dotted module name to path.""" - try: - spec = importlib.util.find_spec(x) - # AttributeError: looks like package module, but actually filename - # ImportError: module does not exist - # ValueError: not a module name - except (AttributeError, ImportError, ValueError): - return x - if spec is None or spec.origin in {None, "namespace"}: - return x - elif spec.submodule_search_locations: - return os.path.dirname(spec.origin) - else: - return spec.origin - - def _parsearg(self, arg): - """ return (fspath, names) tuple after checking the file exists. """ - strpath, *parts = str(arg).split("::") - if self.config.option.pyargs: - strpath = self._tryconvertpyarg(strpath) - relpath = strpath.replace("/", os.sep) - fspath = self.config.invocation_dir.join(relpath, abs=True) - if not fspath.check(): - if self.config.option.pyargs: - raise UsageError( - "file or package not found: " + arg + " (missing __init__.py?)" - ) - raise UsageError("file not found: " + arg) - fspath = fspath.realpath() - return (fspath, parts) + yield from matching - def matchnodes(self, matching, names): - self.trace("matchnodes", matching, names) - self.trace.root.indent += 1 - nodes = self._matchnodes(matching, names) - num = len(nodes) - self.trace("matchnodes finished -> ", num, "nodes") - self.trace.root.indent -= 1 - if num == 0: - raise NoMatch(matching, names[:1]) - return nodes - - def _matchnodes(self, matching, names): - if not matching or not names: - return matching - name = names[0] - assert name - nextnames = names[1:] - resultnodes = [] - for node in matching: - if isinstance(node, nodes.Item): - if not names: - resultnodes.append(node) - continue - assert isinstance(node, nodes.Collector) - key = (type(node), node.nodeid) - if key in self._collection_node_cache3: - rep = self._collection_node_cache3[key] - else: - rep = collect_one_node(node) - self._collection_node_cache3[key] = rep - if rep.passed: - has_matched = False - for x in rep.result: - # TODO: remove parametrized workaround once collection structure contains parametrization - if x.name == name or x.name.split("[")[0] == name: - resultnodes.extend(self.matchnodes([x], nextnames)) - has_matched = True - # XXX accept IDs that don't have "()" for class instances - if not has_matched and len(rep.result) == 1 and x.name == "()": - nextnames.insert(0, name) - resultnodes.extend(self.matchnodes([x], nextnames)) - else: - # report collection failures here to avoid failing to run some test - # specified in the command line because the module could not be - # imported (#134) - node.ihook.pytest_collectreport(report=rep) - return resultnodes + self.trace.root.indent -= 1 - def genitems(self, node): + def genitems( + self, node: Union[nodes.Item, nodes.Collector] + ) -> Iterator[nodes.Item]: self.trace("genitems", node) if isinstance(node, nodes.Item): node.ihook.pytest_itemcollected(item=node) @@ -683,3 +810,67 @@ def genitems(self, node): for subnode in rep.result: yield from self.genitems(subnode) node.ihook.pytest_collectreport(report=rep) + + +def search_pypath(module_name: str) -> str: + """Search sys.path for the given a dotted module name, and return its file system path.""" + try: + spec = importlib.util.find_spec(module_name) + # AttributeError: looks like package module, but actually filename + # ImportError: module does not exist + # ValueError: not a module name + except (AttributeError, ImportError, ValueError): + return module_name + if spec is None or spec.origin is None or spec.origin == "namespace": + return module_name + elif spec.submodule_search_locations: + return os.path.dirname(spec.origin) + else: + return spec.origin + + +def resolve_collection_argument( + invocation_path: Path, arg: str, *, as_pypath: bool = False +) -> Tuple[py.path.local, List[str]]: + """Parse path arguments optionally containing selection parts and return (fspath, names). + + Command-line arguments can point to files and/or directories, and optionally contain + parts for specific tests selection, for example: + + "pkg/tests/test_foo.py::TestClass::test_foo" + + This function ensures the path exists, and returns a tuple: + + (py.path.path("/full/path/to/pkg/tests/test_foo.py"), ["TestClass", "test_foo"]) + + When as_pypath is True, expects that the command-line argument actually contains + module paths instead of file-system paths: + + "pkg.tests.test_foo::TestClass::test_foo" + + In which case we search sys.path for a matching module, and then return the *path* to the + found module. + + If the path doesn't exist, raise UsageError. + If the path is a directory and selection parts are present, raise UsageError. + """ + strpath, *parts = str(arg).split("::") + if as_pypath: + strpath = search_pypath(strpath) + fspath = invocation_path / strpath + fspath = absolutepath(fspath) + if not fspath.exists(): + msg = ( + "module or package not found: {arg} (missing __init__.py?)" + if as_pypath + else "file or directory not found: {arg}" + ) + raise UsageError(msg.format(arg=arg)) + if parts and fspath.is_dir(): + msg = ( + "package argument cannot contain :: selection parts: {arg}" + if as_pypath + else "directory argument cannot contain :: selection parts: {arg}" + ) + raise UsageError(msg.format(arg=arg)) + return py.path.local(str(fspath)), parts diff --git a/src/_pytest/mark/__init__.py b/src/_pytest/mark/__init__.py index dab0cf149fd..329a11c4ae8 100644 --- a/src/_pytest/mark/__init__.py +++ b/src/_pytest/mark/__init__.py @@ -1,8 +1,16 @@ -""" generic mechanism for marking and selecting python functions. """ +"""Generic mechanism for marking and selecting python functions.""" +import warnings +from typing import AbstractSet +from typing import Collection +from typing import List from typing import Optional +from typing import TYPE_CHECKING +from typing import Union -from .legacy import matchkeyword -from .legacy import matchmark +import attr + +from .expression import Expression +from .expression import ParseError from .structures import EMPTY_PARAMETERSET_OPTION from .structures import get_empty_parameterset_mark from .structures import Mark @@ -11,37 +19,56 @@ from .structures import MarkGenerator from .structures import ParameterSet from _pytest.config import Config +from _pytest.config import ExitCode from _pytest.config import hookimpl from _pytest.config import UsageError +from _pytest.config.argparsing import Parser +from _pytest.deprecated import MINUS_K_COLON +from _pytest.deprecated import MINUS_K_DASH from _pytest.store import StoreKey -__all__ = ["Mark", "MarkDecorator", "MarkGenerator", "get_empty_parameterset_mark"] +if TYPE_CHECKING: + from _pytest.nodes import Item + + +__all__ = [ + "MARK_GEN", + "Mark", + "MarkDecorator", + "MarkGenerator", + "ParameterSet", + "get_empty_parameterset_mark", +] old_mark_config_key = StoreKey[Optional[Config]]() -def param(*values, **kw): +def param( + *values: object, + marks: Union[MarkDecorator, Collection[Union[MarkDecorator, Mark]]] = (), + id: Optional[str] = None, +) -> ParameterSet: """Specify a parameter in `pytest.mark.parametrize`_ calls or :ref:`parametrized fixtures `. .. code-block:: python - @pytest.mark.parametrize("test_input,expected", [ - ("3+5", 8), - pytest.param("6*9", 42, marks=pytest.mark.xfail), - ]) + @pytest.mark.parametrize( + "test_input,expected", + [("3+5", 8), pytest.param("6*9", 42, marks=pytest.mark.xfail),], + ) def test_eval(test_input, expected): assert eval(test_input) == expected - :param values: variable args of the values of the parameter set, in order. - :keyword marks: a single mark or a list of marks to be applied to this parameter set. - :keyword str id: the id to attribute to this parameter set. + :param values: Variable args of the values of the parameter set, in order. + :keyword marks: A single mark or a list of marks to be applied to this parameter set. + :keyword str id: The id to attribute to this parameter set. """ - return ParameterSet.param(*values, **kw) + return ParameterSet.param(*values, marks=marks, id=id) -def pytest_addoption(parser): +def pytest_addoption(parser: Parser) -> None: group = parser.getgroup("general") group._addoption( "-k", @@ -69,8 +96,8 @@ def pytest_addoption(parser): dest="markexpr", default="", metavar="MARKEXPR", - help="only run tests matching given mark expression. " - "example: -m 'mark1 and not mark2'.", + help="only run tests matching given mark expression.\n" + "For example: -m 'mark1 and not mark2'.", ) group.addoption( @@ -84,7 +111,7 @@ def pytest_addoption(parser): @hookimpl(tryfirst=True) -def pytest_cmdline_main(config): +def pytest_cmdline_main(config: Config) -> Optional[Union[int, ExitCode]]: import _pytest.config if config.option.markers: @@ -100,23 +127,87 @@ def pytest_cmdline_main(config): config._ensure_unconfigure() return 0 + return None + + +@attr.s(slots=True) +class KeywordMatcher: + """A matcher for keywords. + + Given a list of names, matches any substring of one of these names. The + string inclusion check is case-insensitive. + + Will match on the name of colitem, including the names of its parents. + Only matches names of items which are either a :class:`Class` or a + :class:`Function`. + + Additionally, matches on names in the 'extra_keyword_matches' set of + any item, as well as names directly assigned to test functions. + """ + + _names = attr.ib(type=AbstractSet[str]) + + @classmethod + def from_item(cls, item: "Item") -> "KeywordMatcher": + mapped_names = set() + + # Add the names of the current item and any parent items. + import pytest + + for node in item.listchain(): + if not isinstance(node, (pytest.Instance, pytest.Session)): + mapped_names.add(node.name) + + # Add the names added as extra keywords to current or parent items. + mapped_names.update(item.listextrakeywords()) + + # Add the names attached to the current function through direct assignment. + function_obj = getattr(item, "function", None) + if function_obj: + mapped_names.update(function_obj.__dict__) -def deselect_by_keyword(items, config): + # Add the markers to the keywords as we no longer handle them correctly. + mapped_names.update(mark.name for mark in item.iter_markers()) + + return cls(mapped_names) + + def __call__(self, subname: str) -> bool: + subname = subname.lower() + names = (name.lower() for name in self._names) + + for name in names: + if subname in name: + return True + return False + + +def deselect_by_keyword(items: "List[Item]", config: Config) -> None: keywordexpr = config.option.keyword.lstrip() if not keywordexpr: return if keywordexpr.startswith("-"): + # To be removed in pytest 7.0.0. + warnings.warn(MINUS_K_DASH, stacklevel=2) keywordexpr = "not " + keywordexpr[1:] selectuntil = False if keywordexpr[-1:] == ":": + # To be removed in pytest 7.0.0. + warnings.warn(MINUS_K_COLON, stacklevel=2) selectuntil = True keywordexpr = keywordexpr[:-1] + try: + expression = Expression.compile(keywordexpr) + except ParseError as e: + raise UsageError( + f"Wrong expression passed to '-k': {keywordexpr}: {e}" + ) from None + remaining = [] deselected = [] for colitem in items: - if keywordexpr and not matchkeyword(colitem, keywordexpr): + if keywordexpr and not expression.evaluate(KeywordMatcher.from_item(colitem)): deselected.append(colitem) else: if selectuntil: @@ -128,15 +219,38 @@ def deselect_by_keyword(items, config): items[:] = remaining -def deselect_by_mark(items, config): +@attr.s(slots=True) +class MarkMatcher: + """A matcher for markers which are present. + + Tries to match on any marker names, attached to the given colitem. + """ + + own_mark_names = attr.ib() + + @classmethod + def from_item(cls, item) -> "MarkMatcher": + mark_names = {mark.name for mark in item.iter_markers()} + return cls(mark_names) + + def __call__(self, name: str) -> bool: + return name in self.own_mark_names + + +def deselect_by_mark(items: "List[Item]", config: Config) -> None: matchexpr = config.option.markexpr if not matchexpr: return + try: + expression = Expression.compile(matchexpr) + except ParseError as e: + raise UsageError(f"Wrong expression passed to '-m': {matchexpr}: {e}") from None + remaining = [] deselected = [] for item in items: - if matchmark(item, matchexpr): + if expression.evaluate(MarkMatcher.from_item(item)): remaining.append(item) else: deselected.append(item) @@ -146,12 +260,12 @@ def deselect_by_mark(items, config): items[:] = remaining -def pytest_collection_modifyitems(items, config): +def pytest_collection_modifyitems(items: "List[Item]", config: Config) -> None: deselect_by_keyword(items, config) deselect_by_mark(items, config) -def pytest_configure(config): +def pytest_configure(config: Config) -> None: config._store[old_mark_config_key] = MARK_GEN._config MARK_GEN._config = config @@ -164,5 +278,5 @@ def pytest_configure(config): ) -def pytest_unconfigure(config): +def pytest_unconfigure(config: Config) -> None: MARK_GEN._config = config._store.get(old_mark_config_key, None) diff --git a/src/_pytest/mark/evaluate.py b/src/_pytest/mark/evaluate.py deleted file mode 100644 index 772baf31b6e..00000000000 --- a/src/_pytest/mark/evaluate.py +++ /dev/null @@ -1,132 +0,0 @@ -import os -import platform -import sys -import traceback -from typing import Any -from typing import Dict - -from ..outcomes import fail -from ..outcomes import TEST_OUTCOME -from _pytest.config import Config -from _pytest.store import StoreKey - - -evalcache_key = StoreKey[Dict[str, Any]]() - - -def cached_eval(config: Config, expr: str, d: Dict[str, object]) -> Any: - default = {} # type: Dict[str, object] - evalcache = config._store.setdefault(evalcache_key, default) - try: - return evalcache[expr] - except KeyError: - import _pytest._code - - exprcode = _pytest._code.compile(expr, mode="eval") - evalcache[expr] = x = eval(exprcode, d) - return x - - -class MarkEvaluator: - def __init__(self, item, name): - self.item = item - self._marks = None - self._mark = None - self._mark_name = name - - def __bool__(self): - # don't cache here to prevent staleness - return bool(self._get_marks()) - - __nonzero__ = __bool__ - - def wasvalid(self): - return not hasattr(self, "exc") - - def _get_marks(self): - return list(self.item.iter_markers(name=self._mark_name)) - - def invalidraise(self, exc): - raises = self.get("raises") - if not raises: - return - return not isinstance(exc, raises) - - def istrue(self): - try: - return self._istrue() - except TEST_OUTCOME: - self.exc = sys.exc_info() - if isinstance(self.exc[1], SyntaxError): - # TODO: Investigate why SyntaxError.offset is Optional, and if it can be None here. - assert self.exc[1].offset is not None - msg = [" " * (self.exc[1].offset + 4) + "^"] - msg.append("SyntaxError: invalid syntax") - else: - msg = traceback.format_exception_only(*self.exc[:2]) - fail( - "Error evaluating %r expression\n" - " %s\n" - "%s" % (self._mark_name, self.expr, "\n".join(msg)), - pytrace=False, - ) - - def _getglobals(self): - d = {"os": os, "sys": sys, "platform": platform, "config": self.item.config} - if hasattr(self.item, "obj"): - d.update(self.item.obj.__globals__) - return d - - def _istrue(self): - if hasattr(self, "result"): - return self.result - self._marks = self._get_marks() - - if self._marks: - self.result = False - for mark in self._marks: - self._mark = mark - if "condition" in mark.kwargs: - args = (mark.kwargs["condition"],) - else: - args = mark.args - - for expr in args: - self.expr = expr - if isinstance(expr, str): - d = self._getglobals() - result = cached_eval(self.item.config, expr, d) - else: - if "reason" not in mark.kwargs: - # XXX better be checked at collection time - msg = ( - "you need to specify reason=STRING " - "when using booleans as conditions." - ) - fail(msg) - result = bool(expr) - if result: - self.result = True - self.reason = mark.kwargs.get("reason", None) - self.expr = expr - return self.result - - if not args: - self.result = True - self.reason = mark.kwargs.get("reason", None) - return self.result - return False - - def get(self, attr, default=None): - if self._mark is None: - return default - return self._mark.kwargs.get(attr, default) - - def getexplanation(self): - expl = getattr(self, "reason", None) or self.get("reason", None) - if not expl: - if not hasattr(self, "expr"): - return "" - else: - return "condition: " + str(self.expr) - return expl diff --git a/src/_pytest/mark/expression.py b/src/_pytest/mark/expression.py new file mode 100644 index 00000000000..dc3991b10c4 --- /dev/null +++ b/src/_pytest/mark/expression.py @@ -0,0 +1,221 @@ +r"""Evaluate match expressions, as used by `-k` and `-m`. + +The grammar is: + +expression: expr? EOF +expr: and_expr ('or' and_expr)* +and_expr: not_expr ('and' not_expr)* +not_expr: 'not' not_expr | '(' expr ')' | ident +ident: (\w|:|\+|-|\.|\[|\])+ + +The semantics are: + +- Empty expression evaluates to False. +- ident evaluates to True of False according to a provided matcher function. +- or/and/not evaluate according to the usual boolean semantics. +""" +import ast +import enum +import re +import types +from typing import Callable +from typing import Iterator +from typing import Mapping +from typing import Optional +from typing import Sequence +from typing import TYPE_CHECKING + +import attr + +if TYPE_CHECKING: + from typing import NoReturn + + +__all__ = [ + "Expression", + "ParseError", +] + + +class TokenType(enum.Enum): + LPAREN = "left parenthesis" + RPAREN = "right parenthesis" + OR = "or" + AND = "and" + NOT = "not" + IDENT = "identifier" + EOF = "end of input" + + +@attr.s(frozen=True, slots=True) +class Token: + type = attr.ib(type=TokenType) + value = attr.ib(type=str) + pos = attr.ib(type=int) + + +class ParseError(Exception): + """The expression contains invalid syntax. + + :param column: The column in the line where the error occurred (1-based). + :param message: A description of the error. + """ + + def __init__(self, column: int, message: str) -> None: + self.column = column + self.message = message + + def __str__(self) -> str: + return f"at column {self.column}: {self.message}" + + +class Scanner: + __slots__ = ("tokens", "current") + + def __init__(self, input: str) -> None: + self.tokens = self.lex(input) + self.current = next(self.tokens) + + def lex(self, input: str) -> Iterator[Token]: + pos = 0 + while pos < len(input): + if input[pos] in (" ", "\t"): + pos += 1 + elif input[pos] == "(": + yield Token(TokenType.LPAREN, "(", pos) + pos += 1 + elif input[pos] == ")": + yield Token(TokenType.RPAREN, ")", pos) + pos += 1 + else: + match = re.match(r"(:?\w|:|\+|-|\.|\[|\])+", input[pos:]) + if match: + value = match.group(0) + if value == "or": + yield Token(TokenType.OR, value, pos) + elif value == "and": + yield Token(TokenType.AND, value, pos) + elif value == "not": + yield Token(TokenType.NOT, value, pos) + else: + yield Token(TokenType.IDENT, value, pos) + pos += len(value) + else: + raise ParseError( + pos + 1, 'unexpected character "{}"'.format(input[pos]), + ) + yield Token(TokenType.EOF, "", pos) + + def accept(self, type: TokenType, *, reject: bool = False) -> Optional[Token]: + if self.current.type is type: + token = self.current + if token.type is not TokenType.EOF: + self.current = next(self.tokens) + return token + if reject: + self.reject((type,)) + return None + + def reject(self, expected: Sequence[TokenType]) -> "NoReturn": + raise ParseError( + self.current.pos + 1, + "expected {}; got {}".format( + " OR ".join(type.value for type in expected), self.current.type.value, + ), + ) + + +# True, False and None are legal match expression identifiers, +# but illegal as Python identifiers. To fix this, this prefix +# is added to identifiers in the conversion to Python AST. +IDENT_PREFIX = "$" + + +def expression(s: Scanner) -> ast.Expression: + if s.accept(TokenType.EOF): + ret: ast.expr = ast.NameConstant(False) + else: + ret = expr(s) + s.accept(TokenType.EOF, reject=True) + return ast.fix_missing_locations(ast.Expression(ret)) + + +def expr(s: Scanner) -> ast.expr: + ret = and_expr(s) + while s.accept(TokenType.OR): + rhs = and_expr(s) + ret = ast.BoolOp(ast.Or(), [ret, rhs]) + return ret + + +def and_expr(s: Scanner) -> ast.expr: + ret = not_expr(s) + while s.accept(TokenType.AND): + rhs = not_expr(s) + ret = ast.BoolOp(ast.And(), [ret, rhs]) + return ret + + +def not_expr(s: Scanner) -> ast.expr: + if s.accept(TokenType.NOT): + return ast.UnaryOp(ast.Not(), not_expr(s)) + if s.accept(TokenType.LPAREN): + ret = expr(s) + s.accept(TokenType.RPAREN, reject=True) + return ret + ident = s.accept(TokenType.IDENT) + if ident: + return ast.Name(IDENT_PREFIX + ident.value, ast.Load()) + s.reject((TokenType.NOT, TokenType.LPAREN, TokenType.IDENT)) + + +class MatcherAdapter(Mapping[str, bool]): + """Adapts a matcher function to a locals mapping as required by eval().""" + + def __init__(self, matcher: Callable[[str], bool]) -> None: + self.matcher = matcher + + def __getitem__(self, key: str) -> bool: + return self.matcher(key[len(IDENT_PREFIX) :]) + + def __iter__(self) -> Iterator[str]: + raise NotImplementedError() + + def __len__(self) -> int: + raise NotImplementedError() + + +class Expression: + """A compiled match expression as used by -k and -m. + + The expression can be evaulated against different matchers. + """ + + __slots__ = ("code",) + + def __init__(self, code: types.CodeType) -> None: + self.code = code + + @classmethod + def compile(self, input: str) -> "Expression": + """Compile a match expression. + + :param input: The input expression - one line. + """ + astexpr = expression(Scanner(input)) + code: types.CodeType = compile( + astexpr, filename="", mode="eval", + ) + return Expression(code) + + def evaluate(self, matcher: Callable[[str], bool]) -> bool: + """Evaluate the match expression. + + :param matcher: + Given an identifier, should return whether it matches or not. + Should be prepared to handle arbitrary strings as input. + + :returns: Whether the expression matches or not. + """ + ret: bool = eval(self.code, {"__builtins__": {}}, MatcherAdapter(matcher)) + return ret diff --git a/src/_pytest/mark/legacy.py b/src/_pytest/mark/legacy.py deleted file mode 100644 index 3d7a194b615..00000000000 --- a/src/_pytest/mark/legacy.py +++ /dev/null @@ -1,116 +0,0 @@ -""" -this is a place where we put datastructures used by legacy apis -we hope to remove -""" -import keyword -from typing import Set - -import attr - -from _pytest.compat import TYPE_CHECKING -from _pytest.config import UsageError - -if TYPE_CHECKING: - from _pytest.nodes import Item # noqa: F401 (used in type string) - - -@attr.s -class MarkMapping: - """Provides a local mapping for markers where item access - resolves to True if the marker is present. """ - - own_mark_names = attr.ib() - - @classmethod - def from_item(cls, item): - mark_names = {mark.name for mark in item.iter_markers()} - return cls(mark_names) - - def __getitem__(self, name): - return name in self.own_mark_names - - -@attr.s -class KeywordMapping: - """Provides a local mapping for keywords. - Given a list of names, map any substring of one of these names to True. - """ - - _names = attr.ib(type=Set[str]) - - @classmethod - def from_item(cls, item: "Item") -> "KeywordMapping": - mapped_names = set() - - # Add the names of the current item and any parent items - import pytest - - for item in item.listchain(): - if not isinstance(item, pytest.Instance): - mapped_names.add(item.name) - - # Add the names added as extra keywords to current or parent items - mapped_names.update(item.listextrakeywords()) - - # Add the names attached to the current function through direct assignment - function_obj = getattr(item, "function", None) - if function_obj: - mapped_names.update(function_obj.__dict__) - - # add the markers to the keywords as we no longer handle them correctly - mapped_names.update(mark.name for mark in item.iter_markers()) - - return cls(mapped_names) - - def __getitem__(self, subname: str) -> bool: - """Return whether subname is included within stored names. - - The string inclusion check is case-insensitive. - - """ - subname = subname.lower() - names = (name.lower() for name in self._names) - - for name in names: - if subname in name: - return True - return False - - -python_keywords_allowed_list = ["or", "and", "not"] - - -def matchmark(colitem, markexpr): - """Tries to match on any marker names, attached to the given colitem.""" - try: - return eval(markexpr, {}, MarkMapping.from_item(colitem)) - except SyntaxError as e: - raise SyntaxError(str(e) + "\nMarker expression must be valid Python!") - - -def matchkeyword(colitem, keywordexpr): - """Tries to match given keyword expression to given collector item. - - Will match on the name of colitem, including the names of its parents. - Only matches names of items which are either a :class:`Class` or a - :class:`Function`. - Additionally, matches on names in the 'extra_keyword_matches' set of - any item, as well as names directly assigned to test functions. - """ - mapping = KeywordMapping.from_item(colitem) - if " " not in keywordexpr: - # special case to allow for simple "-k pass" and "-k 1.3" - return mapping[keywordexpr] - elif keywordexpr.startswith("not ") and " " not in keywordexpr[4:]: - return not mapping[keywordexpr[4:]] - for kwd in keywordexpr.split(): - if keyword.iskeyword(kwd) and kwd not in python_keywords_allowed_list: - raise UsageError( - "Python keyword '{}' not accepted in expressions passed to '-k'".format( - kwd - ) - ) - try: - return eval(keywordexpr, {}, mapping) - except SyntaxError: - raise UsageError("Wrong expression passed to '-k': {}".format(keywordexpr)) diff --git a/src/_pytest/mark/structures.py b/src/_pytest/mark/structures.py index 1ab22b7c758..6c126cf4a29 100644 --- a/src/_pytest/mark/structures.py +++ b/src/_pytest/mark/structures.py @@ -1,39 +1,68 @@ +import collections.abc import inspect import warnings -from collections import namedtuple -from collections.abc import MutableMapping +from typing import Any +from typing import Callable +from typing import Collection from typing import Iterable +from typing import Iterator from typing import List +from typing import Mapping +from typing import MutableMapping +from typing import NamedTuple from typing import Optional +from typing import overload +from typing import Sequence from typing import Set +from typing import Tuple +from typing import Type +from typing import TYPE_CHECKING +from typing import TypeVar from typing import Union import attr -from .._code.source import getfslineno +from .._code import getfslineno from ..compat import ascii_escaped +from ..compat import final from ..compat import NOTSET +from ..compat import NotSetType +from _pytest.config import Config from _pytest.outcomes import fail from _pytest.warning_types import PytestUnknownMarkWarning +if TYPE_CHECKING: + from ..nodes import Node + + EMPTY_PARAMETERSET_OPTION = "empty_parameter_set_mark" -def istestfunc(func): +def istestfunc(func) -> bool: return ( hasattr(func, "__call__") and getattr(func, "__name__", "") != "" ) -def get_empty_parameterset_mark(config, argnames, func): +def get_empty_parameterset_mark( + config: Config, argnames: Sequence[str], func +) -> "MarkDecorator": from ..nodes import Collector + fs, lineno = getfslineno(func) + reason = "got empty parameter set %r, function %s at %s:%d" % ( + argnames, + func.__name__, + fs, + lineno, + ) + requested_mark = config.getini(EMPTY_PARAMETERSET_OPTION) if requested_mark in ("", None, "skip"): - mark = MARK_GEN.skip + mark = MARK_GEN.skip(reason=reason) elif requested_mark == "xfail": - mark = MARK_GEN.xfail(run=False) + mark = MARK_GEN.xfail(reason=reason, run=False) elif requested_mark == "fail_at_collect": f_name = func.__name__ _, lineno = getfslineno(func) @@ -42,23 +71,30 @@ def get_empty_parameterset_mark(config, argnames, func): ) else: raise LookupError(requested_mark) - fs, lineno = getfslineno(func) - reason = "got empty parameter set %r, function %s at %s:%d" % ( - argnames, - func.__name__, - fs, - lineno, - ) - return mark(reason=reason) + return mark -class ParameterSet(namedtuple("ParameterSet", "values, marks, id")): +class ParameterSet( + NamedTuple( + "ParameterSet", + [ + ("values", Sequence[Union[object, NotSetType]]), + ("marks", Collection[Union["MarkDecorator", "Mark"]]), + ("id", Optional[str]), + ], + ) +): @classmethod - def param(cls, *values, marks=(), id=None): + def param( + cls, + *values: object, + marks: Union["MarkDecorator", Collection[Union["MarkDecorator", "Mark"]]] = (), + id: Optional[str] = None, + ) -> "ParameterSet": if isinstance(marks, MarkDecorator): marks = (marks,) else: - assert isinstance(marks, (tuple, list, set)) + assert isinstance(marks, collections.abc.Collection) if id is not None: if not isinstance(id, str): @@ -69,15 +105,20 @@ def param(cls, *values, marks=(), id=None): return cls(values, marks, id) @classmethod - def extract_from(cls, parameterset, force_tuple=False): - """ + def extract_from( + cls, + parameterset: Union["ParameterSet", Sequence[object], object], + force_tuple: bool = False, + ) -> "ParameterSet": + """Extract from an object or objects. + :param parameterset: - a legacy style parameterset that may or may not be a tuple, - and may or may not be wrapped into a mess of mark objects + A legacy style parameterset that may or may not be a tuple, + and may or may not be wrapped into a mess of mark objects. :param force_tuple: - enforce tuple wrapping so single argument tuple values - don't get decomposed and break tests + Enforce tuple wrapping so single argument tuple values + don't get decomposed and break tests. """ if isinstance(parameterset, cls): @@ -85,10 +126,20 @@ def extract_from(cls, parameterset, force_tuple=False): if force_tuple: return cls.param(parameterset) else: - return cls(parameterset, marks=[], id=None) + # TODO: Refactor to fix this type-ignore. Currently the following + # passes type-checking but crashes: + # + # @pytest.mark.parametrize(('x', 'y'), [1, 2]) + # def test_foo(x, y): pass + return cls(parameterset, marks=[], id=None) # type: ignore[arg-type] @staticmethod - def _parse_parametrize_args(argnames, argvalues, *args, **kwargs): + def _parse_parametrize_args( + argnames: Union[str, List[str], Tuple[str, ...]], + argvalues: Iterable[Union["ParameterSet", Sequence[object], object]], + *args, + **kwargs, + ) -> Tuple[Union[List[str], Tuple[str, ...]], bool]: if not isinstance(argnames, (tuple, list)): argnames = [x.strip() for x in argnames.split(",") if x.strip()] force_tuple = len(argnames) == 1 @@ -97,19 +148,29 @@ def _parse_parametrize_args(argnames, argvalues, *args, **kwargs): return argnames, force_tuple @staticmethod - def _parse_parametrize_parameters(argvalues, force_tuple): + def _parse_parametrize_parameters( + argvalues: Iterable[Union["ParameterSet", Sequence[object], object]], + force_tuple: bool, + ) -> List["ParameterSet"]: return [ ParameterSet.extract_from(x, force_tuple=force_tuple) for x in argvalues ] @classmethod - def _for_parametrize(cls, argnames, argvalues, func, config, function_definition): + def _for_parametrize( + cls, + argnames: Union[str, List[str], Tuple[str, ...]], + argvalues: Iterable[Union["ParameterSet", Sequence[object], object]], + func, + config: Config, + nodeid: str, + ) -> Tuple[Union[List[str], Tuple[str, ...]], List["ParameterSet"]]: argnames, force_tuple = cls._parse_parametrize_args(argnames, argvalues) parameters = cls._parse_parametrize_parameters(argvalues, force_tuple) del argvalues if parameters: - # check all parameter sets have the correct number of values + # Check all parameter sets have the correct number of values. for param in parameters: if len(param.values) != len(argnames): msg = ( @@ -120,7 +181,7 @@ def _for_parametrize(cls, argnames, argvalues, func, config, function_definition ) fail( msg.format( - nodeid=function_definition.nodeid, + nodeid=nodeid, values=param.values, names=argnames, names_len=len(argnames), @@ -129,8 +190,8 @@ def _for_parametrize(cls, argnames, argvalues, func, config, function_definition pytrace=False, ) else: - # empty parameter set (likely computed at runtime): create a single - # parameter set with NOTSET values, with the "empty parameter set" mark applied to it + # Empty parameter set (likely computed at runtime): create a single + # parameter set with NOTSET values, with the "empty parameter set" mark applied to it. mark = get_empty_parameterset_mark(config, argnames, func) parameters.append( ParameterSet(values=(NOTSET,) * len(argnames), marks=[mark], id=None) @@ -138,35 +199,39 @@ def _for_parametrize(cls, argnames, argvalues, func, config, function_definition return argnames, parameters +@final @attr.s(frozen=True) class Mark: - #: name of the mark + #: Name of the mark. name = attr.ib(type=str) - #: positional arguments of the mark decorator - args = attr.ib() # List[object] - #: keyword arguments of the mark decorator - kwargs = attr.ib() # Dict[str, object] + #: Positional arguments of the mark decorator. + args = attr.ib(type=Tuple[Any, ...]) + #: Keyword arguments of the mark decorator. + kwargs = attr.ib(type=Mapping[str, Any]) - #: source Mark for ids with parametrize Marks + #: Source Mark for ids with parametrize Marks. _param_ids_from = attr.ib(type=Optional["Mark"], default=None, repr=False) - #: resolved/generated ids with parametrize Marks - _param_ids_generated = attr.ib(type=Optional[List[str]], default=None, repr=False) + #: Resolved/generated ids with parametrize Marks. + _param_ids_generated = attr.ib( + type=Optional[Sequence[str]], default=None, repr=False + ) - def _has_param_ids(self): + def _has_param_ids(self) -> bool: return "ids" in self.kwargs or len(self.args) >= 4 def combined_with(self, other: "Mark") -> "Mark": - """ - :param other: the mark to combine with - :type other: Mark - :rtype: Mark + """Return a new Mark which is a combination of this + Mark and another Mark. + + Combines by appending args and merging kwargs. - combines by appending args and merging the mappings + :param Mark other: The mark to combine with. + :rtype: Mark """ assert self.name == other.name # Remember source of ids with parametrize Marks. - param_ids_from = None # type: Optional[Mark] + param_ids_from: Optional[Mark] = None if self.name == "parametrize": if other._has_param_ids(): param_ids_from = other @@ -181,13 +246,20 @@ def combined_with(self, other: "Mark") -> "Mark": ) +# A generic parameter designating an object to which a Mark may +# be applied -- a test function (callable) or class. +# Note: a lambda is not allowed, but this can't be represented. +_Markable = TypeVar("_Markable", bound=Union[Callable[..., object], type]) + + @attr.s class MarkDecorator: - """ A decorator for test functions and test classes. When applied - it will create :class:`Mark` objects which are often created like this:: + """A decorator for applying a mark on test functions and classes. + + MarkDecorators are created with ``pytest.mark``:: - mark1 = pytest.mark.NAME # simple MarkDecorator - mark2 = pytest.mark.NAME(name1=value) # parametrized MarkDecorator + mark1 = pytest.mark.NAME # Simple MarkDecorator + mark2 = pytest.mark.NAME(name1=value) # Parametrized MarkDecorator and can then be applied as decorators to test functions:: @@ -195,64 +267,75 @@ class MarkDecorator: def test_function(): pass - When a MarkDecorator instance is called it does the following: + When a MarkDecorator is called it does the following: 1. If called with a single class as its only positional argument and no - additional keyword arguments, it attaches itself to the class so it + additional keyword arguments, it attaches the mark to the class so it gets applied automatically to all test cases found in that class. - 2. If called with a single function as its only positional argument and - no additional keyword arguments, it attaches a MarkInfo object to the - function, containing all the arguments already stored internally in - the MarkDecorator. - 3. When called in any other case, it performs a 'fake construction' call, - i.e. it returns a new MarkDecorator instance with the original - MarkDecorator's content updated with the arguments passed to this - call. - - Note: The rules above prevent MarkDecorator objects from storing only a - single function or class reference as their positional argument with no - additional keyword or positional arguments. + 2. If called with a single function as its only positional argument and + no additional keyword arguments, it attaches the mark to the function, + containing all the arguments already stored internally in the + MarkDecorator. + + 3. When called in any other case, it returns a new MarkDecorator instance + with the original MarkDecorator's content updated with the arguments + passed to this call. + + Note: The rules above prevent MarkDecorators from storing only a single + function or class reference as their positional argument with no + additional keyword or positional arguments. You can work around this by + using `with_args()`. """ - mark = attr.ib(validator=attr.validators.instance_of(Mark)) + mark = attr.ib(type=Mark, validator=attr.validators.instance_of(Mark)) @property - def name(self): - """alias for mark.name""" + def name(self) -> str: + """Alias for mark.name.""" return self.mark.name @property - def args(self): - """alias for mark.args""" + def args(self) -> Tuple[Any, ...]: + """Alias for mark.args.""" return self.mark.args @property - def kwargs(self): - """alias for mark.kwargs""" + def kwargs(self) -> Mapping[str, Any]: + """Alias for mark.kwargs.""" return self.mark.kwargs @property - def markname(self): + def markname(self) -> str: return self.name # for backward-compat (2.4.1 had this attr) - def __repr__(self): - return "".format(self.mark) + def __repr__(self) -> str: + return f"" - def with_args(self, *args, **kwargs): - """ return a MarkDecorator with extra arguments added + def with_args(self, *args: object, **kwargs: object) -> "MarkDecorator": + """Return a MarkDecorator with extra arguments added. - unlike call this can be used even if the sole argument is a callable/class + Unlike calling the MarkDecorator, with_args() can be used even + if the sole argument is a callable/class. - :return: MarkDecorator + :rtype: MarkDecorator """ - mark = Mark(self.name, args, kwargs) return self.__class__(self.mark.combined_with(mark)) - def __call__(self, *args, **kwargs): - """ if passed a single callable argument: decorate it with mark info. - otherwise add *args/**kwargs in-place to mark information. """ + # Type ignored because the overloads overlap with an incompatible + # return type. Not much we can do about that. Thankfully mypy picks + # the first match so it works out even if we break the rules. + @overload + def __call__(self, arg: _Markable) -> _Markable: # type: ignore[misc] + pass + + @overload + def __call__(self, *args: object, **kwargs: object) -> "MarkDecorator": + pass + + def __call__(self, *args: object, **kwargs: object): + """Call the MarkDecorator.""" if args and not kwargs: func = args[0] is_class = inspect.isclass(func) @@ -262,10 +345,8 @@ def __call__(self, *args, **kwargs): return self.with_args(*args, **kwargs) -def get_unpacked_marks(obj): - """ - obtain the unpacked marks that are stored on an object - """ +def get_unpacked_marks(obj) -> List[Mark]: + """Obtain the unpacked marks that are stored on an object.""" mark_list = getattr(obj, "pytestmark", []) if not isinstance(mark_list, list): mark_list = [mark_list] @@ -273,10 +354,9 @@ def get_unpacked_marks(obj): def normalize_mark_list(mark_list: Iterable[Union[Mark, MarkDecorator]]) -> List[Mark]: - """ - normalizes marker decorating helpers to mark objects + """Normalize marker decorating helpers to mark objects. - :type mark_list: List[Union[Mark, Markdecorator]] + :type List[Union[Mark, Markdecorator]] mark_list: :rtype: List[Mark] """ extracted = [ @@ -284,34 +364,118 @@ def normalize_mark_list(mark_list: Iterable[Union[Mark, MarkDecorator]]) -> List ] # unpack MarkDecorator for mark in extracted: if not isinstance(mark, Mark): - raise TypeError("got {!r} instead of Mark".format(mark)) + raise TypeError(f"got {mark!r} instead of Mark") return [x for x in extracted if isinstance(x, Mark)] -def store_mark(obj, mark): - """store a Mark on an object - this is used to implement the Mark declarations/decorators correctly +def store_mark(obj, mark: Mark) -> None: + """Store a Mark on an object. + + This is used to implement the Mark declarations/decorators correctly. """ assert isinstance(mark, Mark), mark - # always reassign name to avoid updating pytestmark - # in a reference that was only borrowed + # Always reassign name to avoid updating pytestmark in a reference that + # was only borrowed. obj.pytestmark = get_unpacked_marks(obj) + [mark] +# Typing for builtin pytest marks. This is cheating; it gives builtin marks +# special privilege, and breaks modularity. But practicality beats purity... +if TYPE_CHECKING: + from _pytest.fixtures import _Scope + + class _SkipMarkDecorator(MarkDecorator): + @overload # type: ignore[override,misc] + def __call__(self, arg: _Markable) -> _Markable: + ... + + @overload + def __call__(self, reason: str = ...) -> "MarkDecorator": + ... + + class _SkipifMarkDecorator(MarkDecorator): + def __call__( # type: ignore[override] + self, + condition: Union[str, bool] = ..., + *conditions: Union[str, bool], + reason: str = ..., + ) -> MarkDecorator: + ... + + class _XfailMarkDecorator(MarkDecorator): + @overload # type: ignore[override,misc] + def __call__(self, arg: _Markable) -> _Markable: + ... + + @overload + def __call__( + self, + condition: Union[str, bool] = ..., + *conditions: Union[str, bool], + reason: str = ..., + run: bool = ..., + raises: Union[Type[BaseException], Tuple[Type[BaseException], ...]] = ..., + strict: bool = ..., + ) -> MarkDecorator: + ... + + class _ParametrizeMarkDecorator(MarkDecorator): + def __call__( # type: ignore[override] + self, + argnames: Union[str, List[str], Tuple[str, ...]], + argvalues: Iterable[Union[ParameterSet, Sequence[object], object]], + *, + indirect: Union[bool, Sequence[str]] = ..., + ids: Optional[ + Union[ + Iterable[Union[None, str, float, int, bool]], + Callable[[Any], Optional[object]], + ] + ] = ..., + scope: Optional[_Scope] = ..., + ) -> MarkDecorator: + ... + + class _UsefixturesMarkDecorator(MarkDecorator): + def __call__( # type: ignore[override] + self, *fixtures: str + ) -> MarkDecorator: + ... + + class _FilterwarningsMarkDecorator(MarkDecorator): + def __call__( # type: ignore[override] + self, *filters: str + ) -> MarkDecorator: + ... + + +@final class MarkGenerator: - """ Factory for :class:`MarkDecorator` objects - exposed as - a ``pytest.mark`` singleton instance. Example:: + """Factory for :class:`MarkDecorator` objects - exposed as + a ``pytest.mark`` singleton instance. + + Example:: import pytest + @pytest.mark.slowtest def test_function(): pass - will set a 'slowtest' :class:`MarkInfo` object - on the ``test_function`` object. """ + applies a 'slowtest' :class:`Mark` on ``test_function``. + """ + + _config: Optional[Config] = None + _markers: Set[str] = set() - _config = None - _markers = set() # type: Set[str] + # See TYPE_CHECKING above. + if TYPE_CHECKING: + skip: _SkipMarkDecorator + skipif: _SkipifMarkDecorator + xfail: _XfailMarkDecorator + parametrize: _ParametrizeMarkDecorator + usefixtures: _UsefixturesMarkDecorator + filterwarnings: _FilterwarningsMarkDecorator def __getattr__(self, name: str) -> MarkDecorator: if name[0] == "_": @@ -332,21 +496,21 @@ def __getattr__(self, name: str) -> MarkDecorator: # If the name is not in the set of known marks after updating, # then it really is time to issue a warning or an error. if name not in self._markers: - if self._config.option.strict_markers: + if self._config.option.strict_markers or self._config.option.strict: fail( - "{!r} not found in `markers` configuration option".format(name), + f"{name!r} not found in `markers` configuration option", pytrace=False, ) # Raise a specific error for common misspellings of "parametrize". if name in ["parameterize", "parametrise", "parameterise"]: __tracebackhide__ = True - fail("Unknown '{}' mark, did you mean 'parametrize'?".format(name)) + fail(f"Unknown '{name}' mark, did you mean 'parametrize'?") warnings.warn( "Unknown pytest.mark.%s - is this a typo? You can register " "custom marks to avoid this warning - for details, see " - "https://docs.pytest.org/en/latest/mark.html" % name, + "https://docs.pytest.org/en/stable/mark.html" % name, PytestUnknownMarkWarning, 2, ) @@ -357,13 +521,14 @@ def __getattr__(self, name: str) -> MarkDecorator: MARK_GEN = MarkGenerator() -class NodeKeywords(MutableMapping): - def __init__(self, node): +@final +class NodeKeywords(MutableMapping[str, Any]): + def __init__(self, node: "Node") -> None: self.node = node self.parent = node.parent self._markers = {node.name: True} - def __getitem__(self, key): + def __getitem__(self, key: str) -> Any: try: return self._markers[key] except KeyError: @@ -371,24 +536,24 @@ def __getitem__(self, key): raise return self.parent.keywords[key] - def __setitem__(self, key, value): + def __setitem__(self, key: str, value: Any) -> None: self._markers[key] = value - def __delitem__(self, key): + def __delitem__(self, key: str) -> None: raise ValueError("cannot delete key in keywords dict") - def __iter__(self): + def __iter__(self) -> Iterator[str]: seen = self._seen() return iter(seen) - def _seen(self): + def _seen(self) -> Set[str]: seen = set(self._markers) if self.parent is not None: seen.update(self.parent.keywords) return seen - def __len__(self): + def __len__(self) -> int: return len(self._seen()) - def __repr__(self): - return "".format(self.node) + def __repr__(self) -> str: + return f"" diff --git a/src/_pytest/monkeypatch.py b/src/_pytest/monkeypatch.py index ce1c0f65102..a052f693ac0 100644 --- a/src/_pytest/monkeypatch.py +++ b/src/_pytest/monkeypatch.py @@ -1,22 +1,37 @@ -""" monkeypatching and mocking functionality. """ +"""Monkeypatching and mocking functionality.""" import os import re import sys import warnings from contextlib import contextmanager +from pathlib import Path +from typing import Any from typing import Generator - -import pytest +from typing import List +from typing import MutableMapping +from typing import Optional +from typing import overload +from typing import Tuple +from typing import TypeVar +from typing import Union + +from _pytest.compat import final from _pytest.fixtures import fixture -from _pytest.pathlib import Path +from _pytest.warning_types import PytestWarning RE_IMPORT_ERROR_NAME = re.compile(r"^No module named (.*)$") +K = TypeVar("K") +V = TypeVar("V") + + @fixture -def monkeypatch(): - """The returned ``monkeypatch`` fixture provides these - helper methods to modify objects, dictionaries or os.environ:: +def monkeypatch() -> Generator["MonkeyPatch", None, None]: + """A convenient fixture for monkey-patching. + + The fixture provides these methods to modify objects, dictionaries or + os.environ:: monkeypatch.setattr(obj, name, value, raising=True) monkeypatch.delattr(obj, name, raising=True) @@ -27,18 +42,17 @@ def monkeypatch(): monkeypatch.syspath_prepend(path) monkeypatch.chdir(path) - All modifications will be undone after the requesting - test function or fixture has finished. The ``raising`` - parameter determines if a KeyError or AttributeError - will be raised if the set/deletion operation has no target. + All modifications will be undone after the requesting test function or + fixture has finished. The ``raising`` parameter determines if a KeyError + or AttributeError will be raised if the set/deletion operation has no target. """ mpatch = MonkeyPatch() yield mpatch mpatch.undo() -def resolve(name): - # simplified from zope.dottedname +def resolve(name: str) -> object: + # Simplified from zope.dottedname. parts = name.split(".") used = parts.pop(0) @@ -51,38 +65,35 @@ def resolve(name): pass else: continue - # we use explicit un-nesting of the handling block in order - # to avoid nested exceptions on python 3 + # We use explicit un-nesting of the handling block in order + # to avoid nested exceptions. try: __import__(used) except ImportError as ex: - # str is used for py2 vs py3 expected = str(ex).split()[-1] if expected == used: raise else: - raise ImportError("import error in {}: {}".format(used, ex)) + raise ImportError(f"import error in {used}: {ex}") from ex found = annotated_getattr(found, part, used) return found -def annotated_getattr(obj, name, ann): +def annotated_getattr(obj: object, name: str, ann: str) -> object: try: obj = getattr(obj, name) - except AttributeError: + except AttributeError as e: raise AttributeError( "{!r} object at {} has no attribute {!r}".format( type(obj).__name__, ann, name ) - ) + ) from e return obj -def derive_importpath(import_path, raising): - if not isinstance(import_path, str) or "." not in import_path: - raise TypeError( - "must be absolute import path string, not {!r}".format(import_path) - ) +def derive_importpath(import_path: str, raising: bool) -> Tuple[str, object]: + if not isinstance(import_path, str) or "." not in import_path: # type: ignore[unreachable] + raise TypeError(f"must be absolute import path string, not {import_path!r}") module, attr = import_path.rsplit(".", 1) target = resolve(module) if raising: @@ -91,32 +102,46 @@ def derive_importpath(import_path, raising): class Notset: - def __repr__(self): + def __repr__(self) -> str: return "" notset = Notset() +@final class MonkeyPatch: - """ Object returned by the ``monkeypatch`` fixture keeping a record of setattr/item/env/syspath changes. + """Helper to conveniently monkeypatch attributes/items/environment + variables/syspath. + + Returned by the :fixture:`monkeypatch` fixture. + + :versionchanged:: 6.2 + Can now also be used directly as `pytest.MonkeyPatch()`, for when + the fixture is not available. In this case, use + :meth:`with MonkeyPatch.context() as mp: ` or remember to call + :meth:`undo` explicitly. """ - def __init__(self): - self._setattr = [] - self._setitem = [] - self._cwd = None - self._savesyspath = None + def __init__(self) -> None: + self._setattr: List[Tuple[object, str, object]] = [] + self._setitem: List[Tuple[MutableMapping[Any, Any], object, object]] = ([]) + self._cwd: Optional[str] = None + self._savesyspath: Optional[List[str]] = None + @classmethod @contextmanager - def context(self) -> Generator["MonkeyPatch", None, None]: - """ - Context manager that returns a new :class:`MonkeyPatch` object which - undoes any patching done inside the ``with`` block upon exit: + def context(cls) -> Generator["MonkeyPatch", None, None]: + """Context manager that returns a new :class:`MonkeyPatch` object + which undoes any patching done inside the ``with`` block upon exit. + + Example: .. code-block:: python import functools + + def test_partial(monkeypatch): with monkeypatch.context() as m: m.setattr(functools, "partial", 3) @@ -125,30 +150,46 @@ def test_partial(monkeypatch): such as mocking ``stdlib`` functions that might break pytest itself if mocked (for examples of this see `#3290 `_. """ - m = MonkeyPatch() + m = cls() try: yield m finally: m.undo() - def setattr(self, target, name, value=notset, raising=True): - """ Set attribute value on target, memorizing the old value. - By default raise AttributeError if the attribute did not exist. + @overload + def setattr( + self, target: str, name: object, value: Notset = ..., raising: bool = ..., + ) -> None: + ... + + @overload + def setattr( + self, target: object, name: str, value: object, raising: bool = ..., + ) -> None: + ... + + def setattr( + self, + target: Union[str, object], + name: Union[object, str], + value: object = notset, + raising: bool = True, + ) -> None: + """Set attribute value on target, memorizing the old value. For convenience you can specify a string as ``target`` which will be interpreted as a dotted import path, with the last part - being the attribute name. Example: + being the attribute name. For example, ``monkeypatch.setattr("os.getcwd", lambda: "/")`` would set the ``getcwd`` function of the ``os`` module. - The ``raising`` value determines if the setattr should fail - if the attribute is not already present (defaults to True - which means it will raise). + Raises AttributeError if the attribute does not exist, unless + ``raising`` is set to False. """ __tracebackhide__ = True import inspect - if value is notset: + if isinstance(value, Notset): if not isinstance(target, str): raise TypeError( "use setattr(target, name, value) or " @@ -157,10 +198,17 @@ def setattr(self, target, name, value=notset, raising=True): ) value = name name, target = derive_importpath(target, raising) + else: + if not isinstance(name, str): + raise TypeError( + "use setattr(target, name, value) with name being a string or " + "setattr(target, value) with target being a dotted " + "import string" + ) oldval = getattr(target, name, notset) if raising and oldval is notset: - raise AttributeError("{!r} has no attribute {!r}".format(target, name)) + raise AttributeError(f"{target!r} has no attribute {name!r}") # avoid class descriptors like staticmethod/classmethod if inspect.isclass(target): @@ -168,21 +216,25 @@ def setattr(self, target, name, value=notset, raising=True): self._setattr.append((target, name, oldval)) setattr(target, name, value) - def delattr(self, target, name=notset, raising=True): - """ Delete attribute ``name`` from ``target``, by default raise - AttributeError it the attribute did not previously exist. + def delattr( + self, + target: Union[object, str], + name: Union[str, Notset] = notset, + raising: bool = True, + ) -> None: + """Delete attribute ``name`` from ``target``. If no ``name`` is specified and ``target`` is a string it will be interpreted as a dotted import path with the last part being the attribute name. - If ``raising`` is set to False, no exception will be raised if the - attribute is missing. + Raises AttributeError it the attribute does not exist, unless + ``raising`` is set to False. """ __tracebackhide__ = True import inspect - if name is notset: + if isinstance(name, Notset): if not isinstance(target, str): raise TypeError( "use delattr(target, name) or " @@ -202,16 +254,16 @@ def delattr(self, target, name=notset, raising=True): self._setattr.append((target, name, oldval)) delattr(target, name) - def setitem(self, dic, name, value): - """ Set dictionary entry ``name`` to value. """ + def setitem(self, dic: MutableMapping[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 - def delitem(self, dic, name, raising=True): - """ Delete ``name`` from dict. Raise KeyError if it doesn't exist. + def delitem(self, dic: MutableMapping[K, V], name: K, raising: bool = True) -> None: + """Delete ``name`` from dict. - If ``raising`` is set to False, no exception will be raised if the - key is missing. + Raises ``KeyError`` if it doesn't exist, unless ``raising`` is set to + False. """ if name not in dic: if raising: @@ -220,13 +272,16 @@ def delitem(self, dic, name, raising=True): self._setitem.append((dic, name, dic.get(name, notset))) del dic[name] - def setenv(self, name, value, prepend=None): - """ Set environment variable ``name`` to ``value``. If ``prepend`` - is a character, read the current environment variable value - and prepend the ``value`` adjoined with the ``prepend`` character.""" + def setenv(self, name: str, value: str, prepend: Optional[str] = None) -> None: + """Set environment variable ``name`` to ``value``. + + If ``prepend`` is a character, read the current environment variable + value and prepend the ``value`` adjoined with the ``prepend`` + character. + """ if not isinstance(value, str): - warnings.warn( - pytest.PytestWarning( + warnings.warn( # type: ignore[unreachable] + PytestWarning( "Value of environment variable {name} type should be str, but got " "{value!r} (type: {type}); converted to str implicitly".format( name=name, value=value, type=type(value).__name__ @@ -239,17 +294,17 @@ def setenv(self, name, value, prepend=None): value = value + prepend + os.environ[name] self.setitem(os.environ, name, value) - def delenv(self, name, raising=True): - """ Delete ``name`` from the environment. Raise KeyError if it does - not exist. + def delenv(self, name: str, raising: bool = True) -> None: + """Delete ``name`` from the environment. - If ``raising`` is set to False, no exception will be raised if the - environment variable is missing. + Raises ``KeyError`` if it does not exist, unless ``raising`` is set to + False. """ - self.delitem(os.environ, name, raising=raising) + environ: MutableMapping[str, str] = os.environ + self.delitem(environ, name, raising=raising) - def syspath_prepend(self, path): - """ Prepend ``path`` to ``sys.path`` list of import locations. """ + def syspath_prepend(self, path) -> None: + """Prepend ``path`` to ``sys.path`` list of import locations.""" from pkg_resources import fixup_namespace_packages if self._savesyspath is None: @@ -270,8 +325,9 @@ def syspath_prepend(self, path): invalidate_caches() - def chdir(self, path): - """ Change the current working directory to the specified path. + def chdir(self, path) -> None: + """Change the current working directory to the specified path. + Path can be a string or a py.path.local object. """ if self._cwd is None: @@ -279,15 +335,16 @@ def chdir(self, path): if hasattr(path, "chdir"): path.chdir() elif isinstance(path, Path): - # modern python uses the fspath protocol here LEGACY + # Modern python uses the fspath protocol here LEGACY os.chdir(str(path)) else: os.chdir(path) - def undo(self): - """ Undo previous changes. This call consumes the - undo stack. Calling it a second time has no effect unless - you do more monkeypatching after the undo call. + def undo(self) -> None: + """Undo previous changes. + + This call consumes the undo stack. Calling it a second time has no + effect unless you do more monkeypatching after the undo call. There is generally no need to call `undo()`, since it is called automatically during tear-down. @@ -304,14 +361,14 @@ def undo(self): else: delattr(obj, name) self._setattr[:] = [] - for dictionary, name, value in reversed(self._setitem): + for dictionary, key, value in reversed(self._setitem): if value is notset: try: - del dictionary[name] + del dictionary[key] except KeyError: - pass # was already deleted, so we have the desired state + pass # Was already deleted, so we have the desired state. else: - dictionary[name] = value + dictionary[key] = value self._setitem[:] = [] if self._savesyspath is not None: sys.path[:] = self._savesyspath diff --git a/src/_pytest/nodes.py b/src/_pytest/nodes.py index 45f0aa8a1de..27434fb6a67 100644 --- a/src/_pytest/nodes.py +++ b/src/_pytest/nodes.py @@ -1,120 +1,143 @@ import os import warnings -from functools import lru_cache -from typing import Any -from typing import Dict +from pathlib import Path +from typing import Callable +from typing import Iterable +from typing import Iterator from typing import List from typing import Optional +from typing import overload from typing import Set from typing import Tuple +from typing import Type +from typing import TYPE_CHECKING +from typing import TypeVar from typing import Union import py import _pytest._code -from _pytest._code.code import ExceptionChainRepr +from _pytest._code import getfslineno from _pytest._code.code import ExceptionInfo -from _pytest._code.code import ReprExceptionInfo -from _pytest._code.source import getfslineno +from _pytest._code.code import TerminalRepr from _pytest.compat import cached_property -from _pytest.compat import TYPE_CHECKING from _pytest.config import Config -from _pytest.config import PytestPluginManager -from _pytest.deprecated import NODE_USE_FROM_PARENT -from _pytest.fixtures import FixtureDef -from _pytest.fixtures import FixtureLookupError -from _pytest.fixtures import FixtureLookupErrorRepr +from _pytest.config import ConftestImportFailure +from _pytest.deprecated import FSCOLLECTOR_GETHOOKPROXY_ISINITPATH from _pytest.mark.structures import Mark from _pytest.mark.structures import MarkDecorator from _pytest.mark.structures import NodeKeywords from _pytest.outcomes import fail -from _pytest.outcomes import Failed +from _pytest.pathlib import absolutepath from _pytest.store import Store if TYPE_CHECKING: # Imported here due to circular import. - from _pytest.main import Session # noqa: F401 + from _pytest.main import Session + from _pytest._code.code import _TracebackStyle + SEP = "/" tracebackcutdir = py.path.local(_pytest.__file__).dirpath() -@lru_cache(maxsize=None) -def _splitnode(nodeid): - """Split a nodeid into constituent 'parts'. +def iterparentnodeids(nodeid: str) -> Iterator[str]: + """Return the parent node IDs of a given node ID, inclusive. - Node IDs are strings, and can be things like: - '' - 'testing/code' - 'testing/code/test_excinfo.py' - 'testing/code/test_excinfo.py::TestFormattedExcinfo' + For the node ID - Return values are lists e.g. - [] - ['testing', 'code'] - ['testing', 'code', 'test_excinfo.py'] - ['testing', 'code', 'test_excinfo.py', 'TestFormattedExcinfo'] - """ - if nodeid == "": - # If there is no root node at all, return an empty list so the caller's logic can remain sane - return () - parts = nodeid.split(SEP) - # Replace single last element 'test_foo.py::Bar' with multiple elements 'test_foo.py', 'Bar' - parts[-1:] = parts[-1].split("::") - # Convert parts into a tuple to avoid possible errors with caching of a mutable type - return tuple(parts) + "testing/code/test_excinfo.py::TestFormattedExcinfo::test_repr_source" + the result would be -def ischildnode(baseid, nodeid): - """Return True if the nodeid is a child node of the baseid. + "" + "testing" + "testing/code" + "testing/code/test_excinfo.py" + "testing/code/test_excinfo.py::TestFormattedExcinfo" + "testing/code/test_excinfo.py::TestFormattedExcinfo::test_repr_source" - E.g. 'foo/bar::Baz' is a child of 'foo', 'foo/bar' and 'foo/bar::Baz', but not of 'foo/blorp' + Note that :: parts are only considered at the last / component. """ - base_parts = _splitnode(baseid) - node_parts = _splitnode(nodeid) - if len(node_parts) < len(base_parts): - return False - return node_parts[: len(base_parts)] == base_parts + pos = 0 + sep = SEP + yield "" + while True: + at = nodeid.find(sep, pos) + if at == -1 and sep == SEP: + sep = "::" + elif at == -1: + if nodeid: + yield nodeid + break + else: + if at: + yield nodeid[:at] + pos = at + len(sep) + + +_NodeType = TypeVar("_NodeType", bound="Node") class NodeMeta(type): def __call__(self, *k, **kw): - warnings.warn(NODE_USE_FROM_PARENT.format(name=self.__name__), stacklevel=2) - return super().__call__(*k, **kw) + msg = ( + "Direct construction of {name} has been deprecated, please use {name}.from_parent.\n" + "See " + "https://docs.pytest.org/en/stable/deprecations.html#node-construction-changed-to-node-from-parent" + " for more details." + ).format(name=self.__name__) + fail(msg, pytrace=False) def _create(self, *k, **kw): return super().__call__(*k, **kw) class Node(metaclass=NodeMeta): - """ base class for Collector and Item the test collection tree. - Collector subclasses have children, Items are terminal nodes.""" + """Base class for Collector and Item, the components of the test + collection tree. + + Collector subclasses have children; Items are leaf nodes. + """ + + # Use __slots__ to make attribute access faster. + # Note that __dict__ is still available. + __slots__ = ( + "name", + "parent", + "config", + "session", + "fspath", + "_nodeid", + "_store", + "__dict__", + ) def __init__( self, name: str, - parent: Optional["Node"] = None, + parent: "Optional[Node]" = None, config: Optional[Config] = None, - session: Optional["Session"] = None, + session: "Optional[Session]" = None, fspath: Optional[py.path.local] = None, nodeid: Optional[str] = None, ) -> None: - #: a unique name within the scope of the parent node + #: A unique name within the scope of the parent node. self.name = name - #: the parent collector node. + #: The parent collector node. self.parent = parent - #: the pytest config object + #: The pytest config object. if config: - self.config = config + self.config: Config = config else: if not parent: raise TypeError("config or parent must be provided") self.config = parent.config - #: the session this node is part of + #: The pytest session this node is part of. if session: self.session = session else: @@ -122,20 +145,17 @@ def __init__( raise TypeError("session or parent must be provided") self.session = parent.session - #: filesystem path where this node was collected from (can be None) + #: Filesystem path where this node was collected from (can be None). self.fspath = fspath or getattr(parent, "fspath", None) - #: keywords/markers collected from all scopes + #: Keywords/markers collected from all scopes. self.keywords = NodeKeywords(self) - #: the marker objects belonging to this node - self.own_markers = [] # type: List[Mark] - - #: allow adding of extra keywords to use for matching - self.extra_keyword_matches = set() # type: Set[str] + #: The marker objects belonging to this node. + self.own_markers: List[Mark] = [] - # used for storing artificial fixturedefs for direct parametrization - self._name2pseudofixturedef = {} # type: Dict[str, FixtureDef] + #: Allow adding of extra keywords to use for matching. + self.extra_keyword_matches: Set[str] = set() if nodeid is not None: assert "::()" not in nodeid @@ -153,15 +173,15 @@ def __init__( @classmethod def from_parent(cls, parent: "Node", **kw): - """ - Public Constructor for Nodes + """Public constructor for Nodes. This indirection got introduced in order to enable removing the fragile logic from the node constructors. - Subclasses can use ``super().from_parent(...)`` when overriding the construction + Subclasses can use ``super().from_parent(...)`` when overriding the + construction. - :param parent: the parent node of this test Node + :param parent: The parent node of this Node. """ if "config" in kw: raise TypeError("config is not a valid argument for from_parent") @@ -171,64 +191,67 @@ def from_parent(cls, parent: "Node", **kw): @property def ihook(self): - """ fspath sensitive hook proxy used to call pytest hooks""" + """fspath-sensitive hook proxy used to call pytest hooks.""" return self.session.gethookproxy(self.fspath) - def __repr__(self): + def __repr__(self) -> str: return "<{} {}>".format(self.__class__.__name__, getattr(self, "name", None)) - def warn(self, warning): - """Issue a warning for this item. + def warn(self, warning: Warning) -> None: + """Issue a warning for this Node. - Warnings will be displayed after the test session, unless explicitly suppressed + Warnings will be displayed after the test session, unless explicitly suppressed. - :param Warning warning: the warning instance to issue. Must be a subclass of PytestWarning. + :param Warning warning: + The warning instance to issue. - :raise ValueError: if ``warning`` instance is not a subclass of PytestWarning. + :raises ValueError: If ``warning`` instance is not a subclass of Warning. Example usage: .. code-block:: python node.warn(PytestWarning("some message")) + node.warn(UserWarning("some message")) + .. versionchanged:: 6.2 + Any subclass of :class:`Warning` is now accepted, rather than only + :class:`PytestWarning ` subclasses. """ - from _pytest.warning_types import PytestWarning - - if not isinstance(warning, PytestWarning): + # enforce type checks here to avoid getting a generic type error later otherwise. + if not isinstance(warning, Warning): raise ValueError( - "warning must be an instance of PytestWarning or subclass, got {!r}".format( + "warning must be an instance of Warning or subclass, got {!r}".format( warning ) ) path, lineno = get_fslocation_from_item(self) + assert lineno is not None warnings.warn_explicit( - warning, - category=None, - filename=str(path), - lineno=lineno + 1 if lineno is not None else None, + warning, category=None, filename=str(path), lineno=lineno + 1, ) - # methods for ordering nodes + # Methods for ordering nodes. + @property - def nodeid(self): - """ a ::-separated string denoting its collection tree address. """ + def nodeid(self) -> str: + """A ::-separated string denoting its collection tree address.""" return self._nodeid - def __hash__(self): - return hash(self.nodeid) + def __hash__(self) -> int: + return hash(self._nodeid) - def setup(self): + def setup(self) -> None: pass - def teardown(self): + def teardown(self) -> None: pass - def listchain(self): - """ return list of all parent collectors up to self, - starting from root of collection tree. """ + def listchain(self) -> List["Node"]: + """Return list of all parent collectors up to self, starting from + the root of collection tree.""" chain = [] - item = self # type: Optional[Node] + item: Optional[Node] = self while item is not None: chain.append(item) item = item.parent @@ -238,12 +261,10 @@ def listchain(self): def add_marker( self, marker: Union[str, MarkDecorator], append: bool = True ) -> None: - """dynamically add a marker object to the node. + """Dynamically add a marker object to the node. - :type marker: ``str`` or ``pytest.mark.*`` object - :param marker: - ``append=True`` whether to append the marker, - if ``False`` insert at position ``0``. + :param append: + Whether to append the marker, or prepend it. """ from _pytest.mark import MARK_GEN @@ -253,76 +274,93 @@ def add_marker( marker_ = getattr(MARK_GEN, marker) else: raise ValueError("is not a string or pytest.mark.* Marker") - self.keywords[marker_.name] = marker + self.keywords[marker_.name] = marker_ if append: self.own_markers.append(marker_.mark) else: self.own_markers.insert(0, marker_.mark) - def iter_markers(self, name=None): - """ - :param name: if given, filter the results by the name attribute + def iter_markers(self, name: Optional[str] = None) -> Iterator[Mark]: + """Iterate over all markers of the node. - iterate over all markers of the node + :param name: If given, filter the results by the name attribute. """ return (x[1] for x in self.iter_markers_with_node(name=name)) - def iter_markers_with_node(self, name=None): - """ - :param name: if given, filter the results by the name attribute + def iter_markers_with_node( + self, name: Optional[str] = None + ) -> Iterator[Tuple["Node", Mark]]: + """Iterate over all markers of the node. - iterate over all markers of the node - returns sequence of tuples (node, mark) + :param name: If given, filter the results by the name attribute. + :returns: An iterator of (node, mark) tuples. """ for node in reversed(self.listchain()): for mark in node.own_markers: if name is None or getattr(mark, "name", None) == name: yield node, mark - def get_closest_marker(self, name, default=None): - """return the first marker matching the name, from closest (for example function) to farther level (for example - module level). + @overload + def get_closest_marker(self, name: str) -> Optional[Mark]: + ... + + @overload + def get_closest_marker(self, name: str, default: Mark) -> Mark: + ... + + def get_closest_marker( + self, name: str, default: Optional[Mark] = None + ) -> Optional[Mark]: + """Return the first marker matching the name, from closest (for + example function) to farther level (for example module level). - :param default: fallback return value of no marker was found - :param name: name to filter by + :param default: Fallback return value if no marker was found. + :param name: Name to filter by. """ return next(self.iter_markers(name=name), default) - def listextrakeywords(self): - """ Return a set of all extra keywords in self and any parents.""" - extra_keywords = set() # type: Set[str] + def listextrakeywords(self) -> Set[str]: + """Return a set of all extra keywords in self and any parents.""" + extra_keywords: Set[str] = set() for item in self.listchain(): extra_keywords.update(item.extra_keyword_matches) return extra_keywords - def listnames(self): + def listnames(self) -> List[str]: return [x.name for x in self.listchain()] - def addfinalizer(self, fin): - """ register a function to be called when this node is finalized. + def addfinalizer(self, fin: Callable[[], object]) -> None: + """Register a function to be called when this node is finalized. This method can only be called when this node is active in a setup chain, for example during self.setup(). """ self.session._setupstate.addfinalizer(fin, self) - def getparent(self, cls): - """ get the next parent node (including ourself) - which is an instance of the given class""" - current = self # type: Optional[Node] + def getparent(self, cls: Type[_NodeType]) -> Optional[_NodeType]: + """Get the next parent node (including self) which is an instance of + the given class.""" + current: Optional[Node] = self while current and not isinstance(current, cls): current = current.parent + assert current is None or isinstance(current, cls) return current - def _prunetraceback(self, excinfo): + def _prunetraceback(self, excinfo: ExceptionInfo[BaseException]) -> None: pass def _repr_failure_py( - self, excinfo: ExceptionInfo[Union[Failed, FixtureLookupError]], style=None - ) -> Union[str, ReprExceptionInfo, ExceptionChainRepr, FixtureLookupErrorRepr]: + self, + excinfo: ExceptionInfo[BaseException], + style: "Optional[_TracebackStyle]" = None, + ) -> TerminalRepr: + from _pytest.fixtures import FixtureLookupError + + if isinstance(excinfo.value, ConftestImportFailure): + excinfo = ExceptionInfo(excinfo.value.excinfo) if isinstance(excinfo.value, fail.Exception): if not excinfo.value.pytrace: - return str(excinfo.value) + style = "value" if isinstance(excinfo.value, FixtureLookupError): return excinfo.value.formatrepr() if self.config.getoption("fulltrace", False): @@ -346,9 +384,14 @@ def _repr_failure_py( else: truncate_locals = True + # excinfo.getrepr() formats paths relative to the CWD if `abspath` is False. + # It is possible for a fixture/test to change the CWD while this code runs, which + # would then result in the user seeing confusing paths in the failure message. + # To fix this, if the CWD changed, always display the full absolute path. + # It will be better to just always display paths relative to invocation_dir, but + # this requires a lot of plumbing (#6428). try: - os.getcwd() - abspath = False + abspath = Path(os.getcwd()) != self.config.invocation_params.dir except OSError: abspath = True @@ -362,49 +405,59 @@ def _repr_failure_py( ) def repr_failure( - self, excinfo, style=None - ) -> Union[str, ReprExceptionInfo, ExceptionChainRepr, FixtureLookupErrorRepr]: + self, + excinfo: ExceptionInfo[BaseException], + style: "Optional[_TracebackStyle]" = None, + ) -> Union[str, TerminalRepr]: + """Return a representation of a collection or test failure. + + :param excinfo: Exception information for the failure. + """ return self._repr_failure_py(excinfo, style) def get_fslocation_from_item( - item: "Item", + node: "Node", ) -> Tuple[Union[str, py.path.local], Optional[int]]: - """Tries to extract the actual location from an item, depending on available attributes: + """Try to extract the actual location from a node, depending on available attributes: - * "fslocation": a pair (path, lineno) - * "obj": a Python object that the item wraps. + * "location": a pair (path, lineno) + * "obj": a Python object that the node wraps. * "fspath": just a path - :rtype: a tuple of (str|LocalPath, int) with filename and line number. + :rtype: A tuple of (str|py.path.local, int) with filename and line number. """ - try: - return item.location[:2] - except AttributeError: - pass - obj = getattr(item, "obj", None) + # See Item.location. + location: Optional[Tuple[str, Optional[int], str]] = getattr(node, "location", None) + if location is not None: + return location[:2] + obj = getattr(node, "obj", None) if obj is not None: return getfslineno(obj) - return getattr(item, "fspath", "unknown location"), -1 + return getattr(node, "fspath", "unknown location"), -1 class Collector(Node): - """ Collector instances create children through collect() - and thus iteratively build a tree. - """ + """Collector instances create children through collect() and thus + iteratively build a tree.""" class CollectError(Exception): - """ an error during collection, contains a custom message. """ + """An error during collection, contains a custom message.""" - def collect(self): - """ returns a list of children (items and collectors) - for this collection node. - """ + def collect(self) -> Iterable[Union["Item", "Collector"]]: + """Return a list of children (items and collectors) for this + collection node.""" raise NotImplementedError("abstract") - def repr_failure(self, excinfo): - """ represent a collection failure. """ - if excinfo.errisinstance(self.CollectError) and not self.config.getoption( + # TODO: This omits the style= parameter which breaks Liskov Substitution. + def repr_failure( # type: ignore[override] + self, excinfo: ExceptionInfo[BaseException] + ) -> Union[str, TerminalRepr]: + """Return a representation of a collection failure. + + :param excinfo: Exception information for the failure. + """ + if isinstance(excinfo.value, self.CollectError) and not self.config.getoption( "fulltrace", False ): exc = excinfo.value @@ -418,7 +471,7 @@ def repr_failure(self, excinfo): return self._repr_failure_py(excinfo, style=tbstyle) - def _prunetraceback(self, excinfo): + def _prunetraceback(self, excinfo: ExceptionInfo[BaseException]) -> None: if hasattr(self, "fspath"): traceback = excinfo.traceback ntraceback = traceback.cut(path=self.fspath) @@ -433,23 +486,14 @@ def _check_initialpaths_for_relpath(session, fspath): return fspath.relto(initial_path) -class FSHookProxy: - def __init__( - self, fspath: py.path.local, pm: PytestPluginManager, remove_mods - ) -> None: - self.fspath = fspath - self.pm = pm - self.remove_mods = remove_mods - - def __getattr__(self, name: str): - x = self.pm.subset_hook_caller(name, remove_plugins=self.remove_mods) - self.__dict__[name] = x - return x - - class FSCollector(Collector): def __init__( - self, fspath: py.path.local, parent=None, config=None, session=None, nodeid=None + self, + fspath: py.path.local, + parent=None, + config: Optional[Config] = None, + session: Optional["Session"] = None, + nodeid: Optional[str] = None, ) -> None: name = fspath.basename if parent is not None: @@ -471,91 +515,56 @@ def __init__( super().__init__(name, parent, config, session, nodeid=nodeid, fspath=fspath) - self._norecursepatterns = self.config.getini("norecursedirs") - @classmethod - def from_parent(cls, parent, *, fspath): - """ - The public constructor - """ - return super().from_parent(parent=parent, fspath=fspath) - - def _gethookproxy(self, fspath: py.path.local): - # check if we have the common case of running - # hooks with all conftest.py files - pm = self.config.pluginmanager - my_conftestmodules = pm._getconftestmodules(fspath) - remove_mods = pm._conftest_plugins.difference(my_conftestmodules) - if remove_mods: - # one or more conftests are not in use at this fspath - proxy = FSHookProxy(fspath, pm, remove_mods) - else: - # all plugins are active for this fspath - proxy = self.config.hook - return proxy - - def _recurse(self, dirpath: py.path.local) -> bool: - if dirpath.basename == "__pycache__": - return False - ihook = self._gethookproxy(dirpath.dirpath()) - if ihook.pytest_ignore_collect(path=dirpath, config=self.config): - return False - for pat in self._norecursepatterns: - if dirpath.check(fnmatch=pat): - return False - ihook = self._gethookproxy(dirpath) - ihook.pytest_collect_directory(path=dirpath, parent=self) - return True - - def _collectfile(self, path, handle_dupes=True): - assert ( - path.isfile() - ), "{!r} is not a file (isdir={!r}, exists={!r}, islink={!r})".format( - path, path.isdir(), path.exists(), path.islink() - ) - ihook = self.gethookproxy(path) - if not self.isinitpath(path): - if ihook.pytest_ignore_collect(path=path, config=self.config): - return () + def from_parent(cls, parent, *, fspath, **kw): + """The public constructor.""" + return super().from_parent(parent=parent, fspath=fspath, **kw) - if handle_dupes: - keepduplicates = self.config.getoption("keepduplicates") - if not keepduplicates: - duplicate_paths = self.config.pluginmanager._duplicatepaths - if path in duplicate_paths: - return () - else: - duplicate_paths.add(path) + def gethookproxy(self, fspath: py.path.local): + warnings.warn(FSCOLLECTOR_GETHOOKPROXY_ISINITPATH, stacklevel=2) + return self.session.gethookproxy(fspath) - return ihook.pytest_collect_file(path=path, parent=self) + def isinitpath(self, path: py.path.local) -> bool: + warnings.warn(FSCOLLECTOR_GETHOOKPROXY_ISINITPATH, stacklevel=2) + return self.session.isinitpath(path) class File(FSCollector): - """ base class for collecting tests from a file. """ + """Base class for collecting tests from a file. + + :ref:`non-python tests`. + """ class Item(Node): - """ a basic test invocation item. Note that for a single function - there might be multiple test invocation items. + """A basic test invocation item. + + Note that for a single function there might be multiple test invocation items. """ nextitem = None - def __init__(self, name, parent=None, config=None, session=None, nodeid=None): + def __init__( + self, + name, + parent=None, + config: Optional[Config] = None, + session: Optional["Session"] = None, + nodeid: Optional[str] = None, + ) -> None: super().__init__(name, parent, config, session, nodeid=nodeid) - self._report_sections = [] # type: List[Tuple[str, str, str]] + self._report_sections: List[Tuple[str, str, str]] = [] - #: user properties is a list of tuples (name, value) that holds user - #: defined properties for this test. - self.user_properties = [] # type: List[Tuple[str, Any]] + #: A list of tuples (name, value) that holds user defined properties + #: for this test. + self.user_properties: List[Tuple[str, object]] = [] def runtest(self) -> None: raise NotImplementedError("runtest must be implemented by Item subclass") def add_report_section(self, when: str, key: str, content: str) -> None: - """ - Adds a new report section, similar to what's done internally to add stdout and - stderr captured output:: + """Add a new report section, similar to what's done internally to add + stdout and stderr captured output:: item.add_report_section("call", "stdout", "report section contents") @@ -564,7 +573,6 @@ def add_report_section(self, when: str, key: str, content: str) -> None: :param str key: Name of the section, can be customized at will. Pytest uses ``"stdout"`` and ``"stderr"`` internally. - :param str content: The full contents as a string. """ @@ -577,10 +585,7 @@ def reportinfo(self) -> Tuple[Union[py.path.local, str], Optional[int], str]: @cached_property def location(self) -> Tuple[str, Optional[int], str]: location = self.reportinfo() - if isinstance(location[0], py.path.local): - fspath = location[0] - else: - fspath = py.path.local(location[0]) + fspath = absolutepath(str(location[0])) relfspath = self.session._node_location_to_relpath(fspath) assert type(location[2]) is str return (relfspath, location[1], location[2]) diff --git a/src/_pytest/nose.py b/src/_pytest/nose.py index d6f3c2b224a..bb8f99772ac 100644 --- a/src/_pytest/nose.py +++ b/src/_pytest/nose.py @@ -1,16 +1,17 @@ -""" run test suites written for nose. """ +"""Run testsuites written for nose.""" from _pytest import python from _pytest import unittest from _pytest.config import hookimpl +from _pytest.nodes import Item @hookimpl(trylast=True) def pytest_runtest_setup(item): if is_potential_nosetest(item): if not call_optional(item.obj, "setup"): - # call module level setup if there is no object level one + # Call module level setup if there is no object level one. call_optional(item.parent.obj, "setup") - # XXX this implies we only call teardown when setup worked + # XXX This implies we only call teardown when setup worked. item.session._setupstate.addfinalizer((lambda: teardown_nose(item)), item) @@ -20,9 +21,9 @@ def teardown_nose(item): call_optional(item.parent.obj, "teardown") -def is_potential_nosetest(item): - # extra check needed since we do not do nose style setup/teardown - # on direct unittest style classes +def is_potential_nosetest(item: Item) -> bool: + # Extra check needed since we do not do nose style setup/teardown + # on direct unittest style classes. return isinstance(item, python.Function) and not isinstance( item, unittest.TestCaseFunction ) @@ -33,6 +34,6 @@ def call_optional(obj, name): isfixture = hasattr(method, "_pytestfixturefunction") if method is not None and not isfixture and callable(method): # If there's any problems allow the exception to raise rather than - # silently ignoring them + # silently ignoring them. method() return True diff --git a/src/_pytest/outcomes.py b/src/_pytest/outcomes.py index bed73c94de9..f0607cbd849 100644 --- a/src/_pytest/outcomes.py +++ b/src/_pytest/outcomes.py @@ -1,21 +1,17 @@ -""" -exception classes and constants handling test outcomes -as well as functions creating them -""" +"""Exception classes and constants handling test outcomes as well as +functions creating them.""" import sys from typing import Any from typing import Callable from typing import cast from typing import Optional +from typing import Type from typing import TypeVar -from packaging.version import Version - -TYPE_CHECKING = False # avoid circular import through compat +TYPE_CHECKING = False # Avoid circular import through compat. if TYPE_CHECKING: from typing import NoReturn - from typing import Type # noqa: F401 (Used in string type annotation.) from typing_extensions import Protocol else: # typing.Protocol is only available starting from Python 3.8. It is also @@ -27,13 +23,12 @@ class OutcomeException(BaseException): - """ OutcomeException and its subclass instances indicate and - contain info about test and collection outcomes. - """ + """OutcomeException and its subclass instances indicate and contain info + about test and collection outcomes.""" def __init__(self, msg: Optional[str] = None, pytrace: bool = True) -> None: if msg is not None and not isinstance(msg, str): - error_msg = ( + error_msg = ( # type: ignore[unreachable] "{} expected string as 'msg' parameter, got '{}' instead.\n" "Perhaps you meant to use a mark?" ) @@ -45,7 +40,7 @@ def __init__(self, msg: Optional[str] = None, pytrace: bool = True) -> None: def __repr__(self) -> str: if self.msg: return self.msg - return "<{} instance>".format(self.__class__.__name__) + return f"<{self.__class__.__name__} instance>" __str__ = __repr__ @@ -69,13 +64,13 @@ def __init__( class Failed(OutcomeException): - """ raised from an explicit call to pytest.fail() """ + """Raised from an explicit call to pytest.fail().""" __module__ = "builtins" class Exit(Exception): - """ raised for immediate program exits (no tracebacks/summaries)""" + """Raised for immediate program exits (no tracebacks/summaries).""" def __init__( self, msg: str = "unknown reason", returncode: Optional[int] = None @@ -88,13 +83,13 @@ def __init__( # Elaborate hack to work around https://github.com/python/mypy/issues/2087. # Ideally would just be `exit.Exception = Exit` etc. -_F = TypeVar("_F", bound=Callable) -_ET = TypeVar("_ET", bound="Type[BaseException]") +_F = TypeVar("_F", bound=Callable[..., object]) +_ET = TypeVar("_ET", bound=Type[BaseException]) class _WithException(Protocol[_F, _ET]): - Exception = None # type: _ET - __call__ = None # type: _F + Exception: _ET + __call__: _F def _with_exception(exception_type: _ET) -> Callable[[_F], _WithException[_F, _ET]]: @@ -106,16 +101,15 @@ def decorate(func: _F) -> _WithException[_F, _ET]: return decorate -# exposed helper methods +# Exposed helper methods. @_with_exception(Exit) def exit(msg: str, returncode: Optional[int] = None) -> "NoReturn": - """ - Exit testing process. + """Exit testing process. - :param str msg: message to display upon exit. - :param int returncode: return code to be used when exiting pytest. + :param str msg: Message to display upon exit. + :param int returncode: Return code to be used when exiting pytest. """ __tracebackhide__ = True raise Exit(msg, returncode) @@ -123,20 +117,20 @@ def exit(msg: str, returncode: Optional[int] = None) -> "NoReturn": @_with_exception(Skipped) def skip(msg: str = "", *, allow_module_level: bool = False) -> "NoReturn": - """ - Skip an executing test with the given message. + """Skip an executing test with the given message. This function should be called only during testing (setup, call or teardown) or during collection by using the ``allow_module_level`` flag. This function can be called in doctests as well. - :kwarg bool allow_module_level: allows this function to be called at - module level, skipping the rest of the module. Default to False. + :param bool allow_module_level: + Allows this function to be called at module level, skipping the rest + of the module. Defaults to False. .. note:: - It is better to use the :ref:`pytest.mark.skipif ref` marker when possible to declare a test to be - skipped under certain conditions like mismatching platforms or - dependencies. + It is better to use the :ref:`pytest.mark.skipif ref` marker when + possible to declare a test to be skipped under certain conditions + like mismatching platforms or dependencies. Similarly, use the ``# doctest: +SKIP`` directive (see `doctest.SKIP `_) to skip a doctest statically. @@ -147,11 +141,12 @@ def skip(msg: str = "", *, allow_module_level: bool = False) -> "NoReturn": @_with_exception(Failed) def fail(msg: str = "", pytrace: bool = True) -> "NoReturn": - """ - Explicitly fail an executing test with the given message. + """Explicitly fail an executing test with the given message. - :param str msg: the message to show the user as reason for the failure. - :param bool pytrace: if false the msg represents the full failure information and no + :param str msg: + The message to show the user as reason for the failure. + :param bool pytrace: + If False, msg represents the full failure information and no python traceback will be reported. """ __tracebackhide__ = True @@ -159,19 +154,19 @@ def fail(msg: str = "", pytrace: bool = True) -> "NoReturn": class XFailed(Failed): - """ raised from an explicit call to pytest.xfail() """ + """Raised from an explicit call to pytest.xfail().""" @_with_exception(XFailed) def xfail(reason: str = "") -> "NoReturn": - """ - Imperatively xfail an executing test or setup functions with the given reason. + """Imperatively xfail an executing test or setup function with the given reason. This function should be called only during testing (setup, call or teardown). .. note:: - It is better to use the :ref:`pytest.mark.xfail ref` marker when possible to declare a test to be - xfailed under certain conditions like known bugs or missing features. + It is better to use the :ref:`pytest.mark.xfail ref` marker when + possible to declare a test to be xfailed under certain conditions + like known bugs or missing features. """ __tracebackhide__ = True raise XFailed(reason) @@ -180,17 +175,20 @@ def xfail(reason: str = "") -> "NoReturn": def importorskip( modname: str, minversion: Optional[str] = None, reason: Optional[str] = None ) -> Any: - """Imports and returns the requested module ``modname``, or skip the + """Import and return the requested module ``modname``, or skip the current test if the module cannot be imported. - :param str modname: the name of the module to import - :param str minversion: if given, the imported module's ``__version__`` - attribute must be at least this minimal version, otherwise the test is - still skipped. - :param str reason: if given, this reason is shown as the message when the - module cannot be imported. - :returns: The imported module. This should be assigned to its canonical - name. + :param str modname: + The name of the module to import. + :param str minversion: + If given, the imported module's ``__version__`` attribute must be at + least this minimal version, otherwise the test is still skipped. + :param str reason: + If given, this reason is shown as the message when the module cannot + be imported. + + :returns: + The imported module. This should be assigned to its canonical name. Example:: @@ -202,21 +200,24 @@ def importorskip( compile(modname, "", "eval") # to catch syntaxerrors with warnings.catch_warnings(): - # make sure to ignore ImportWarnings that might happen because + # Make sure to ignore ImportWarnings that might happen because # of existing directories with the same name we're trying to - # import but without a __init__.py file + # import but without a __init__.py file. warnings.simplefilter("ignore") try: __import__(modname) except ImportError as exc: if reason is None: - reason = "could not import {!r}: {}".format(modname, exc) + reason = f"could not import {modname!r}: {exc}" raise Skipped(reason, allow_module_level=True) from None mod = sys.modules[modname] if minversion is None: return mod verattr = getattr(mod, "__version__", None) if minversion is not None: + # Imported lazily to improve start-up time. + from packaging.version import Version + if verattr is None or Version(verattr) < Version(minversion): raise Skipped( "module %r has __version__ %r, required is: %r" diff --git a/src/_pytest/pastebin.py b/src/_pytest/pastebin.py index 3f4a7502d59..131873c174a 100644 --- a/src/_pytest/pastebin.py +++ b/src/_pytest/pastebin.py @@ -1,15 +1,21 @@ -""" submit failure or test session information to a pastebin service. """ +"""Submit failure or test session information to a pastebin service.""" import tempfile +from io import StringIO from typing import IO +from typing import Union import pytest +from _pytest.config import Config +from _pytest.config import create_terminal_writer +from _pytest.config.argparsing import Parser from _pytest.store import StoreKey +from _pytest.terminal import TerminalReporter pastebinfile_key = StoreKey[IO[bytes]]() -def pytest_addoption(parser): +def pytest_addoption(parser: Parser) -> None: group = parser.getgroup("terminal reporting") group._addoption( "--pastebin", @@ -23,14 +29,14 @@ def pytest_addoption(parser): @pytest.hookimpl(trylast=True) -def pytest_configure(config): +def pytest_configure(config: Config) -> None: if config.option.pastebin == "all": tr = config.pluginmanager.getplugin("terminalreporter") - # if no terminal reporter plugin is present, nothing we can do here; - # this can happen when this function executes in a slave node - # when using pytest-xdist, for example + # If no terminal reporter plugin is present, nothing we can do here; + # this can happen when this function executes in a worker node + # when using pytest-xdist, for example. if tr is not None: - # pastebin file will be utf-8 encoded binary file + # pastebin file will be UTF-8 encoded binary file. config._store[pastebinfile_key] = tempfile.TemporaryFile("w+b") oldwrite = tr._tw.write @@ -43,29 +49,28 @@ def tee_write(s, **kwargs): tr._tw.write = tee_write -def pytest_unconfigure(config): +def pytest_unconfigure(config: Config) -> None: if pastebinfile_key in config._store: pastebinfile = config._store[pastebinfile_key] - # get terminal contents and delete file + # Get terminal contents and delete file. pastebinfile.seek(0) sessionlog = pastebinfile.read() pastebinfile.close() del config._store[pastebinfile_key] - # undo our patching in the terminal reporter + # Undo our patching in the terminal reporter. tr = config.pluginmanager.getplugin("terminalreporter") del tr._tw.__dict__["write"] - # write summary + # Write summary. tr.write_sep("=", "Sending information to Paste Service") pastebinurl = create_new_paste(sessionlog) tr.write_line("pastebin session-log: %s\n" % pastebinurl) -def create_new_paste(contents): - """ - Creates a new paste using bpaste.net service. +def create_new_paste(contents: Union[str, bytes]) -> str: + """Create a new paste using the bpaste.net service. - :contents: paste contents as utf-8 encoded bytes - :returns: url to the pasted contents or error message + :contents: Paste contents string. + :returns: URL to the pasted contents, or an error message. """ import re from urllib.request import urlopen @@ -74,7 +79,7 @@ def create_new_paste(contents): params = {"code": contents, "lexer": "text", "expiry": "1week"} url = "https://bpaste.net" try: - response = ( + response: str = ( urlopen(url, data=urlencode(params).encode("ascii")).read().decode("utf-8") ) except OSError as exc_info: # urllib errors @@ -86,24 +91,20 @@ def create_new_paste(contents): return "bad response: invalid format ('" + response + "')" -def pytest_terminal_summary(terminalreporter): - import _pytest.config - +def pytest_terminal_summary(terminalreporter: TerminalReporter) -> None: if terminalreporter.config.option.pastebin != "failed": return - tr = terminalreporter - if "failed" in tr.stats: + if "failed" in terminalreporter.stats: terminalreporter.write_sep("=", "Sending information to Paste Service") - for rep in terminalreporter.stats.get("failed"): + for rep in terminalreporter.stats["failed"]: try: msg = rep.longrepr.reprtraceback.reprentries[-1].reprfileloc except AttributeError: - msg = tr._getfailureheadline(rep) - tw = _pytest.config.create_terminal_writer( - terminalreporter.config, stringio=True - ) + msg = terminalreporter._getfailureheadline(rep) + file = StringIO() + tw = create_terminal_writer(terminalreporter.config, file) rep.toterminal(tw) - s = tw.stringio.getvalue() + s = file.getvalue() assert len(s) pastebinurl = create_new_paste(s) - tr.write_line("{} --> {}".format(msg, pastebinurl)) + terminalreporter.write_line(f"{msg} --> {pastebinurl}") diff --git a/src/_pytest/pathlib.py b/src/_pytest/pathlib.py index 8d25b21dd7d..8875a28f84b 100644 --- a/src/_pytest/pathlib.py +++ b/src/_pytest/pathlib.py @@ -1,37 +1,63 @@ import atexit +import contextlib import fnmatch +import importlib.util import itertools import os import shutil import sys import uuid import warnings +from enum import Enum +from errno import EBADF +from errno import ELOOP +from errno import ENOENT +from errno import ENOTDIR from functools import partial from os.path import expanduser from os.path import expandvars from os.path import isabs from os.path import sep +from pathlib import Path +from pathlib import PurePath from posixpath import sep as posix_sep +from types import ModuleType +from typing import Callable from typing import Iterable from typing import Iterator +from typing import Optional from typing import Set from typing import TypeVar from typing import Union +import py + +from _pytest.compat import assert_never +from _pytest.outcomes import skip from _pytest.warning_types import PytestWarning -if sys.version_info[:2] >= (3, 6): - from pathlib import Path, PurePath -else: - from pathlib2 import Path, PurePath +LOCK_TIMEOUT = 60 * 60 * 24 * 3 -__all__ = ["Path", "PurePath"] +_AnyPurePath = TypeVar("_AnyPurePath", bound=PurePath) -LOCK_TIMEOUT = 60 * 60 * 3 +# The following function, variables and comments were +# copied from cpython 3.9 Lib/pathlib.py file. +# EBADF - guard against macOS `stat` throwing EBADF +_IGNORED_ERRORS = (ENOENT, ENOTDIR, EBADF, ELOOP) -_AnyPurePath = TypeVar("_AnyPurePath", bound=PurePath) +_IGNORED_WINERRORS = ( + 21, # ERROR_NOT_READY - drive exists but is not accessible + 1921, # ERROR_CANT_RESOLVE_FILENAME - fix for broken symlink pointing to itself +) + + +def _ignore_error(exception): + return ( + getattr(exception, "errno", None) in _IGNORED_ERRORS + or getattr(exception, "winerror", None) in _IGNORED_WINERRORS + ) def get_lock_path(path: _AnyPurePath) -> _AnyPurePath: @@ -39,31 +65,27 @@ def get_lock_path(path: _AnyPurePath) -> _AnyPurePath: def ensure_reset_dir(path: Path) -> None: - """ - ensures the given path is an empty directory - """ + """Ensure the given path is an empty directory.""" if path.exists(): rm_rf(path) path.mkdir() def on_rm_rf_error(func, path: str, exc, *, start_path: Path) -> bool: - """Handles known read-only errors during rmtree. + """Handle known read-only errors during rmtree. The returned value is used only by our own tests. """ exctype, excvalue = exc[:2] - # 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 + # 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): return False if not isinstance(excvalue, PermissionError): warnings.warn( - PytestWarning( - "(rm_rf) error removing {}\n{}: {}".format(path, exctype, excvalue) - ) + PytestWarning(f"(rm_rf) error removing {path}\n{exctype}: {excvalue}") ) return False @@ -91,7 +113,7 @@ def chmod_rw(p: str) -> None: if p.is_file(): for parent in p.parents: chmod_rw(str(parent)) - # stop when we reach the original path passed to rm_rf + # Stop when we reach the original path passed to rm_rf. if parent == start_path: break chmod_rw(str(path)) @@ -100,16 +122,46 @@ def chmod_rw(p: str) -> None: return True +def ensure_extended_length_path(path: Path) -> Path: + """Get the extended-length version of a path (Windows). + + On Windows, by default, the maximum length of a path (MAX_PATH) is 260 + characters, and operations on paths longer than that fail. But it is possible + to overcome this by converting the path to "extended-length" form before + performing the operation: + https://docs.microsoft.com/en-us/windows/win32/fileio/naming-a-file#maximum-path-length-limitation + + On Windows, this function returns the extended-length absolute version of path. + On other platforms it returns path unchanged. + """ + if sys.platform.startswith("win32"): + path = path.resolve() + path = Path(get_extended_length_path_str(str(path))) + return path + + +def get_extended_length_path_str(path: str) -> str: + """Convert a path to a Windows extended length path.""" + long_path_prefix = "\\\\?\\" + unc_long_path_prefix = "\\\\?\\UNC\\" + if path.startswith((long_path_prefix, unc_long_path_prefix)): + return path + # UNC + if path.startswith("\\\\"): + return unc_long_path_prefix + path[2:] + return long_path_prefix + path + + def rm_rf(path: Path) -> None: """Remove the path contents recursively, even if some elements - are read-only. - """ + are read-only.""" + path = ensure_extended_length_path(path) onerror = partial(on_rm_rf_error, start_path=path) shutil.rmtree(str(path), onerror=onerror) def find_prefixed(root: Path, prefix: str) -> Iterator[Path]: - """finds all elements in root that begin with the prefix, case insensitive""" + """Find all elements in root that begin with the prefix, case insensitive.""" l_prefix = prefix.lower() for x in root.iterdir(): if x.name.lower().startswith(l_prefix): @@ -117,10 +169,10 @@ def find_prefixed(root: Path, prefix: str) -> Iterator[Path]: def extract_suffixes(iter: Iterable[PurePath], prefix: str) -> Iterator[str]: - """ - :param iter: iterator over path names - :param prefix: expected prefix of the path names - :returns: the parts of the paths following the prefix + """Return the parts of the paths following the prefix. + + :param iter: Iterator over path names. + :param prefix: Expected prefix of the path names. """ p_len = len(prefix) for p in iter: @@ -128,13 +180,12 @@ def extract_suffixes(iter: Iterable[PurePath], prefix: str) -> Iterator[str]: def find_suffixes(root: Path, prefix: str) -> Iterator[str]: - """combines find_prefixes and extract_suffixes - """ + """Combine find_prefixes and extract_suffixes.""" return extract_suffixes(find_prefixed(root, prefix), prefix) def parse_num(maybe_num) -> int: - """parses number path suffixes, returns -1 on error""" + """Parse number path suffixes, returns -1 on error.""" try: return int(maybe_num) except ValueError: @@ -144,13 +195,13 @@ def parse_num(maybe_num) -> int: def _force_symlink( root: Path, target: Union[str, PurePath], link_to: Union[str, Path] ) -> None: - """helper to create the current symlink + """Helper to create the current symlink. - it's full of race conditions that are reasonably ok to ignore - for the context of best effort linking to the latest test run + It's full of race conditions that are reasonably OK to ignore + for the context of best effort linking to the latest test run. - the presumption being that in case of much parallelism - the inaccuracy is going to be acceptable + The presumption being that in case of much parallelism + the inaccuracy is going to be acceptable. """ current_symlink = root.joinpath(target) try: @@ -164,12 +215,12 @@ def _force_symlink( def make_numbered_dir(root: Path, prefix: str) -> Path: - """create a directory with an increased number as suffix for the given prefix""" + """Create a directory with an increased number as suffix for the given prefix.""" for i in range(10): # try up to 10 times to create the folder max_existing = max(map(parse_num, find_suffixes(root, prefix)), default=-1) new_number = max_existing + 1 - new_path = root.joinpath("{}{}".format(prefix, new_number)) + new_path = root.joinpath(f"{prefix}{new_number}") try: new_path.mkdir() except Exception: @@ -178,31 +229,31 @@ def make_numbered_dir(root: Path, prefix: str) -> Path: _force_symlink(root, prefix + "current", new_path) return new_path else: - raise EnvironmentError( + raise OSError( "could not create numbered dir with prefix " "{prefix} in {root} after 10 tries".format(prefix=prefix, root=root) ) def create_cleanup_lock(p: Path) -> Path: - """crates a lock to prevent premature folder cleanup""" + """Create a lock to prevent premature folder cleanup.""" lock_path = get_lock_path(p) try: fd = os.open(str(lock_path), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o644) except FileExistsError as e: - raise EnvironmentError("cannot create lockfile in {path}".format(path=p)) from e + raise OSError(f"cannot create lockfile in {p}") from e else: pid = os.getpid() spid = str(pid).encode() os.write(fd, spid) os.close(fd) if not lock_path.is_file(): - raise EnvironmentError("lock path got renamed after successful creation") + raise OSError("lock path got renamed after successful creation") return lock_path def register_cleanup_lock_removal(lock_path: Path, register=atexit.register): - """registers a cleanup function for removing a lock, by default on atexit""" + """Register a cleanup function for removing a lock, by default on atexit.""" pid = os.getpid() def cleanup_on_exit(lock_path: Path = lock_path, original_pid: int = pid) -> None: @@ -212,65 +263,76 @@ def cleanup_on_exit(lock_path: Path = lock_path, original_pid: int = pid) -> Non return try: lock_path.unlink() - except (OSError, IOError): + except OSError: pass return register(cleanup_on_exit) def maybe_delete_a_numbered_dir(path: Path) -> None: - """removes a numbered directory if its lock can be obtained and it does not seem to be in use""" + """Remove a numbered directory if its lock can be obtained and it does + not seem to be in use.""" + path = ensure_extended_length_path(path) lock_path = None try: lock_path = create_cleanup_lock(path) parent = path.parent - garbage = parent.joinpath("garbage-{}".format(uuid.uuid4())) + garbage = parent.joinpath(f"garbage-{uuid.uuid4()}") path.rename(garbage) rm_rf(garbage) - except (OSError, EnvironmentError): + except OSError: # known races: # * other process did a cleanup at the same time # * deletable folder was found # * process cwd (Windows) return finally: - # if we created the lock, ensure we remove it even if we failed - # to properly remove the numbered dir + # If we created the lock, ensure we remove it even if we failed + # to properly remove the numbered dir. if lock_path is not None: try: lock_path.unlink() - except (OSError, IOError): + except OSError: pass def ensure_deletable(path: Path, consider_lock_dead_if_created_before: float) -> bool: - """checks if a lock exists and breaks it if its considered dead""" + """Check if `path` is deletable based on whether the lock file is expired.""" if path.is_symlink(): return False lock = get_lock_path(path) - if not lock.exists(): - return True + try: + if not lock.is_file(): + return True + except OSError: + # we might not have access to the lock file at all, in this case assume + # we don't have access to the entire directory (#7491). + return False try: lock_time = lock.stat().st_mtime except Exception: return False else: if lock_time < consider_lock_dead_if_created_before: - lock.unlink() - return True - else: - return False + # We want to ignore any errors while trying to remove the lock such as: + # - PermissionDenied, like the file permissions have changed since the lock creation; + # - FileNotFoundError, in case another pytest process got here first; + # and any other cause of failure. + with contextlib.suppress(OSError): + lock.unlink() + return True + return False def try_cleanup(path: Path, consider_lock_dead_if_created_before: float) -> None: - """tries to cleanup a folder if we can ensure it's deletable""" + """Try to cleanup a folder if we can ensure it's deletable.""" if ensure_deletable(path, consider_lock_dead_if_created_before): maybe_delete_a_numbered_dir(path) def cleanup_candidates(root: Path, prefix: str, keep: int) -> Iterator[Path]: - """lists candidates for numbered directories to be removed - follows py.path""" + """List candidates for numbered directories to be removed - follows py.path.""" max_existing = max(map(parse_num, find_suffixes(root, prefix)), default=-1) max_delete = max_existing - keep paths = find_prefixed(root, prefix) @@ -284,7 +346,7 @@ def cleanup_candidates(root: Path, prefix: str, keep: int) -> Iterator[Path]: def cleanup_numbered_dir( root: Path, prefix: str, keep: int, consider_lock_dead_if_created_before: float ) -> None: - """cleanup for lock driven numbered directories""" + """Cleanup for lock driven numbered directories.""" for path in cleanup_candidates(root, prefix, keep): try_cleanup(path, consider_lock_dead_if_created_before) for path in root.glob("garbage-*"): @@ -294,7 +356,7 @@ def cleanup_numbered_dir( def make_numbered_dir_with_cleanup( root: Path, prefix: str, keep: int, lock_timeout: float ) -> Path: - """creates a numbered dir with a cleanup lock and removes old ones""" + """Create a numbered dir with a cleanup lock and remove old ones.""" e = None for i in range(10): try: @@ -305,40 +367,41 @@ def make_numbered_dir_with_cleanup( e = exc else: consider_lock_dead_if_created_before = p.stat().st_mtime - lock_timeout - cleanup_numbered_dir( - root=root, - prefix=prefix, - keep=keep, - consider_lock_dead_if_created_before=consider_lock_dead_if_created_before, + # Register a cleanup for program exit + atexit.register( + cleanup_numbered_dir, + root, + prefix, + keep, + consider_lock_dead_if_created_before, ) return p assert e is not None raise e -def resolve_from_str(input, root): - assert not isinstance(input, Path), "would break on py2" - root = Path(root) +def resolve_from_str(input: str, rootpath: Path) -> Path: input = expanduser(input) input = expandvars(input) if isabs(input): return Path(input) else: - return root.joinpath(input) + return rootpath.joinpath(input) def fnmatch_ex(pattern: str, path) -> bool: - """FNMatcher port from py.path.common which works with PurePath() instances. + """A port of FNMatcher from py.path.common which works with PurePath() instances. - The difference between this algorithm and PurePath.match() is that the latter matches "**" glob expressions - for each part of the path, while this algorithm uses the whole path instead. + The difference between this algorithm and PurePath.match() is that the + latter matches "**" glob expressions for each part of the path, while + this algorithm uses the whole path instead. For example: - "tests/foo/bar/doc/test_foo.py" matches pattern "tests/**/doc/test*.py" with this algorithm, but not with - PurePath.match(). + "tests/foo/bar/doc/test_foo.py" matches pattern "tests/**/doc/test*.py" + with this algorithm, but not with PurePath.match(). - This algorithm was ported to keep backward-compatibility with existing settings which assume paths match according - this logic. + This algorithm was ported to keep backward-compatibility with existing + settings which assume paths match according this logic. References: * https://bugs.python.org/issue29249 @@ -358,10 +421,241 @@ def fnmatch_ex(pattern: str, path) -> bool: else: name = str(path) if path.is_absolute() and not os.path.isabs(pattern): - pattern = "*{}{}".format(os.sep, pattern) + pattern = f"*{os.sep}{pattern}" return fnmatch.fnmatch(name, pattern) def parts(s: str) -> Set[str]: parts = s.split(sep) return {sep.join(parts[: i + 1]) or sep for i in range(len(parts))} + + +def symlink_or_skip(src, dst, **kwargs): + """Make a symlink, or skip the test in case symlinks are not supported.""" + try: + os.symlink(str(src), str(dst), **kwargs) + except OSError as e: + skip(f"symlinks not supported: {e}") + + +class ImportMode(Enum): + """Possible values for `mode` parameter of `import_path`.""" + + prepend = "prepend" + append = "append" + importlib = "importlib" + + +class ImportPathMismatchError(ImportError): + """Raised on import_path() if there is a mismatch of __file__'s. + + This can happen when `import_path` is called multiple times with different filenames that has + the same basename but reside in packages + (for example "/tests1/test_foo.py" and "/tests2/test_foo.py"). + """ + + +def import_path( + p: Union[str, py.path.local, Path], + *, + mode: Union[str, ImportMode] = ImportMode.prepend, +) -> ModuleType: + """Import and return a module from the given path, which can be a file (a module) or + a directory (a package). + + The import mechanism used is controlled by the `mode` parameter: + + * `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__. + + * `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. + + :raises ImportPathMismatchError: + If after importing the given `path` and the module `__file__` + are different. Only raised in `prepend` and `append` modes. + """ + mode = ImportMode(mode) + + path = Path(str(p)) + + if not path.exists(): + raise ImportError(path) + + if mode is ImportMode.importlib: + module_name = path.stem + + for meta_importer in sys.meta_path: + spec = meta_importer.find_spec(module_name, [str(path.parent)]) + if spec is not None: + break + else: + spec = importlib.util.spec_from_file_location(module_name, str(path)) + + if spec is None: + raise ImportError( + "Can't find module {} at location {}".format(module_name, str(path)) + ) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) # type: ignore[union-attr] + return mod + + pkg_path = resolve_package_path(path) + if pkg_path is not None: + pkg_root = pkg_path.parent + names = list(path.with_suffix("").relative_to(pkg_root).parts) + if names[-1] == "__init__": + names.pop() + module_name = ".".join(names) + else: + pkg_root = path.parent + module_name = path.stem + + # Change sys.path permanently: restoring it at the end of this function would cause surprising + # problems because of delayed imports: for example, a conftest.py file imported by this function + # might have local imports, which would fail at runtime if we restored sys.path. + if mode is ImportMode.append: + if str(pkg_root) not in sys.path: + sys.path.append(str(pkg_root)) + elif mode is ImportMode.prepend: + if str(pkg_root) != sys.path[0]: + sys.path.insert(0, str(pkg_root)) + else: + assert_never(mode) + + importlib.import_module(module_name) + + mod = sys.modules[module_name] + if path.name == "__init__.py": + return mod + + ignore = os.environ.get("PY_IGNORE_IMPORTMISMATCH", "") + if ignore != "1": + module_file = mod.__file__ + 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"))] + + try: + is_same = _is_same(str(path), module_file) + except FileNotFoundError: + is_same = False + + if not is_same: + raise ImportPathMismatchError(module_name, module_file, path) + + return mod + + +# Implement a special _is_same function on Windows which returns True if the two filenames +# compare equal, to circumvent os.path.samefile returning False for mounts in UNC (#7678). +if sys.platform.startswith("win"): + + def _is_same(f1: str, f2: str) -> bool: + return Path(f1) == Path(f2) or os.path.samefile(f1, f2) + + +else: + + def _is_same(f1: str, f2: str) -> bool: + return os.path.samefile(f1, f2) + + +def resolve_package_path(path: Path) -> Optional[Path]: + """Return the Python package path by looking for the last + directory upwards which still contains an __init__.py. + + Returns None if it can not be determined. + """ + result = None + for parent in itertools.chain((path,), path.parents): + if parent.is_dir(): + if not parent.joinpath("__init__.py").is_file(): + break + if not parent.name.isidentifier(): + break + result = parent + return result + + +def visit( + path: str, recurse: Callable[["os.DirEntry[str]"], bool] +) -> Iterator["os.DirEntry[str]"]: + """Walk a directory recursively, in breadth-first order. + + 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) + + yield from entries + + for entry in entries: + if entry.is_dir() and recurse(entry): + yield from visit(entry.path, recurse) + + +def absolutepath(path: Union[Path, str]) -> Path: + """Convert a path to an absolute path using os.path.abspath. + + Prefer this over Path.resolve() (see #6523). + Prefer this over Path.absolute() (not public, doesn't normalize). + """ + return Path(os.path.abspath(str(path))) + + +def commonpath(path1: Path, path2: Path) -> Optional[Path]: + """Return the common part shared with the other path, or None if there is + no common part. + + If one path is relative and one is absolute, returns None. + """ + try: + return Path(os.path.commonpath((str(path1), str(path2)))) + except ValueError: + return None + + +def bestrelpath(directory: Path, dest: Path) -> str: + """Return a string which is a relative path from directory to dest such + that directory/bestrelpath == dest. + + The paths must be either both absolute or both relative. + + If no such path can be determined, returns dest. + """ + if dest == directory: + return os.curdir + # Find the longest common directory. + base = commonpath(directory, dest) + # Can be the case on Windows for two absolute paths on different drives. + # Can be the case for two relative paths without common prefix. + # Can be the case for a relative path and an absolute path. + if not base: + return str(dest) + reldirectory = directory.relative_to(base) + reldest = dest.relative_to(base) + return os.path.join( + # Back from directory to base. + *([os.pardir] * len(reldirectory.parts)), + # Forward from base to dest. + *reldest.parts, + ) diff --git a/src/_pytest/py.typed b/src/_pytest/py.typed new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/_pytest/pytester.py b/src/_pytest/pytester.py index 9df3ed779d5..6833eb02149 100644 --- a/src/_pytest/pytester.py +++ b/src/_pytest/pytester.py @@ -1,57 +1,84 @@ -"""(disabled by default) support for testing pytest and pytest plugins.""" +"""(Disabled by default) support for testing pytest and pytest plugins. + +PYTEST_DONT_REWRITE +""" import collections.abc +import contextlib import gc import importlib import os import platform import re +import shutil import subprocess import sys -import time import traceback from fnmatch import fnmatch from io import StringIO +from pathlib import Path +from typing import Any from typing import Callable from typing import Dict +from typing import Generator from typing import Iterable from typing import List from typing import Optional +from typing import overload from typing import Sequence +from typing import TextIO from typing import Tuple +from typing import Type +from typing import TYPE_CHECKING from typing import Union from weakref import WeakKeyDictionary +import attr import py +from iniconfig import IniConfig +from iniconfig import SectionWrapper -import pytest +from _pytest import timing from _pytest._code import Source -from _pytest.capture import MultiCapture -from _pytest.capture import SysCapture -from _pytest.compat import TYPE_CHECKING +from _pytest.capture import _get_multicapture +from _pytest.compat import final from _pytest.config import _PluggyPlugin +from _pytest.config import Config from _pytest.config import ExitCode +from _pytest.config import hookimpl +from _pytest.config import main +from _pytest.config import PytestPluginManager +from _pytest.config.argparsing import Parser +from _pytest.deprecated import check_ispytest +from _pytest.fixtures import fixture from _pytest.fixtures import FixtureRequest from _pytest.main import Session from _pytest.monkeypatch import MonkeyPatch from _pytest.nodes import Collector from _pytest.nodes import Item -from _pytest.pathlib import Path -from _pytest.python import Module +from _pytest.outcomes import fail +from _pytest.outcomes import importorskip +from _pytest.outcomes import skip +from _pytest.pathlib import make_numbered_dir +from _pytest.reports import CollectReport from _pytest.reports import TestReport -from _pytest.tmpdir import TempdirFactory +from _pytest.tmpdir import TempPathFactory +from _pytest.warning_types import PytestWarning if TYPE_CHECKING: - from typing import Type + from typing_extensions import Literal import pexpect +pytest_plugins = ["pytester_assertions"] + + IGNORE_PAM = [ # filenames added when obtaining details about the current user "/var/lib/sss/mc/passwd" ] -def pytest_addoption(parser): +def pytest_addoption(parser: Parser) -> None: parser.addoption( "--lsof", action="store_true", @@ -76,7 +103,7 @@ def pytest_addoption(parser): ) -def pytest_configure(config): +def pytest_configure(config: Config) -> None: if config.getvalue("lsof"): checker = LsofFdLeakChecker() if checker.matching_platform(): @@ -90,21 +117,16 @@ def pytest_configure(config): class LsofFdLeakChecker: - def get_open_files(self): - out = self._exec_lsof() - open_files = self._parse_lsof_output(out) - return open_files - - def _exec_lsof(self): - pid = os.getpid() - # py3: use subprocess.DEVNULL directly. - with open(os.devnull, "wb") as devnull: - return subprocess.check_output( - ("lsof", "-Ffn0", "-p", str(pid)), stderr=devnull - ).decode() - - def _parse_lsof_output(self, out): - def isopen(line): + def get_open_files(self) -> List[Tuple[str, str]]: + out = subprocess.run( + ("lsof", "-Ffn0", "-p", str(os.getpid())), + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + check=True, + universal_newlines=True, + ).stdout + + def isopen(line: str) -> bool: return line.startswith("f") and ( "deleted" not in line and "mem" not in line @@ -126,16 +148,16 @@ def isopen(line): return open_files - def matching_platform(self): + def matching_platform(self) -> bool: try: - subprocess.check_output(("lsof", "-v")) + subprocess.run(("lsof", "-v"), check=True) except (OSError, subprocess.CalledProcessError): return False else: return True - @pytest.hookimpl(hookwrapper=True, tryfirst=True) - def pytest_runtest_protocol(self, item): + @hookimpl(hookwrapper=True, tryfirst=True) + def pytest_runtest_protocol(self, item: Item) -> Generator[None, None, None]: lines1 = self.get_open_files() yield if hasattr(sys, "pypy_version_info"): @@ -145,61 +167,60 @@ def pytest_runtest_protocol(self, item): new_fds = {t[0] for t in lines2} - {t[0] for t in lines1} leaked_files = [t for t in lines2 if t[0] in new_fds] if leaked_files: - error = [] - error.append("***** %s FD leakage detected" % len(leaked_files)) - error.extend([str(f) for f in leaked_files]) - error.append("*** Before:") - error.extend([str(f) for f in lines1]) - error.append("*** After:") - error.extend([str(f) for f in lines2]) - error.append(error[0]) - error.append("*** function %s:%s: %s " % item.location) - error.append("See issue #2366") - item.warn(pytest.PytestWarning("\n".join(error))) + error = [ + "***** %s FD leakage detected" % len(leaked_files), + *(str(f) for f in leaked_files), + "*** Before:", + *(str(f) for f in lines1), + "*** After:", + *(str(f) for f in lines2), + "***** %s FD leakage detected" % len(leaked_files), + "*** function %s:%s: %s " % item.location, + "See issue #2366", + ] + item.warn(PytestWarning("\n".join(error))) # used at least by pytest-xdist plugin -@pytest.fixture +@fixture def _pytest(request: FixtureRequest) -> "PytestArg": """Return a helper which offers a gethookrecorder(hook) method which returns a HookRecorder instance which helps to make assertions about called - hooks. - - """ + hooks.""" return PytestArg(request) class PytestArg: def __init__(self, request: FixtureRequest) -> None: - self.request = request + self._request = request def gethookrecorder(self, hook) -> "HookRecorder": hookrecorder = HookRecorder(hook._pm) - self.request.addfinalizer(hookrecorder.finish_recording) + self._request.addfinalizer(hookrecorder.finish_recording) return hookrecorder -def get_public_names(values): +def get_public_names(values: Iterable[str]) -> List[str]: """Only return names from iterator values without a leading underscore.""" return [x for x in values if x[0] != "_"] class ParsedCall: - def __init__(self, name, kwargs): + def __init__(self, name: str, kwargs) -> None: self.__dict__.update(kwargs) self._name = name - def __repr__(self): + def __repr__(self) -> str: d = self.__dict__.copy() del d["_name"] - return "".format(self._name, d) + return f"" if TYPE_CHECKING: # The class has undetermined attributes, this tells mypy about it. - def __getattr__(self, key): - raise NotImplementedError() + def __getattr__(self, key: str): + ... class HookRecorder: @@ -207,12 +228,12 @@ class HookRecorder: This wraps all the hook calls in the plugin manager, recording each call before propagating the normal calls. - """ - def __init__(self, pluginmanager) -> None: + def __init__(self, pluginmanager: PytestPluginManager) -> None: self._pluginmanager = pluginmanager - self.calls = [] # type: List[ParsedCall] + self.calls: List[ParsedCall] = [] + self.ret: Optional[Union[int, ExitCode]] = None def before(hook_name: str, hook_impls, kwargs) -> None: self.calls.append(ParsedCall(hook_name, kwargs)) @@ -230,7 +251,7 @@ def getcalls(self, names: Union[str, Iterable[str]]) -> List[ParsedCall]: names = names.split() return [call for call in self.calls if call._name in names] - def assert_contains(self, entries) -> None: + def assert_contains(self, entries: Sequence[Tuple[str, str]]) -> None: __tracebackhide__ = True i = 0 entries = list(entries) @@ -249,7 +270,7 @@ def assert_contains(self, entries) -> None: break print("NONAMEMATCH", name, "with", call) else: - pytest.fail("could not find {!r} check {!r}".format(name, check)) + fail(f"could not find {name!r} check {check!r}") def popcall(self, name: str) -> ParsedCall: __tracebackhide__ = True @@ -257,9 +278,9 @@ def popcall(self, name: str) -> ParsedCall: if call._name == name: del self.calls[i] return call - lines = ["could not find call {!r}, in:".format(name)] + lines = [f"could not find call {name!r}, in:"] lines.extend([" %s" % x for x in self.calls]) - pytest.fail("\n".join(lines)) + fail("\n".join(lines)) def getcall(self, name: str) -> ParsedCall: values = self.getcalls(name) @@ -268,23 +289,47 @@ def getcall(self, name: str) -> ParsedCall: # functionality for test reports + @overload + def getreports( + self, names: "Literal['pytest_collectreport']", + ) -> Sequence[CollectReport]: + ... + + @overload + def getreports( + self, names: "Literal['pytest_runtest_logreport']", + ) -> Sequence[TestReport]: + ... + + @overload + def getreports( + self, + names: Union[str, Iterable[str]] = ( + "pytest_collectreport", + "pytest_runtest_logreport", + ), + ) -> Sequence[Union[CollectReport, TestReport]]: + ... + def getreports( self, - names: Union[ - str, Iterable[str] - ] = "pytest_runtest_logreport pytest_collectreport", - ) -> List[TestReport]: + names: Union[str, Iterable[str]] = ( + "pytest_collectreport", + "pytest_runtest_logreport", + ), + ) -> Sequence[Union[CollectReport, TestReport]]: return [x.report for x in self.getcalls(names)] def matchreport( self, inamepart: str = "", - names: Union[ - str, Iterable[str] - ] = "pytest_runtest_logreport pytest_collectreport", - when=None, - ): - """return a testreport whose dotted import path matches""" + names: Union[str, Iterable[str]] = ( + "pytest_runtest_logreport", + "pytest_collectreport", + ), + when: Optional[str] = None, + ) -> Union[CollectReport, TestReport]: + """Return a testreport whose dotted import path matches.""" values = [] for rep in self.getreports(names=names): if not when and rep.when != "call" and rep.passed: @@ -307,31 +352,61 @@ def matchreport( ) return values[0] + @overload + def getfailures( + self, names: "Literal['pytest_collectreport']", + ) -> Sequence[CollectReport]: + ... + + @overload + def getfailures( + self, names: "Literal['pytest_runtest_logreport']", + ) -> Sequence[TestReport]: + ... + + @overload + def getfailures( + self, + names: Union[str, Iterable[str]] = ( + "pytest_collectreport", + "pytest_runtest_logreport", + ), + ) -> Sequence[Union[CollectReport, TestReport]]: + ... + def getfailures( self, - names: Union[ - str, Iterable[str] - ] = "pytest_runtest_logreport pytest_collectreport", - ) -> List[TestReport]: + names: Union[str, Iterable[str]] = ( + "pytest_collectreport", + "pytest_runtest_logreport", + ), + ) -> Sequence[Union[CollectReport, TestReport]]: return [rep for rep in self.getreports(names) if rep.failed] - def getfailedcollections(self) -> List[TestReport]: + def getfailedcollections(self) -> Sequence[CollectReport]: return self.getfailures("pytest_collectreport") def listoutcomes( self, - ) -> Tuple[List[TestReport], List[TestReport], List[TestReport]]: + ) -> Tuple[ + Sequence[TestReport], + Sequence[Union[CollectReport, TestReport]], + Sequence[Union[CollectReport, TestReport]], + ]: passed = [] skipped = [] failed = [] - for rep in self.getreports("pytest_collectreport pytest_runtest_logreport"): + for rep in self.getreports( + ("pytest_collectreport", "pytest_runtest_logreport") + ): if rep.passed: if rep.when == "call": + assert isinstance(rep, TestReport) passed.append(rep) elif rep.skipped: skipped.append(rep) else: - assert rep.failed, "Unexpected outcome: {!r}".format(rep) + assert rep.failed, f"Unexpected outcome: {rep!r}" failed.append(rep) return passed, skipped, failed @@ -340,38 +415,62 @@ def countoutcomes(self) -> List[int]: def assertoutcome(self, passed: int = 0, skipped: int = 0, failed: int = 0) -> None: __tracebackhide__ = True + from _pytest.pytester_assertions import assertoutcome outcomes = self.listoutcomes() - realpassed, realskipped, realfailed = outcomes - obtained = { - "passed": len(realpassed), - "skipped": len(realskipped), - "failed": len(realfailed), - } - expected = {"passed": passed, "skipped": skipped, "failed": failed} - assert obtained == expected, outcomes + assertoutcome( + outcomes, passed=passed, skipped=skipped, failed=failed, + ) def clear(self) -> None: self.calls[:] = [] -@pytest.fixture +@fixture def linecomp() -> "LineComp": + """A :class: `LineComp` instance for checking that an input linearly + contains a sequence of strings.""" return LineComp() -@pytest.fixture(name="LineMatcher") -def LineMatcher_fixture(request: FixtureRequest) -> "Type[LineMatcher]": +@fixture(name="LineMatcher") +def LineMatcher_fixture(request: FixtureRequest) -> Type["LineMatcher"]: + """A reference to the :class: `LineMatcher`. + + This is instantiable with a list of lines (without their trailing newlines). + This is useful for testing large texts, such as the output of commands. + """ return LineMatcher -@pytest.fixture -def testdir(request: FixtureRequest, tmpdir_factory) -> "Testdir": - return Testdir(request, tmpdir_factory) +@fixture +def pytester(request: FixtureRequest, tmp_path_factory: TempPathFactory) -> "Pytester": + """ + Facilities to write tests/configuration files, execute pytest in isolation, and match + against expected output, perfect for black-box testing of pytest plugins. + + It attempts to isolate the test run from external factors as much as possible, modifying + the current working directory to ``path`` and environment variables during initialization. + + It is particularly useful for testing plugins. It is similar to the :fixture:`tmp_path` + fixture but provides methods which aid in testing pytest itself. + """ + return Pytester(request, tmp_path_factory, _ispytest=True) + + +@fixture +def testdir(pytester: "Pytester") -> "Testdir": + """ + Identical to :fixture:`pytester`, and provides an instance whose methods return + legacy ``py.path.local`` objects instead when applicable. + + New code should avoid using :fixture:`testdir` in favor of :fixture:`pytester`. + """ + return Testdir(pytester, _ispytest=True) -@pytest.fixture -def _sys_snapshot(): +@fixture +def _sys_snapshot() -> Generator[None, None, None]: snappaths = SysPathsSnapshot() snapmods = SysModulesSnapshot() yield @@ -379,8 +478,8 @@ def _sys_snapshot(): snappaths.restore() -@pytest.fixture -def _config_for_test(): +@fixture +def _config_for_test() -> Generator[Config, None, None]: from _pytest.config import get_config config = get_config() @@ -388,26 +487,14 @@ def _config_for_test(): config._ensure_unconfigure() # cleanup, e.g. capman closing tmpfiles. -# regex to match the session duration string in the summary: "74.34s" +# Regex to match the session duration string in the summary: "74.34s". rex_session_duration = re.compile(r"\d+\.\d\ds") -# regex to match all the counts and phrases in the summary line: "34 passed, 111 skipped" +# Regex to match all the counts and phrases in the summary line: "34 passed, 111 skipped". rex_outcome = re.compile(r"(\d+) (\w+)") class RunResult: - """The result of running a command. - - Attributes: - - :ivar ret: the return value - :ivar outlines: list of lines captured from stdout - :ivar errlines: list of lines captured from stderr - :ivar stdout: :py:class:`LineMatcher` of stdout, use ``stdout.str()`` to - reconstruct stdout or the commonly used ``stdout.fnmatch_lines()`` - method - :ivar stderr: :py:class:`LineMatcher` of stderr - :ivar duration: duration in seconds - """ + """The result of running a command.""" def __init__( self, @@ -417,14 +504,24 @@ def __init__( duration: float, ) -> None: try: - self.ret = pytest.ExitCode(ret) # type: Union[int, ExitCode] + self.ret: Union[int, ExitCode] = ExitCode(ret) + """The return value.""" except ValueError: self.ret = ret self.outlines = outlines + """List of lines captured from stdout.""" self.errlines = errlines + """List of lines captured from stderr.""" self.stdout = LineMatcher(outlines) + """:class:`LineMatcher` of stdout. + + Use e.g. :func:`str(stdout) ` to reconstruct stdout, or the commonly used + :func:`stdout.fnmatch_lines() ` method. + """ self.stderr = LineMatcher(errlines) + """:class:`LineMatcher` of stderr.""" self.duration = duration + """Duration in seconds.""" def __repr__(self) -> str: return ( @@ -433,54 +530,65 @@ def __repr__(self) -> str: ) def parseoutcomes(self) -> Dict[str, int]: - """Return a dictionary of outcomestring->num from parsing the terminal + """Return a dictionary of outcome noun -> count from parsing the terminal output that the test process produced. + The returned nouns will always be in plural form:: + + ======= 1 failed, 1 passed, 1 warning, 1 error in 0.13s ==== + + Will return ``{"failed": 1, "passed": 1, "warnings": 1, "errors": 1}``. + """ + return self.parse_summary_nouns(self.outlines) + + @classmethod + def parse_summary_nouns(cls, lines) -> Dict[str, int]: + """Extract the nouns from a pytest terminal summary line. + + It always returns the plural noun for consistency:: + + ======= 1 failed, 1 passed, 1 warning, 1 error in 0.13s ==== + + Will return ``{"failed": 1, "passed": 1, "warnings": 1, "errors": 1}``. """ - for line in reversed(self.outlines): + for line in reversed(lines): if rex_session_duration.search(line): outcomes = rex_outcome.findall(line) ret = {noun: int(count) for (count, noun) in outcomes} break else: raise ValueError("Pytest terminal summary report not found") - if "errors" in ret: - assert "error" not in ret - ret["error"] = ret.pop("errors") - return ret + + to_plural = { + "warning": "warnings", + "error": "errors", + } + return {to_plural.get(k, k): v for k, v in ret.items()} def assert_outcomes( self, passed: int = 0, skipped: int = 0, failed: int = 0, - error: int = 0, + errors: int = 0, xpassed: int = 0, xfailed: int = 0, ) -> None: """Assert that the specified outcomes appear with the respective - numbers (0 means it didn't occur) in the text output from a test run. - """ + numbers (0 means it didn't occur) in the text output from a test run.""" __tracebackhide__ = True - - d = self.parseoutcomes() - obtained = { - "passed": d.get("passed", 0), - "skipped": d.get("skipped", 0), - "failed": d.get("failed", 0), - "error": d.get("error", 0), - "xpassed": d.get("xpassed", 0), - "xfailed": d.get("xfailed", 0), - } - expected = { - "passed": passed, - "skipped": skipped, - "failed": failed, - "error": error, - "xpassed": xpassed, - "xfailed": xfailed, - } - assert obtained == expected + from _pytest.pytester_assertions import assert_outcomes + + outcomes = self.parseoutcomes() + assert_outcomes( + outcomes, + passed=passed, + skipped=skipped, + failed=failed, + errors=errors, + xpassed=xpassed, + xfailed=xfailed, + ) class CwdSnapshot: @@ -492,7 +600,7 @@ def restore(self) -> None: class SysModulesSnapshot: - def __init__(self, preserve: Optional[Callable[[str], bool]] = None): + def __init__(self, preserve: Optional[Callable[[str], bool]] = None) -> None: self.__preserve = preserve self.__saved = dict(sys.modules) @@ -513,22 +621,24 @@ def restore(self) -> None: sys.path[:], sys.meta_path[:] = self.__saved -class Testdir: - """Temporary test directory with tools to test/run pytest itself. +@final +class Pytester: + """ + Facilities to write tests/configuration files, execute pytest in isolation, and match + against expected output, perfect for black-box testing of pytest plugins. - This is based on the ``tmpdir`` fixture but provides a number of methods - which aid with testing pytest itself. Unless :py:meth:`chdir` is used all - methods will use :py:attr:`tmpdir` as their current working directory. + It attempts to isolate the test run from external factors as much as possible, modifying + the current working directory to ``path`` and environment variables during initialization. Attributes: - :ivar tmpdir: The :py:class:`py.path.local` instance of the temporary directory. + :ivar Path path: temporary directory path used to create files/run tests from, etc. - :ivar plugins: A list of plugins to use with :py:meth:`parseconfig` and + :ivar plugins: + A list of plugins to use with :py:meth:`parseconfig` and :py:meth:`runpytest`. Initially this is an empty list but plugins can be added to the list. The type of items to add to the list depends on the method using them so refer to them for details. - """ __test__ = False @@ -538,85 +648,102 @@ class Testdir: class TimeoutExpired(Exception): pass - def __init__(self, request: FixtureRequest, tmpdir_factory: TempdirFactory) -> None: - self.request = request - self._mod_collections = ( - WeakKeyDictionary() - ) # type: WeakKeyDictionary[Module, List[Union[Item, Collector]]] + def __init__( + self, + request: FixtureRequest, + tmp_path_factory: TempPathFactory, + *, + _ispytest: bool = False, + ) -> None: + check_ispytest(_ispytest) + self._request = request + self._mod_collections: WeakKeyDictionary[ + Collector, List[Union[Item, Collector]] + ] = (WeakKeyDictionary()) if request.function: - name = request.function.__name__ # type: str + name: str = request.function.__name__ else: name = request.node.name self._name = name - self.tmpdir = tmpdir_factory.mktemp(name, numbered=True) - self.test_tmproot = tmpdir_factory.mktemp("tmp-" + name, numbered=True) - self.plugins = [] # type: List[Union[str, _PluggyPlugin]] + self._path: Path = tmp_path_factory.mktemp(name, numbered=True) + self.plugins: List[Union[str, _PluggyPlugin]] = [] self._cwd_snapshot = CwdSnapshot() self._sys_path_snapshot = SysPathsSnapshot() self._sys_modules_snapshot = self.__take_sys_modules_snapshot() self.chdir() - self.request.addfinalizer(self.finalize) - self._method = self.request.config.getoption("--runpytest") + self._request.addfinalizer(self._finalize) + self._method = self._request.config.getoption("--runpytest") + self._test_tmproot = tmp_path_factory.mktemp(f"tmp-{name}", numbered=True) - mp = self.monkeypatch = MonkeyPatch() - mp.setenv("PYTEST_DEBUG_TEMPROOT", str(self.test_tmproot)) + self._monkeypatch = mp = MonkeyPatch() + mp.setenv("PYTEST_DEBUG_TEMPROOT", str(self._test_tmproot)) # Ensure no unexpected caching via tox. mp.delenv("TOX_ENV_DIR", raising=False) # Discard outer pytest options. mp.delenv("PYTEST_ADDOPTS", raising=False) # Ensure no user config is used. - tmphome = str(self.tmpdir) + tmphome = str(self.path) mp.setenv("HOME", tmphome) mp.setenv("USERPROFILE", tmphome) # Do not use colors for inner runs by default. mp.setenv("PY_COLORS", "0") - def __repr__(self): - return "".format(self.tmpdir) + @property + def path(self) -> Path: + """Temporary directory where files are created and pytest is executed.""" + return self._path - def __str__(self): - return str(self.tmpdir) + def __repr__(self) -> str: + return f"" - def finalize(self): - """Clean up global state artifacts. + def _finalize(self) -> None: + """ + Clean up global state artifacts. Some methods modify the global interpreter state and this tries to - clean this up. It does not remove the temporary directory however so + clean this up. It does not remove the temporary directory however so it can be looked at after the test run has finished. - """ self._sys_modules_snapshot.restore() self._sys_path_snapshot.restore() self._cwd_snapshot.restore() - self.monkeypatch.undo() + self._monkeypatch.undo() - def __take_sys_modules_snapshot(self): - # some zope modules used by twisted-related tests keep internal state + def __take_sys_modules_snapshot(self) -> SysModulesSnapshot: + # Some zope modules used by twisted-related tests keep internal state # and can't be deleted; we had some trouble in the past with - # `zope.interface` for example + # `zope.interface` for example. + # + # Preserve readline due to https://bugs.python.org/issue41033. + # pexpect issues a SIGWINCH. def preserve_module(name): - return name.startswith("zope") + return name.startswith(("zope", "readline")) return SysModulesSnapshot(preserve=preserve_module) - def make_hook_recorder(self, pluginmanager): + def make_hook_recorder(self, pluginmanager: PytestPluginManager) -> HookRecorder: """Create a new :py:class:`HookRecorder` for a PluginManager.""" pluginmanager.reprec = reprec = HookRecorder(pluginmanager) - self.request.addfinalizer(reprec.finish_recording) + self._request.addfinalizer(reprec.finish_recording) return reprec - def chdir(self): + def chdir(self) -> None: """Cd into the temporary directory. This is done automatically upon instantiation. - """ - self.tmpdir.chdir() + os.chdir(self.path) - def _makefile(self, ext, lines, files, encoding="utf-8"): + def _makefile( + self, + ext: str, + lines: Sequence[Union[Any, bytes]], + files: Dict[str, str], + encoding: str = "utf-8", + ) -> Path: items = list(files.items()) - def to_text(s): + def to_text(s: Union[Any, bytes]) -> str: return s.decode(encoding) if isinstance(s, bytes) else str(s) if lines: @@ -626,144 +753,189 @@ def to_text(s): ret = None for basename, value in items: - p = self.tmpdir.join(basename).new(ext=ext) - p.dirpath().ensure_dir() - source = Source(value) - source = "\n".join(to_text(line) for line in source.lines) - p.write(source.strip().encode(encoding), "wb") + p = self.path.joinpath(basename).with_suffix(ext) + p.parent.mkdir(parents=True, exist_ok=True) + source_ = Source(value) + source = "\n".join(to_text(line) for line in source_.lines) + p.write_text(source.strip(), encoding=encoding) if ret is None: ret = p + assert ret is not None return ret - def makefile(self, ext, *args, **kwargs): - r"""Create new file(s) in the testdir. + def makefile(self, ext: str, *args: str, **kwargs: str) -> Path: + r"""Create new file(s) in the test directory. - :param str ext: The extension the file(s) should use, including the dot, e.g. `.py`. - :param list[str] args: All args will be treated as strings and joined using newlines. - The result will be written as contents to the file. The name of the - file will be based on the test function requesting this fixture. - :param kwargs: Each keyword is the name of a file, while the value of it will - be written as contents of the file. + :param str ext: + The extension the file(s) should use, including the dot, e.g. `.py`. + :param args: + All args are treated as strings and joined using newlines. + The result is written as contents to the file. The name of the + file is based on the test function requesting this fixture. + :param kwargs: + Each keyword is the name of a file, while the value of it will + be written as contents of the file. Examples: .. code-block:: python - testdir.makefile(".txt", "line1", "line2") + pytester.makefile(".txt", "line1", "line2") - testdir.makefile(".ini", pytest="[pytest]\naddopts=-rs\n") + pytester.makefile(".ini", pytest="[pytest]\naddopts=-rs\n") """ return self._makefile(ext, args, kwargs) - def makeconftest(self, source): + def makeconftest(self, source: str) -> Path: """Write a contest.py file with 'source' as contents.""" return self.makepyfile(conftest=source) - def makeini(self, source): + def makeini(self, source: str) -> Path: """Write a tox.ini file with 'source' as contents.""" return self.makefile(".ini", tox=source) - def getinicfg(self, source): + def getinicfg(self, source: str) -> SectionWrapper: """Return the pytest section from the tox.ini config file.""" p = self.makeini(source) - return py.iniconfig.IniConfig(p)["pytest"] + return IniConfig(str(p))["pytest"] + + def makepyprojecttoml(self, source: str) -> Path: + """Write a pyproject.toml file with 'source' as contents. + + .. versionadded:: 6.0 + """ + return self.makefile(".toml", pyproject=source) + + def makepyfile(self, *args, **kwargs) -> Path: + r"""Shortcut for .makefile() with a .py extension. + + Defaults to the test name with a '.py' extension, e.g test_foobar.py, overwriting + existing files. + + Examples: + + .. code-block:: python + + def test_something(pytester): + # Initial file is created test_something.py. + pytester.makepyfile("foobar") + # To create multiple files, pass kwargs accordingly. + pytester.makepyfile(custom="foobar") + # At this point, both 'test_something.py' & 'custom.py' exist in the test directory. - def makepyfile(self, *args, **kwargs): - """Shortcut for .makefile() with a .py extension.""" + """ return self._makefile(".py", args, kwargs) - def maketxtfile(self, *args, **kwargs): - """Shortcut for .makefile() with a .txt extension.""" + def maketxtfile(self, *args, **kwargs) -> Path: + r"""Shortcut for .makefile() with a .txt extension. + + Defaults to the test name with a '.txt' extension, e.g test_foobar.txt, overwriting + existing files. + + Examples: + + .. code-block:: python + + def test_something(pytester): + # Initial file is created test_something.txt. + pytester.maketxtfile("foobar") + # To create multiple files, pass kwargs accordingly. + pytester.maketxtfile(custom="foobar") + # At this point, both 'test_something.txt' & 'custom.txt' exist in the test directory. + + """ return self._makefile(".txt", args, kwargs) - def syspathinsert(self, path=None): + def syspathinsert( + self, path: Optional[Union[str, "os.PathLike[str]"]] = None + ) -> None: """Prepend a directory to sys.path, defaults to :py:attr:`tmpdir`. This is undone automatically when this object dies at the end of each test. """ if path is None: - path = self.tmpdir + path = self.path - self.monkeypatch.syspath_prepend(str(path)) + self._monkeypatch.syspath_prepend(str(path)) - def mkdir(self, name): + def mkdir(self, name: str) -> Path: """Create a new (sub)directory.""" - return self.tmpdir.mkdir(name) + p = self.path / name + p.mkdir() + return p - def mkpydir(self, name): + def mkpydir(self, name: str) -> Path: """Create a new python package. This creates a (sub)directory with an empty ``__init__.py`` file so it - gets recognised as a python package. - + gets recognised as a Python package. """ - p = self.mkdir(name) - p.ensure("__init__.py") + p = self.path / name + p.mkdir() + p.joinpath("__init__.py").touch() return p - def copy_example(self, name=None): + def copy_example(self, name: Optional[str] = None) -> Path: """Copy file from project's directory into the testdir. :param str name: The name of the file to copy. - :return: path to the copied directory (inside ``self.tmpdir``). + :return: path to the copied directory (inside ``self.path``). """ - import warnings - from _pytest.warning_types import PYTESTER_COPY_EXAMPLE - - warnings.warn(PYTESTER_COPY_EXAMPLE, stacklevel=2) - example_dir = self.request.config.getini("pytester_example_dir") + example_dir = self._request.config.getini("pytester_example_dir") if example_dir is None: raise ValueError("pytester_example_dir is unset, can't copy examples") - example_dir = self.request.config.rootdir.join(example_dir) + example_dir = Path(str(self._request.config.rootdir)) / example_dir - for extra_element in self.request.node.iter_markers("pytester_example_path"): + for extra_element in self._request.node.iter_markers("pytester_example_path"): assert extra_element.args - example_dir = example_dir.join(*extra_element.args) + example_dir = example_dir.joinpath(*extra_element.args) if name is None: func_name = self._name maybe_dir = example_dir / func_name maybe_file = example_dir / (func_name + ".py") - if maybe_dir.isdir(): + if maybe_dir.is_dir(): example_path = maybe_dir - elif maybe_file.isfile(): + elif maybe_file.is_file(): example_path = maybe_file else: raise LookupError( - "{} cant be found as module or package in {}".format( - func_name, example_dir.bestrelpath(self.request.config.rootdir) - ) + f"{func_name} can't be found as module or package in {example_dir}" ) else: - example_path = example_dir.join(name) - - if example_path.isdir() and not example_path.join("__init__.py").isfile(): - example_path.copy(self.tmpdir) - return self.tmpdir - elif example_path.isfile(): - result = self.tmpdir.join(example_path.basename) - example_path.copy(result) + example_path = example_dir.joinpath(name) + + if example_path.is_dir() and not example_path.joinpath("__init__.py").is_file(): + # TODO: py.path.local.copy can copy files to existing directories, + # while with shutil.copytree the destination directory cannot exist, + # we will need to roll our own in order to drop py.path.local completely + py.path.local(example_path).copy(py.path.local(self.path)) + return self.path + elif example_path.is_file(): + result = self.path.joinpath(example_path.name) + shutil.copy(example_path, result) return result else: raise LookupError( - 'example "{}" is not found as a file or directory'.format(example_path) + f'example "{example_path}" is not found as a file or directory' ) Session = Session - def getnode(self, config, arg): + def getnode( + self, config: Config, arg: Union[str, "os.PathLike[str]"] + ) -> Optional[Union[Collector, Item]]: """Return the collection node of a file. - :param config: :py:class:`_pytest.config.Config` instance, see - :py:meth:`parseconfig` and :py:meth:`parseconfigure` to create the - configuration - - :param arg: a :py:class:`py.path.local` instance of the file - + :param _pytest.config.Config config: + A pytest config. + See :py:meth:`parseconfig` and :py:meth:`parseconfigure` for creating it. + :param py.path.local arg: + Path to the file. """ session = Session.from_config(config) assert "::" not in str(arg) @@ -773,15 +945,15 @@ def getnode(self, config, arg): config.hook.pytest_sessionfinish(session=session, exitstatus=ExitCode.OK) return res - def getpathnode(self, path): + def getpathnode(self, path: Union[str, "os.PathLike[str]"]): """Return the collection node of a file. This is like :py:meth:`getnode` but uses :py:meth:`parseconfigure` to create the (configured) pytest Config instance. - :param path: a :py:class:`py.path.local` instance of the file - + :param py.path.local path: Path to the file. """ + path = py.path.local(path) config = self.parseconfigure(path) session = Session.from_config(config) x = session.fspath.bestrelpath(path) @@ -790,66 +962,67 @@ def getpathnode(self, path): config.hook.pytest_sessionfinish(session=session, exitstatus=ExitCode.OK) return res - def genitems(self, colitems): + def genitems(self, colitems: Sequence[Union[Item, Collector]]) -> List[Item]: """Generate all test items from a collection node. This recurses into the collection node and returns a list of all the test items contained within. - """ session = colitems[0].session - result = [] + result: List[Item] = [] for colitem in colitems: result.extend(session.genitems(colitem)) return result - def runitem(self, source): + def runitem(self, source: str) -> Any: """Run the "test_func" Item. The calling test instance (class containing the test method) must provide a ``.getrunner()`` method which should return a runner which can run the test protocol for a single item, e.g. :py:func:`_pytest.runner.runtestprotocol`. - """ # used from runner functional tests item = self.getitem(source) # the test class where we are called from wants to provide the runner - testclassinstance = self.request.instance + testclassinstance = self._request.instance runner = testclassinstance.getrunner() return runner(item) - def inline_runsource(self, source, *cmdlineargs): + def inline_runsource(self, source: str, *cmdlineargs) -> HookRecorder: """Run a test module in process using ``pytest.main()``. This run writes "source" into a temporary file and runs ``pytest.main()`` on it, returning a :py:class:`HookRecorder` instance for the result. - :param source: the source code of the test module + :param source: The source code of the test module. - :param cmdlineargs: any extra command line arguments to use - - :return: :py:class:`HookRecorder` instance of the result + :param cmdlineargs: Any extra command line arguments to use. + :returns: :py:class:`HookRecorder` instance of the result. """ p = self.makepyfile(source) values = list(cmdlineargs) + [p] return self.inline_run(*values) - def inline_genitems(self, *args): + def inline_genitems(self, *args) -> Tuple[List[Item], HookRecorder]: """Run ``pytest.main(['--collectonly'])`` in-process. Runs the :py:func:`pytest.main` function to run all of pytest inside the test process itself like :py:meth:`inline_run`, but returns a tuple of the collected items and a :py:class:`HookRecorder` instance. - """ rec = self.inline_run("--collect-only", *args) items = [x.item for x in rec.getcalls("pytest_itemcollected")] return items, rec - def inline_run(self, *args, plugins=(), no_reraise_ctrlc: bool = False): + def inline_run( + self, + *args: Union[str, "os.PathLike[str]"], + plugins=(), + no_reraise_ctrlc: bool = False, + ) -> HookRecorder: """Run ``pytest.main()`` in-process, returning a HookRecorder. Runs the :py:func:`pytest.main` function to run all of pytest inside @@ -858,14 +1031,15 @@ def inline_run(self, *args, plugins=(), no_reraise_ctrlc: bool = False): from that run than can be done by matching stdout/stderr from :py:meth:`runpytest`. - :param args: command line arguments to pass to :py:func:`pytest.main` - - :kwarg plugins: extra plugin instances the ``pytest.main()`` instance should use. - - :kwarg no_reraise_ctrlc: typically we reraise keyboard interrupts from the child run. If + :param args: + Command line arguments to pass to :py:func:`pytest.main`. + :param plugins: + Extra plugin instances the ``pytest.main()`` instance should use. + :param no_reraise_ctrlc: + Typically we reraise keyboard interrupts from the child run. If True, the KeyboardInterrupt exception is captured. - :return: a :py:class:`HookRecorder` instance + :returns: A :py:class:`HookRecorder` instance. """ # (maybe a cpython bug?) the importlib cache sometimes isn't updated # properly between file creation and inline_run (especially if imports @@ -891,11 +1065,11 @@ def inline_run(self, *args, plugins=(), no_reraise_ctrlc: bool = False): rec = [] class Collect: - def pytest_configure(x, config): + def pytest_configure(x, config: Config) -> None: rec.append(self.make_hook_recorder(config.pluginmanager)) plugins.append(Collect()) - ret = pytest.main(list(args), plugins=plugins) + ret = main([str(x) for x in args], plugins=plugins) if len(rec) == 1: reprec = rec.pop() else: @@ -903,10 +1077,10 @@ def pytest_configure(x, config): class reprec: # type: ignore pass - reprec.ret = ret + reprec.ret = ret # type: ignore - # typically we reraise keyboard interrupts from the child run - # because it's our user requesting interruption of the testing + # Typically we reraise keyboard interrupts from the child run + # because it's our user requesting interruption of the testing. if ret == ExitCode.INTERRUPTED and not no_reraise_ctrlc: calls = reprec.getcalls("pytest_keyboard_interrupt") if calls and calls[-1].excinfo.type == KeyboardInterrupt: @@ -916,16 +1090,17 @@ class reprec: # type: ignore for finalizer in finalizers: finalizer() - def runpytest_inprocess(self, *args, **kwargs) -> RunResult: + def runpytest_inprocess( + self, *args: Union[str, "os.PathLike[str]"], **kwargs: Any + ) -> RunResult: """Return result of running pytest in-process, providing a similar - interface to what self.runpytest() provides. - """ + interface to what self.runpytest() provides.""" syspathinsert = kwargs.pop("syspathinsert", False) if syspathinsert: self.syspathinsert() - now = time.time() - capture = MultiCapture(Capture=SysCapture) + now = timing.time() + capture = _get_multicapture("sys") capture.start_capturing() try: try: @@ -952,34 +1127,37 @@ class reprec: # type: ignore sys.stdout.write(out) sys.stderr.write(err) + assert reprec.ret is not None res = RunResult( - reprec.ret, out.splitlines(), err.splitlines(), time.time() - now + reprec.ret, out.splitlines(), err.splitlines(), timing.time() - now ) res.reprec = reprec # type: ignore return res - def runpytest(self, *args, **kwargs) -> RunResult: + def runpytest( + self, *args: Union[str, "os.PathLike[str]"], **kwargs: Any + ) -> RunResult: """Run pytest inline or in a subprocess, depending on the command line - option "--runpytest" and return a :py:class:`RunResult`. - - """ - args = self._ensure_basetemp(args) + option "--runpytest" and return a :py:class:`RunResult`.""" + new_args = self._ensure_basetemp(args) if self._method == "inprocess": - return self.runpytest_inprocess(*args, **kwargs) + return self.runpytest_inprocess(*new_args, **kwargs) elif self._method == "subprocess": - return self.runpytest_subprocess(*args, **kwargs) - raise RuntimeError("Unrecognized runpytest option: {}".format(self._method)) - - def _ensure_basetemp(self, args): - args = list(args) - for x in args: + return self.runpytest_subprocess(*new_args, **kwargs) + raise RuntimeError(f"Unrecognized runpytest option: {self._method}") + + def _ensure_basetemp( + self, args: Sequence[Union[str, "os.PathLike[str]"]] + ) -> List[Union[str, "os.PathLike[str]"]]: + new_args = list(args) + for x in new_args: if str(x).startswith("--basetemp"): break else: - args.append("--basetemp=%s" % self.tmpdir.dirpath("basetemp")) - return args + new_args.append("--basetemp=%s" % self.path.parent.joinpath("basetemp")) + return new_args - def parseconfig(self, *args): + def parseconfig(self, *args: Union[str, "os.PathLike[str]"]) -> Config: """Return a new pytest Config instance from given commandline args. This invokes the pytest bootstrapping code in _pytest.config to create @@ -989,41 +1167,40 @@ def parseconfig(self, *args): If :py:attr:`plugins` has been populated they should be plugin modules to be registered with the PluginManager. - """ - args = self._ensure_basetemp(args) - import _pytest.config - config = _pytest.config._prepareconfig(args, self.plugins) + new_args = self._ensure_basetemp(args) + new_args = [str(x) for x in new_args] + + config = _pytest.config._prepareconfig(new_args, self.plugins) # type: ignore[arg-type] # we don't know what the test will do with this half-setup config # object and thus we make sure it gets unconfigured properly in any # case (otherwise capturing could still be active, for example) - self.request.addfinalizer(config._ensure_unconfigure) + self._request.addfinalizer(config._ensure_unconfigure) return config - def parseconfigure(self, *args): + def parseconfigure(self, *args: Union[str, "os.PathLike[str]"]) -> Config: """Return a new pytest configured Config instance. - This returns a new :py:class:`_pytest.config.Config` instance like + Returns a new :py:class:`_pytest.config.Config` instance like :py:meth:`parseconfig`, but also calls the pytest_configure hook. """ config = self.parseconfig(*args) config._do_configure() return config - def getitem(self, source, funcname="test_func"): + def getitem(self, source: str, funcname: str = "test_func") -> Item: """Return the test item for a test function. - This writes the source to a python file and runs pytest's collection on + Writes the source to a python file and runs pytest's collection on the resulting module, returning the test item for the requested function name. - :param source: the module source - - :param funcname: the name of the test function for which to return a - test item - + :param source: + The module source. + :param funcname: + The name of the test function for which to return a test item. """ items = self.getitems(source) for item in items: @@ -1033,37 +1210,39 @@ def getitem(self, source, funcname="test_func"): funcname, source, items ) - def getitems(self, source): + def getitems(self, source: str) -> List[Item]: """Return all test items collected from the module. - This writes the source to a python file and runs pytest's collection on + Writes the source to a Python file and runs pytest's collection on the resulting module, returning all test items contained within. - """ modcol = self.getmodulecol(source) return self.genitems([modcol]) - def getmodulecol(self, source, configargs=(), withinit=False): + def getmodulecol( + self, source: Union[str, Path], configargs=(), *, withinit: bool = False + ): """Return the module collection node for ``source``. - This writes ``source`` to a file using :py:meth:`makepyfile` and then + Writes ``source`` to a file using :py:meth:`makepyfile` and then runs the pytest collection on it, returning the collection node for the test module. - :param source: the source code of the module to collect - - :param configargs: any extra arguments to pass to - :py:meth:`parseconfigure` + :param source: + The source code of the module to collect. - :param withinit: whether to also write an ``__init__.py`` file to the - same directory to ensure it is a package + :param configargs: + Any extra arguments to pass to :py:meth:`parseconfigure`. + :param withinit: + Whether to also write an ``__init__.py`` file to the same + directory to ensure it is a package. """ if isinstance(source, Path): - path = self.tmpdir.join(str(source)) + path = self.path.joinpath(source) assert not withinit, "not supported for paths" else: - kw = {self._name: Source(source).strip()} + kw = {self._name: str(source)} path = self.makepyfile(**kw) if withinit: self.makepyfile(__init__="#") @@ -1071,16 +1250,15 @@ def getmodulecol(self, source, configargs=(), withinit=False): return self.getnode(config, path) def collect_by_name( - self, modcol: Module, name: str + self, modcol: Collector, name: str ) -> Optional[Union[Item, Collector]]: """Return the collection node for name from the module collection. - This will search a module collection node for a collection node - matching the given name. + Searchs a module collection node for a collection node matching the + given name. - :param modcol: a module collection node; see :py:meth:`getmodulecol` - - :param name: the name of the node to return + :param modcol: A module collection node; see :py:meth:`getmodulecol`. + :param name: The name of the node to return. """ if modcol not in self._mod_collections: self._mod_collections[modcol] = list(modcol.collect()) @@ -1092,18 +1270,17 @@ def collect_by_name( def popen( self, cmdargs, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, + stdout: Union[int, TextIO] = subprocess.PIPE, + stderr: Union[int, TextIO] = subprocess.PIPE, stdin=CLOSE_STDIN, - **kw + **kw, ): """Invoke subprocess.Popen. - This calls subprocess.Popen making sure the current working directory - is in the PYTHONPATH. + Calls subprocess.Popen making sure the current working directory is + in the PYTHONPATH. You probably want to use :py:meth:`run` instead. - """ env = os.environ.copy() env["PYTHONPATH"] = os.pathsep.join( @@ -1111,7 +1288,7 @@ def popen( ) kw["env"] = env - if stdin is Testdir.CLOSE_STDIN: + if stdin is self.CLOSE_STDIN: kw["stdin"] = subprocess.PIPE elif isinstance(stdin, bytes): kw["stdin"] = subprocess.PIPE @@ -1119,42 +1296,53 @@ def popen( kw["stdin"] = stdin popen = subprocess.Popen(cmdargs, stdout=stdout, stderr=stderr, **kw) - if stdin is Testdir.CLOSE_STDIN: + if stdin is self.CLOSE_STDIN: + assert popen.stdin is not None popen.stdin.close() elif isinstance(stdin, bytes): + assert popen.stdin is not None popen.stdin.write(stdin) return popen - def run(self, *cmdargs, timeout=None, stdin=CLOSE_STDIN) -> RunResult: + def run( + self, + *cmdargs: Union[str, "os.PathLike[str]"], + timeout: Optional[float] = None, + stdin=CLOSE_STDIN, + ) -> RunResult: """Run a command with arguments. Run a process using subprocess.Popen saving the stdout and stderr. - :param args: the sequence of arguments to pass to `subprocess.Popen()` - :kwarg timeout: the period in seconds after which to timeout and raise - :py:class:`Testdir.TimeoutExpired` - :kwarg stdin: optional standard input. Bytes are being send, closing + :param cmdargs: + The sequence of arguments to pass to `subprocess.Popen()`, with path-like objects + being converted to ``str`` automatically. + :param timeout: + The period in seconds after which to timeout and raise + :py:class:`Pytester.TimeoutExpired`. + :param stdin: + Optional standard input. Bytes are being send, closing the pipe, otherwise it is passed through to ``popen``. Defaults to ``CLOSE_STDIN``, which translates to using a pipe (``subprocess.PIPE``) that gets closed. - Returns a :py:class:`RunResult`. - + :rtype: RunResult """ __tracebackhide__ = True + # TODO: Remove type ignore in next mypy release. + # https://github.com/python/typeshed/pull/4582 cmdargs = tuple( - str(arg) if isinstance(arg, py.path.local) else arg for arg in cmdargs + os.fspath(arg) if isinstance(arg, os.PathLike) else arg for arg in cmdargs # type: ignore[misc] ) - p1 = self.tmpdir.join("stdout") - p2 = self.tmpdir.join("stderr") + p1 = self.path.joinpath("stdout") + p2 = self.path.joinpath("stderr") print("running:", *cmdargs) - print(" in:", py.path.local()) - f1 = open(str(p1), "w", encoding="utf8") - f2 = open(str(p2), "w", encoding="utf8") - try: - now = time.time() + print(" in:", Path.cwd()) + + with p1.open("w", encoding="utf8") as f1, p2.open("w", encoding="utf8") as f2: + now = timing.time() popen = self.popen( cmdargs, stdin=stdin, @@ -1162,10 +1350,10 @@ def run(self, *cmdargs, timeout=None, stdin=CLOSE_STDIN) -> RunResult: stderr=f2, close_fds=(sys.platform != "win32"), ) - if isinstance(stdin, bytes): + if popen.stdin is not None: popen.stdin.close() - def handle_timeout(): + def handle_timeout() -> None: __tracebackhide__ = True timeout_message = ( @@ -1184,48 +1372,43 @@ def handle_timeout(): ret = popen.wait(timeout) except subprocess.TimeoutExpired: handle_timeout() - finally: - f1.close() - f2.close() - f1 = open(str(p1), "r", encoding="utf8") - f2 = open(str(p2), "r", encoding="utf8") - try: + + with p1.open(encoding="utf8") as f1, p2.open(encoding="utf8") as f2: out = f1.read().splitlines() err = f2.read().splitlines() - finally: - f1.close() - f2.close() + self._dump_lines(out, sys.stdout) self._dump_lines(err, sys.stderr) - try: + + with contextlib.suppress(ValueError): ret = ExitCode(ret) - except ValueError: - pass - return RunResult(ret, out, err, time.time() - now) + return RunResult(ret, out, err, timing.time() - now) def _dump_lines(self, lines, fp): try: for line in lines: print(line, file=fp) except UnicodeEncodeError: - print("couldn't print to {} because of encoding".format(fp)) + print(f"couldn't print to {fp} because of encoding") - def _getpytestargs(self): + def _getpytestargs(self) -> Tuple[str, ...]: return sys.executable, "-mpytest" def runpython(self, script) -> RunResult: """Run a python script using sys.executable as interpreter. - Returns a :py:class:`RunResult`. - + :rtype: RunResult """ return self.run(sys.executable, script) def runpython_c(self, command): - """Run python -c "command", return a :py:class:`RunResult`.""" + """Run python -c "command". + + :rtype: RunResult + """ return self.run(sys.executable, "-c", command) - def runpytest_subprocess(self, *args, timeout=None) -> RunResult: + def runpytest_subprocess(self, *args, timeout: Optional[float] = None) -> RunResult: """Run pytest as a subprocess with given arguments. Any plugins added to the :py:attr:`plugins` list will be added using the @@ -1234,16 +1417,16 @@ def runpytest_subprocess(self, *args, timeout=None) -> RunResult: with "runpytest-" to not conflict with the normal numbered pytest location for temporary files and directories. - :param args: the sequence of arguments to pass to the pytest subprocess - :param timeout: the period in seconds after which to timeout and raise - :py:class:`Testdir.TimeoutExpired` + :param args: + The sequence of arguments to pass to the pytest subprocess. + :param timeout: + The period in seconds after which to timeout and raise + :py:class:`Pytester.TimeoutExpired`. - Returns a :py:class:`RunResult`. + :rtype: RunResult """ __tracebackhide__ = True - p = py.path.local.make_numbered_dir( - prefix="runpytest-", keep=None, rootdir=self.tmpdir - ) + p = make_numbered_dir(root=self.path, prefix="runpytest-") args = ("--basetemp=%s" % p,) + args plugins = [x for x in self.plugins if isinstance(x, str)] if plugins: @@ -1260,29 +1443,27 @@ def spawn_pytest( directory locations. The pexpect child is returned. - """ - basetemp = self.tmpdir.mkdir("temp-pexpect") + basetemp = self.path / "temp-pexpect" + basetemp.mkdir() invoke = " ".join(map(str, self._getpytestargs())) - cmd = "{} --basetemp={} {}".format(invoke, basetemp, string) + cmd = f"{invoke} --basetemp={basetemp} {string}" return self.spawn(cmd, expect_timeout=expect_timeout) def spawn(self, cmd: str, expect_timeout: float = 10.0) -> "pexpect.spawn": """Run a command using pexpect. The pexpect child is returned. - """ - pexpect = pytest.importorskip("pexpect", "3.0") + pexpect = importorskip("pexpect", "3.0") if hasattr(sys, "pypy_version_info") and "64" in platform.machine(): - pytest.skip("pypy-64 bit not supported") + skip("pypy-64 bit not supported") if not hasattr(pexpect, "spawn"): - pytest.skip("pexpect.spawn not available") - logfile = self.tmpdir.join("spawn.out").open("wb") + skip("pexpect.spawn not available") + logfile = self.path.joinpath("spawn.out").open("wb") - child = pexpect.spawn(cmd, logfile=logfile) - self.request.addfinalizer(logfile.close) - child.timeout = expect_timeout + child = pexpect.spawn(cmd, logfile=logfile, timeout=expect_timeout) + self._request.addfinalizer(logfile.close) return child @@ -1304,6 +1485,217 @@ def assert_contains_lines(self, lines2: Sequence[str]) -> None: LineMatcher(lines1).fnmatch_lines(lines2) +@final +@attr.s(repr=False, str=False, init=False) +class Testdir: + """ + Similar to :class:`Pytester`, but this class works with legacy py.path.local objects instead. + + All methods just forward to an internal :class:`Pytester` instance, converting results + to `py.path.local` objects as necessary. + """ + + __test__ = False + + CLOSE_STDIN = Pytester.CLOSE_STDIN + TimeoutExpired = Pytester.TimeoutExpired + Session = Pytester.Session + + def __init__(self, pytester: Pytester, *, _ispytest: bool = False) -> None: + check_ispytest(_ispytest) + self._pytester = pytester + + @property + def tmpdir(self) -> py.path.local: + """Temporary directory where tests are executed.""" + return py.path.local(self._pytester.path) + + @property + def test_tmproot(self) -> py.path.local: + return py.path.local(self._pytester._test_tmproot) + + @property + def request(self): + return self._pytester._request + + @property + def plugins(self): + return self._pytester.plugins + + @plugins.setter + def plugins(self, plugins): + self._pytester.plugins = plugins + + @property + def monkeypatch(self) -> MonkeyPatch: + return self._pytester._monkeypatch + + def make_hook_recorder(self, pluginmanager) -> HookRecorder: + """See :meth:`Pytester.make_hook_recorder`.""" + return self._pytester.make_hook_recorder(pluginmanager) + + def chdir(self) -> None: + """See :meth:`Pytester.chdir`.""" + return self._pytester.chdir() + + def finalize(self) -> None: + """See :meth:`Pytester._finalize`.""" + return self._pytester._finalize() + + def makefile(self, ext, *args, **kwargs) -> py.path.local: + """See :meth:`Pytester.makefile`.""" + return py.path.local(str(self._pytester.makefile(ext, *args, **kwargs))) + + def makeconftest(self, source) -> py.path.local: + """See :meth:`Pytester.makeconftest`.""" + return py.path.local(str(self._pytester.makeconftest(source))) + + def makeini(self, source) -> py.path.local: + """See :meth:`Pytester.makeini`.""" + return py.path.local(str(self._pytester.makeini(source))) + + def getinicfg(self, source: str) -> SectionWrapper: + """See :meth:`Pytester.getinicfg`.""" + return self._pytester.getinicfg(source) + + def makepyprojecttoml(self, source) -> py.path.local: + """See :meth:`Pytester.makepyprojecttoml`.""" + return py.path.local(str(self._pytester.makepyprojecttoml(source))) + + def makepyfile(self, *args, **kwargs) -> py.path.local: + """See :meth:`Pytester.makepyfile`.""" + return py.path.local(str(self._pytester.makepyfile(*args, **kwargs))) + + def maketxtfile(self, *args, **kwargs) -> py.path.local: + """See :meth:`Pytester.maketxtfile`.""" + return py.path.local(str(self._pytester.maketxtfile(*args, **kwargs))) + + def syspathinsert(self, path=None) -> None: + """See :meth:`Pytester.syspathinsert`.""" + return self._pytester.syspathinsert(path) + + def mkdir(self, name) -> py.path.local: + """See :meth:`Pytester.mkdir`.""" + return py.path.local(str(self._pytester.mkdir(name))) + + def mkpydir(self, name) -> py.path.local: + """See :meth:`Pytester.mkpydir`.""" + return py.path.local(str(self._pytester.mkpydir(name))) + + def copy_example(self, name=None) -> py.path.local: + """See :meth:`Pytester.copy_example`.""" + return py.path.local(str(self._pytester.copy_example(name))) + + def getnode(self, config: Config, arg) -> Optional[Union[Item, Collector]]: + """See :meth:`Pytester.getnode`.""" + return self._pytester.getnode(config, arg) + + def getpathnode(self, path): + """See :meth:`Pytester.getpathnode`.""" + return self._pytester.getpathnode(path) + + def genitems(self, colitems: List[Union[Item, Collector]]) -> List[Item]: + """See :meth:`Pytester.genitems`.""" + return self._pytester.genitems(colitems) + + def runitem(self, source): + """See :meth:`Pytester.runitem`.""" + return self._pytester.runitem(source) + + def inline_runsource(self, source, *cmdlineargs): + """See :meth:`Pytester.inline_runsource`.""" + return self._pytester.inline_runsource(source, *cmdlineargs) + + def inline_genitems(self, *args): + """See :meth:`Pytester.inline_genitems`.""" + return self._pytester.inline_genitems(*args) + + def inline_run(self, *args, plugins=(), no_reraise_ctrlc: bool = False): + """See :meth:`Pytester.inline_run`.""" + return self._pytester.inline_run( + *args, plugins=plugins, no_reraise_ctrlc=no_reraise_ctrlc + ) + + def runpytest_inprocess(self, *args, **kwargs) -> RunResult: + """See :meth:`Pytester.runpytest_inprocess`.""" + return self._pytester.runpytest_inprocess(*args, **kwargs) + + def runpytest(self, *args, **kwargs) -> RunResult: + """See :meth:`Pytester.runpytest`.""" + return self._pytester.runpytest(*args, **kwargs) + + def parseconfig(self, *args) -> Config: + """See :meth:`Pytester.parseconfig`.""" + return self._pytester.parseconfig(*args) + + def parseconfigure(self, *args) -> Config: + """See :meth:`Pytester.parseconfigure`.""" + return self._pytester.parseconfigure(*args) + + def getitem(self, source, funcname="test_func"): + """See :meth:`Pytester.getitem`.""" + return self._pytester.getitem(source, funcname) + + def getitems(self, source): + """See :meth:`Pytester.getitems`.""" + return self._pytester.getitems(source) + + def getmodulecol(self, source, configargs=(), withinit=False): + """See :meth:`Pytester.getmodulecol`.""" + return self._pytester.getmodulecol( + source, configargs=configargs, withinit=withinit + ) + + def collect_by_name( + self, modcol: Collector, name: str + ) -> Optional[Union[Item, Collector]]: + """See :meth:`Pytester.collect_by_name`.""" + return self._pytester.collect_by_name(modcol, name) + + def popen( + self, + cmdargs, + stdout: Union[int, TextIO] = subprocess.PIPE, + stderr: Union[int, TextIO] = subprocess.PIPE, + stdin=CLOSE_STDIN, + **kw, + ): + """See :meth:`Pytester.popen`.""" + return self._pytester.popen(cmdargs, stdout, stderr, stdin, **kw) + + def run(self, *cmdargs, timeout=None, stdin=CLOSE_STDIN) -> RunResult: + """See :meth:`Pytester.run`.""" + return self._pytester.run(*cmdargs, timeout=timeout, stdin=stdin) + + def runpython(self, script) -> RunResult: + """See :meth:`Pytester.runpython`.""" + return self._pytester.runpython(script) + + def runpython_c(self, command): + """See :meth:`Pytester.runpython_c`.""" + return self._pytester.runpython_c(command) + + def runpytest_subprocess(self, *args, timeout=None) -> RunResult: + """See :meth:`Pytester.runpytest_subprocess`.""" + return self._pytester.runpytest_subprocess(*args, timeout=timeout) + + def spawn_pytest( + self, string: str, expect_timeout: float = 10.0 + ) -> "pexpect.spawn": + """See :meth:`Pytester.spawn_pytest`.""" + return self._pytester.spawn_pytest(string, expect_timeout=expect_timeout) + + def spawn(self, cmd: str, expect_timeout: float = 10.0) -> "pexpect.spawn": + """See :meth:`Pytester.spawn`.""" + return self._pytester.spawn(cmd, expect_timeout=expect_timeout) + + def __repr__(self) -> str: + return f"" + + def __str__(self) -> str: + return str(self.tmpdir) + + class LineMatcher: """Flexible matching of text. @@ -1316,7 +1708,15 @@ class LineMatcher: def __init__(self, lines: List[str]) -> None: self.lines = lines - self._log_output = [] # type: List[str] + self._log_output: List[str] = [] + + def __str__(self) -> str: + """Return the entire original text. + + .. versionadded:: 6.2 + You can use :meth:`str` in older versions. + """ + return "\n".join(self.lines) def _getlines(self, lines2: Union[str, Sequence[str], Source]) -> Sequence[str]: if isinstance(lines2, str): @@ -1326,14 +1726,12 @@ def _getlines(self, lines2: Union[str, Sequence[str], Source]) -> Sequence[str]: return lines2 def fnmatch_lines_random(self, lines2: Sequence[str]) -> None: - """Check lines exist in the output in any order (using :func:`python:fnmatch.fnmatch`). - """ + """Check lines exist in the output in any order (using :func:`python:fnmatch.fnmatch`).""" __tracebackhide__ = True self._match_lines_random(lines2, fnmatch) def re_match_lines_random(self, lines2: Sequence[str]) -> None: - """Check lines exist in the output in any order (using :func:`python:re.match`). - """ + """Check lines exist in the output in any order (using :func:`python:re.match`).""" __tracebackhide__ = True self._match_lines_random(lines2, lambda name, pat: bool(re.match(pat, name))) @@ -1378,8 +1776,8 @@ def fnmatch_lines( wildcards. If they do not match a pytest.fail() is called. The matches and non-matches are also shown as part of the error message. - :param lines2: string patterns to match. - :param consecutive: match lines consecutive? + :param lines2: String patterns to match. + :param consecutive: Match lines consecutively? """ __tracebackhide__ = True self._match_lines(lines2, fnmatch, "fnmatch", consecutive=consecutive) @@ -1411,24 +1809,27 @@ def _match_lines( match_func: Callable[[str, str], bool], match_nickname: str, *, - consecutive: bool = False + consecutive: bool = False, ) -> None: """Underlying implementation of ``fnmatch_lines`` and ``re_match_lines``. - :param list[str] lines2: list of string patterns to match. The actual - format depends on ``match_func`` - :param match_func: a callable ``match_func(line, pattern)`` where line - is the captured line from stdout/stderr and pattern is the matching - pattern - :param str match_nickname: the nickname for the match function that - will be logged to stdout when a match occurs - :param consecutive: match lines consecutively? + :param Sequence[str] lines2: + List of string patterns to match. The actual format depends on + ``match_func``. + :param match_func: + A callable ``match_func(line, pattern)`` where line is the + captured line from stdout/stderr and pattern is the matching + pattern. + :param str match_nickname: + The nickname for the match function that will be logged to stdout + when a match occurs. + :param consecutive: + Match lines consecutively? """ if not isinstance(lines2, collections.abc.Sequence): raise TypeError("invalid type for lines2: {}".format(type(lines2).__name__)) lines2 = self._getlines(lines2) lines1 = self.lines[:] - nextline = None extralines = [] __tracebackhide__ = True wnick = len(match_nickname) + 1 @@ -1450,7 +1851,7 @@ def _match_lines( break else: if consecutive and started: - msg = "no consecutive match: {!r}".format(line) + msg = f"no consecutive match: {line!r}" self._log(msg) self._log( "{:>{width}}".format("with:", width=wnick), repr(nextline) @@ -1464,7 +1865,7 @@ def _match_lines( self._log("{:>{width}}".format("and:", width=wnick), repr(nextline)) extralines.append(nextline) else: - msg = "remains unmatched: {!r}".format(line) + msg = f"remains unmatched: {line!r}" self._log(msg) self._fail(msg) self._log_output = [] @@ -1472,7 +1873,7 @@ def _match_lines( def no_fnmatch_line(self, pat: str) -> None: """Ensure captured lines do not match the given pattern, using ``fnmatch.fnmatch``. - :param str pat: the pattern to match lines. + :param str pat: The pattern to match lines. """ __tracebackhide__ = True self._no_match_line(pat, fnmatch, "fnmatch") @@ -1480,7 +1881,7 @@ def no_fnmatch_line(self, pat: str) -> None: def no_re_match_line(self, pat: str) -> None: """Ensure captured lines do not match the given pattern, using ``re.match``. - :param str pat: the regular expression to match lines. + :param str pat: The regular expression to match lines. """ __tracebackhide__ = True self._no_match_line( @@ -1490,16 +1891,16 @@ def no_re_match_line(self, pat: str) -> None: def _no_match_line( self, pat: str, match_func: Callable[[str, str], bool], match_nickname: str ) -> None: - """Ensure captured lines does not have a the given pattern, using ``fnmatch.fnmatch`` + """Ensure captured lines does not have a the given pattern, using ``fnmatch.fnmatch``. - :param str pat: the pattern to match lines + :param str pat: The pattern to match lines. """ __tracebackhide__ = True nomatch_printed = False wnick = len(match_nickname) + 1 for line in self.lines: if match_func(line, pat): - msg = "{}: {!r}".format(match_nickname, pat) + msg = f"{match_nickname}: {pat!r}" self._log(msg) self._log("{:>{width}}".format("with:", width=wnick), repr(line)) self._fail(msg) @@ -1514,8 +1915,8 @@ def _fail(self, msg: str) -> None: __tracebackhide__ = True log_text = self._log_text self._log_output = [] - pytest.fail(log_text) + fail(log_text) def str(self) -> str: """Return the entire original text.""" - return "\n".join(self.lines) + return str(self) diff --git a/src/_pytest/pytester_assertions.py b/src/_pytest/pytester_assertions.py new file mode 100644 index 00000000000..630c1d3331c --- /dev/null +++ b/src/_pytest/pytester_assertions.py @@ -0,0 +1,66 @@ +"""Helper plugin for pytester; should not be loaded on its own.""" +# This plugin contains assertions used by pytester. pytester cannot +# contain them itself, since it is imported by the `pytest` module, +# hence cannot be subject to assertion rewriting, which requires a +# module to not be already imported. +from typing import Dict +from typing import Sequence +from typing import Tuple +from typing import Union + +from _pytest.reports import CollectReport +from _pytest.reports import TestReport + + +def assertoutcome( + outcomes: Tuple[ + Sequence[TestReport], + Sequence[Union[CollectReport, TestReport]], + Sequence[Union[CollectReport, TestReport]], + ], + passed: int = 0, + skipped: int = 0, + failed: int = 0, +) -> None: + __tracebackhide__ = True + + realpassed, realskipped, realfailed = outcomes + obtained = { + "passed": len(realpassed), + "skipped": len(realskipped), + "failed": len(realfailed), + } + expected = {"passed": passed, "skipped": skipped, "failed": failed} + assert obtained == expected, outcomes + + +def assert_outcomes( + outcomes: Dict[str, int], + passed: int = 0, + skipped: int = 0, + failed: int = 0, + errors: int = 0, + xpassed: int = 0, + xfailed: int = 0, +) -> None: + """Assert that the specified outcomes appear with the respective + numbers (0 means it didn't occur) in the text output from a test run.""" + __tracebackhide__ = True + + obtained = { + "passed": outcomes.get("passed", 0), + "skipped": outcomes.get("skipped", 0), + "failed": outcomes.get("failed", 0), + "errors": outcomes.get("errors", 0), + "xpassed": outcomes.get("xpassed", 0), + "xfailed": outcomes.get("xfailed", 0), + } + expected = { + "passed": passed, + "skipped": skipped, + "failed": failed, + "errors": errors, + "xpassed": xpassed, + "xfailed": xfailed, + } + assert obtained == expected diff --git a/src/_pytest/python.py b/src/_pytest/python.py index e260761794d..e48e7531c19 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -1,22 +1,29 @@ -""" Python test discovery, setup and run of test functions. """ +"""Python test discovery, setup and run of test functions.""" import enum import fnmatch import inspect import itertools import os import sys -import typing +import types import warnings from collections import Counter from collections import defaultdict -from collections.abc import Sequence from functools import partial +from typing import Any from typing import Callable from typing import Dict +from typing import Generator from typing import Iterable +from typing import Iterator from typing import List +from typing import Mapping from typing import Optional +from typing import Sequence +from typing import Set from typing import Tuple +from typing import Type +from typing import TYPE_CHECKING from typing import Union import py @@ -25,51 +32,52 @@ from _pytest import fixtures from _pytest import nodes from _pytest._code import filter_traceback +from _pytest._code import getfslineno from _pytest._code.code import ExceptionInfo -from _pytest._code.source import getfslineno +from _pytest._code.code import TerminalRepr from _pytest._io import TerminalWriter from _pytest._io.saferepr import saferepr from _pytest.compat import ascii_escaped +from _pytest.compat import final from _pytest.compat import get_default_arg_names from _pytest.compat import get_real_func from _pytest.compat import getimfunc from _pytest.compat import getlocation +from _pytest.compat import is_async_function from _pytest.compat import is_generator -from _pytest.compat import iscoroutinefunction from _pytest.compat import NOTSET from _pytest.compat import REGEX_TYPE from _pytest.compat import safe_getattr from _pytest.compat import safe_isclass from _pytest.compat import STRING_TYPES from _pytest.config import Config +from _pytest.config import ExitCode from _pytest.config import hookimpl -from _pytest.deprecated import FUNCARGNAMES +from _pytest.config.argparsing import Parser +from _pytest.deprecated import FSCOLLECTOR_GETHOOKPROXY_ISINITPATH from _pytest.fixtures import FuncFixtureInfo +from _pytest.main import Session from _pytest.mark import MARK_GEN from _pytest.mark import ParameterSet from _pytest.mark.structures import get_unpacked_marks from _pytest.mark.structures import Mark +from _pytest.mark.structures import MarkDecorator from _pytest.mark.structures import normalize_mark_list from _pytest.outcomes import fail from _pytest.outcomes import skip +from _pytest.pathlib import import_path +from _pytest.pathlib import ImportPathMismatchError from _pytest.pathlib import parts +from _pytest.pathlib import visit from _pytest.warning_types import PytestCollectionWarning from _pytest.warning_types import PytestUnhandledCoroutineWarning +if TYPE_CHECKING: + from typing_extensions import Literal + from _pytest.fixtures import _Scope -def pyobj_property(name): - def get(self): - node = self.getparent(getattr(__import__("pytest"), name)) - if node is not None: - return node.obj - doc = "python {} object this node was collected from (can be None).".format( - name.lower() - ) - return property(get, None, None, doc) - - -def pytest_addoption(parser): +def pytest_addoption(parser: Parser) -> None: group = parser.getgroup("general") group.addoption( "--fixtures", @@ -114,32 +122,24 @@ def pytest_addoption(parser): "side effects(use at your own risk)", ) - group.addoption( - "--import-mode", - default="prepend", - choices=["prepend", "append"], - dest="importmode", - help="prepend/append to sys.path when importing test modules, " - "default is to prepend.", - ) - -def pytest_cmdline_main(config): +def pytest_cmdline_main(config: Config) -> Optional[Union[int, ExitCode]]: if config.option.showfixtures: showfixtures(config) return 0 if config.option.show_fixtures_per_test: show_fixtures_per_test(config) return 0 + return None def pytest_generate_tests(metafunc: "Metafunc") -> None: for marker in metafunc.definition.iter_markers(name="parametrize"): # TODO: Fix this type-ignore (overlapping kwargs). - metafunc.parametrize(*marker.args, **marker.kwargs, _param_mark=marker) # type: ignore[misc] # noqa: F821 + metafunc.parametrize(*marker.args, **marker.kwargs, _param_mark=marker) # type: ignore[misc] -def pytest_configure(config): +def pytest_configure(config: Config) -> None: config.addinivalue_line( "markers", "parametrize(argnames, argvalues): call a test function multiple " @@ -148,84 +148,85 @@ def pytest_configure(config): "or a list of tuples of values if argnames specifies multiple names. " "Example: @parametrize('arg1', [1,2]) would lead to two calls of the " "decorated test function, one with arg1=1 and another with arg1=2." - "see https://docs.pytest.org/en/latest/parametrize.html for more info " + "see https://docs.pytest.org/en/stable/parametrize.html for more info " "and examples.", ) config.addinivalue_line( "markers", "usefixtures(fixturename1, fixturename2, ...): mark tests as needing " "all of the specified fixtures. see " - "https://docs.pytest.org/en/latest/fixture.html#usefixtures ", + "https://docs.pytest.org/en/stable/fixture.html#usefixtures ", ) -def async_warn(nodeid: str) -> None: +def async_warn_and_skip(nodeid: str) -> None: msg = "async def functions are not natively supported and have been skipped.\n" msg += ( "You need to install a suitable plugin for your async framework, for example:\n" ) + msg += " - anyio\n" msg += " - pytest-asyncio\n" - msg += " - pytest-trio\n" msg += " - pytest-tornasync\n" + msg += " - pytest-trio\n" msg += " - pytest-twisted" warnings.warn(PytestUnhandledCoroutineWarning(msg.format(nodeid))) skip(msg="async def function and no async plugin installed (see warnings)") @hookimpl(trylast=True) -def pytest_pyfunc_call(pyfuncitem: "Function"): +def pytest_pyfunc_call(pyfuncitem: "Function") -> Optional[object]: testfunction = pyfuncitem.obj - if iscoroutinefunction(testfunction) or ( - sys.version_info >= (3, 6) and inspect.isasyncgenfunction(testfunction) - ): - async_warn(pyfuncitem.nodeid) + if is_async_function(testfunction): + async_warn_and_skip(pyfuncitem.nodeid) funcargs = pyfuncitem.funcargs testargs = {arg: funcargs[arg] for arg in pyfuncitem._fixtureinfo.argnames} result = testfunction(**testargs) if hasattr(result, "__await__") or hasattr(result, "__aiter__"): - async_warn(pyfuncitem.nodeid) + async_warn_and_skip(pyfuncitem.nodeid) return True -def pytest_collect_file(path, parent): +def pytest_collect_file( + path: py.path.local, parent: nodes.Collector +) -> Optional["Module"]: ext = path.ext if ext == ".py": if not parent.session.isinitpath(path): if not path_matches_patterns( path, parent.config.getini("python_files") + ["__init__.py"] ): - return + return None ihook = parent.session.gethookproxy(path) - return ihook.pytest_pycollect_makemodule(path=path, parent=parent) + module: Module = ihook.pytest_pycollect_makemodule(path=path, parent=parent) + return module + return None -def path_matches_patterns(path, patterns): - """Returns True if the given py.path.local matches one of the patterns in the list of globs given""" +def path_matches_patterns(path: py.path.local, patterns: Iterable[str]) -> bool: + """Return whether path matches any of the patterns in the list of globs given.""" return any(path.fnmatch(pattern) for pattern in patterns) -def pytest_pycollect_makemodule(path, parent): +def pytest_pycollect_makemodule(path: py.path.local, parent) -> "Module": if path.basename == "__init__.py": - return Package.from_parent(parent, fspath=path) - return Module.from_parent(parent, fspath=path) + pkg: Package = Package.from_parent(parent, fspath=path) + return pkg + mod: Module = Module.from_parent(parent, fspath=path) + return mod -@hookimpl(hookwrapper=True) -def pytest_pycollect_makeitem(collector, name, obj): - outcome = yield - res = outcome.get_result() - if res is not None: - return - # nothing was collected elsewhere, let's do it here +@hookimpl(trylast=True) +def pytest_pycollect_makeitem(collector: "PyCollector", name: str, obj: object): + # Nothing was collected elsewhere, let's do it here. if safe_isclass(obj): if collector.istestclass(obj, name): - outcome.force_result(Class.from_parent(collector, name=name, obj=obj)) + return Class.from_parent(collector, name=name, obj=obj) elif collector.istestfunction(obj, name): - # mock seems to store unbound methods (issue473), normalize it + # mock seems to store unbound methods (issue473), normalize it. obj = getattr(obj, "__func__", obj) # We need to try and unwrap the function if it's a functools.partial # or a functools.wrapped. - # We mustn't if it's been wrapped with mock.patch (python 2 only) + # We mustn't if it's been wrapped with mock.patch (python 2 only). if not (inspect.isfunction(obj) or inspect.isfunction(get_real_func(obj))): filename, lineno = getfslineno(obj) warnings.warn_explicit( @@ -246,15 +247,42 @@ def pytest_pycollect_makeitem(collector, name, obj): res.warn(PytestCollectionWarning(reason)) else: res = list(collector._genfunctions(name, obj)) - outcome.force_result(res) + return res class PyobjMixin: - module = pyobj_property("Module") - cls = pyobj_property("Class") - instance = pyobj_property("Instance") _ALLOW_MARKERS = True + # Function and attributes that the mixin needs (for type-checking only). + if TYPE_CHECKING: + name: str = "" + parent: Optional[nodes.Node] = None + own_markers: List[Mark] = [] + + def getparent(self, cls: Type[nodes._NodeType]) -> Optional[nodes._NodeType]: + ... + + def listchain(self) -> List[nodes.Node]: + ... + + @property + def module(self): + """Python module object this node was collected from (can be None).""" + node = self.getparent(Module) + return node.obj if node is not None else None + + @property + def cls(self): + """Python class object this node was collected from (can be None).""" + node = self.getparent(Class) + return node.obj if node is not None else None + + @property + def instance(self): + """Python instance object this node was collected from (can be None).""" + node = self.getparent(Instance) + return node.obj if node is not None else None + @property def obj(self): """Underlying Python object.""" @@ -272,11 +300,14 @@ def obj(self, value): self._obj = value def _getobj(self): - """Gets the underlying Python object. May be overwritten by subclasses.""" - return getattr(self.parent.obj, self.name) - - def getmodpath(self, stopatmodule=True, includemodule=False): - """ return python path relative to the containing module. """ + """Get the underlying Python object. May be overwritten by subclasses.""" + # TODO: Improve the type of `parent` such that assert/ignore aren't needed. + assert self.parent is not None + obj = self.parent.obj # type: ignore[attr-defined] + return getattr(obj, self.name) + + def getmodpath(self, stopatmodule: bool = True, includemodule: bool = False) -> str: + """Return Python path relative to the containing module.""" chain = self.listchain() chain.reverse() parts = [] @@ -303,7 +334,7 @@ def reportinfo(self) -> Tuple[Union[py.path.local, str], int, str]: file_path = sys.modules[obj.__module__].__file__ if file_path.endswith(".pyc"): file_path = file_path[:-1] - fspath = file_path # type: Union[py.path.local, str] + fspath: Union[py.path.local, str] = file_path lineno = compat_co_firstlineno else: fspath, lineno = getfslineno(obj) @@ -312,26 +343,46 @@ def reportinfo(self) -> Tuple[Union[py.path.local, str], int, str]: return fspath, lineno, modpath +# As an optimization, these builtin attribute names are pre-ignored when +# iterating over an object during collection -- the pytest_pycollect_makeitem +# hook is not called for them. +# fmt: off +class _EmptyClass: pass # noqa: E701 +IGNORED_ATTRIBUTES = frozenset.union( # noqa: E305 + frozenset(), + # Module. + dir(types.ModuleType("empty_module")), + # Some extra module attributes the above doesn't catch. + {"__builtins__", "__file__", "__cached__"}, + # Class. + dir(_EmptyClass), + # Instance. + dir(_EmptyClass()), +) +del _EmptyClass +# fmt: on + + class PyCollector(PyobjMixin, nodes.Collector): - def funcnamefilter(self, name): + def funcnamefilter(self, name: str) -> bool: return self._matches_prefix_or_glob_option("python_functions", name) - def isnosetest(self, obj): - """ Look for the __test__ attribute, which is applied by the - @nose.tools.istest decorator + def isnosetest(self, obj: object) -> bool: + """Look for the __test__ attribute, which is applied by the + @nose.tools.istest decorator. """ # We explicitly check for "is True" here to not mistakenly treat # classes with a custom __getattr__ returning something truthy (like a # function) as test classes. return safe_getattr(obj, "__test__", False) is True - def classnamefilter(self, name): + def classnamefilter(self, name: str) -> bool: return self._matches_prefix_or_glob_option("python_classes", name) - def istestfunction(self, obj, name): + def istestfunction(self, obj: object, name: str) -> bool: if self.funcnamefilter(name) or self.isnosetest(obj): if isinstance(obj, staticmethod): - # static methods need to be unwrapped + # staticmethods need to be unwrapped. obj = safe_getattr(obj, "__func__", False) return ( safe_getattr(obj, "__call__", False) @@ -340,48 +391,54 @@ def istestfunction(self, obj, name): else: return False - def istestclass(self, obj, name): + def istestclass(self, obj: object, name: str) -> bool: return self.classnamefilter(name) or self.isnosetest(obj) - def _matches_prefix_or_glob_option(self, option_name, name): - """ - checks if the given name matches the prefix or glob-pattern defined - in ini configuration. - """ + def _matches_prefix_or_glob_option(self, option_name: str, name: str) -> bool: + """Check if the given name matches the prefix or glob-pattern defined + in ini configuration.""" for option in self.config.getini(option_name): if name.startswith(option): return True - # check that name looks like a glob-string before calling fnmatch + # Check that name looks like a glob-string before calling fnmatch # because this is called for every name in each collected module, - # and fnmatch is somewhat expensive to call + # and fnmatch is somewhat expensive to call. elif ("*" in option or "?" in option or "[" in option) and fnmatch.fnmatch( name, option ): return True return False - def collect(self): + def collect(self) -> Iterable[Union[nodes.Item, nodes.Collector]]: if not getattr(self.obj, "__test__", True): return [] # NB. we avoid random getattrs and peek in the __dict__ instead # (XXX originally introduced from a PyPy need, still true?) dicts = [getattr(self.obj, "__dict__", {})] - for basecls in inspect.getmro(self.obj.__class__): + for basecls in self.obj.__class__.__mro__: dicts.append(basecls.__dict__) - seen = {} - values = [] + seen: Set[str] = set() + values: List[Union[nodes.Item, nodes.Collector]] = [] + ihook = self.ihook for dic in dicts: + # Note: seems like the dict can change during iteration - + # be careful not to remove the list() without consideration. for name, obj in list(dic.items()): + if name in IGNORED_ATTRIBUTES: + continue if name in seen: continue - seen[name] = True - res = self._makeitem(name, obj) + seen.add(name) + res = ihook.pytest_pycollect_makeitem( + collector=self, name=name, obj=obj + ) if res is None: continue - if not isinstance(res, list): - res = [res] - values.extend(res) + elif isinstance(res, list): + values.extend(res) + else: + values.append(res) def sort_key(item): fspath, lineno, _ = item.reportinfo() @@ -390,12 +447,10 @@ def sort_key(item): values.sort(key=sort_key) return values - def _makeitem(self, name, obj): - # assert self.ihook.fspath == self.fspath, self - return self.ihook.pytest_pycollect_makeitem(collector=self, name=name, obj=obj) - - def _genfunctions(self, name, funcobj): - module = self.getparent(Module).obj + def _genfunctions(self, name: str, funcobj) -> Iterator["Function"]: + modulecol = self.getparent(Module) + assert modulecol is not None + module = modulecol.obj clscol = self.getparent(Class) cls = clscol and clscol.obj or None fm = self.session._fixturemanager @@ -409,7 +464,7 @@ def _genfunctions(self, name, funcobj): methods = [] if hasattr(module, "pytest_generate_tests"): methods.append(module.pytest_generate_tests) - if hasattr(cls, "pytest_generate_tests"): + if cls is not None and hasattr(cls, "pytest_generate_tests"): methods.append(cls().pytest_generate_tests) self.ihook.pytest_generate_tests.call_extra(methods, dict(metafunc=metafunc)) @@ -417,16 +472,16 @@ def _genfunctions(self, name, funcobj): if not metafunc._calls: yield Function.from_parent(self, name=name, fixtureinfo=fixtureinfo) else: - # add funcargs() as fixturedefs to fixtureinfo.arg2fixturedefs + # Add funcargs() as fixturedefs to fixtureinfo.arg2fixturedefs. fixtures.add_funcarg_pseudo_fixture_def(self, metafunc, fm) - # add_funcarg_pseudo_fixture_def may have shadowed some fixtures + # Add_funcarg_pseudo_fixture_def may have shadowed some fixtures # with direct parametrization, so make sure we update what the # function really needs. fixtureinfo.prune_dependency_tree() for callspec in metafunc._calls: - subname = "{}[{}]".format(name, callspec.id) + subname = f"{name}[{callspec.id}]" yield Function.from_parent( self, name=subname, @@ -439,19 +494,19 @@ def _genfunctions(self, name, funcobj): class Module(nodes.File, PyCollector): - """ Collector for test classes and functions. """ + """Collector for test classes and functions.""" def _getobj(self): return self._importtestmodule() - def collect(self): + def collect(self) -> Iterable[Union[nodes.Item, nodes.Collector]]: self._inject_setup_module_fixture() self._inject_setup_function_fixture() self.session._fixturemanager.parsefactories(self) return super().collect() - def _inject_setup_module_fixture(self): - """Injects a hidden autouse, module scoped fixture into the collected module object + def _inject_setup_module_fixture(self) -> None: + """Inject a hidden autouse, module scoped fixture into the collected module object that invokes setUpModule/tearDownModule if either or both are available. Using a fixture to invoke this methods ensures we play nicely and unsurprisingly with @@ -467,8 +522,13 @@ def _inject_setup_module_fixture(self): if setup_module is None and teardown_module is None: return - @fixtures.fixture(autouse=True, scope="module") - def xunit_setup_module_fixture(request): + @fixtures.fixture( + autouse=True, + scope="module", + # Use a unique name to speed up lookup. + name=f"xunit_setup_module_fixture_{self.obj.__name__}", + ) + def xunit_setup_module_fixture(request) -> Generator[None, None, None]: if setup_module is not None: _call_with_optional_argument(setup_module, request.module) yield @@ -477,8 +537,8 @@ def xunit_setup_module_fixture(request): self.obj.__pytest_setup_module = xunit_setup_module_fixture - def _inject_setup_function_fixture(self): - """Injects a hidden autouse, function scoped fixture into the collected module object + def _inject_setup_function_fixture(self) -> None: + """Inject a hidden autouse, function scoped fixture into the collected module object that invokes setup_function/teardown_function if either or both are available. Using a fixture to invoke this methods ensures we play nicely and unsurprisingly with @@ -491,8 +551,13 @@ def _inject_setup_function_fixture(self): if setup_function is None and teardown_function is None: return - @fixtures.fixture(autouse=True, scope="function") - def xunit_setup_function_fixture(request): + @fixtures.fixture( + autouse=True, + scope="function", + # Use a unique name to speed up lookup. + name=f"xunit_setup_function_fixture_{self.obj.__name__}", + ) + def xunit_setup_function_fixture(request) -> Generator[None, None, None]: if request.instance is not None: # in this case we are bound to an instance, so we need to let # setup_method handle this @@ -507,13 +572,15 @@ def xunit_setup_function_fixture(request): self.obj.__pytest_setup_function = xunit_setup_function_fixture def _importtestmodule(self): - # we assume we are only called once per module + # We assume we are only called once per module. importmode = self.config.getoption("--import-mode") try: - mod = self.fspath.pyimport(ensuresyspath=importmode) - except SyntaxError: - raise self.CollectError(ExceptionInfo.from_current().getrepr(style="short")) - except self.fspath.ImportMismatchError as e: + mod = import_path(self.fspath, mode=importmode) + except SyntaxError as e: + raise self.CollectError( + ExceptionInfo.from_current().getrepr(style="short") + ) from e + except ImportPathMismatchError as e: raise self.CollectError( "import file mismatch:\n" "imported module %r has this __file__ attribute:\n" @@ -522,8 +589,8 @@ def _importtestmodule(self): " %s\n" "HINT: remove __pycache__ / .pyc files and/or use a " "unique basename for your test file modules" % e.args - ) - except ImportError: + ) from e + except ImportError as e: exc_info = ExceptionInfo.from_current() if self.config.getoption("verbose") < 2: exc_info.traceback = exc_info.traceback.filter(filter_traceback) @@ -538,8 +605,8 @@ def _importtestmodule(self): "Hint: make sure your test modules/packages have valid Python names.\n" "Traceback:\n" "{traceback}".format(fspath=self.fspath, traceback=formatted_tb) - ) - except _pytest.runner.Skipped as e: + ) from e + except skip.Exception as e: if e.allow_module_level: raise raise self.CollectError( @@ -547,7 +614,7 @@ def _importtestmodule(self): "To decorate a test function, use the @pytest.mark.skip " "or @pytest.mark.skipif decorators instead, and to skip a " "module use `pytestmark = pytest.mark.{skip,skipif}." - ) + ) from e self.config.pluginmanager.consider_module(mod) return mod @@ -562,18 +629,17 @@ def __init__( session=None, nodeid=None, ) -> None: - # NOTE: could be just the following, but kept as-is for compat. + # NOTE: Could be just the following, but kept as-is for compat. # nodes.FSCollector.__init__(self, fspath, parent=parent) session = parent.session nodes.FSCollector.__init__( self, fspath, parent=parent, config=config, session=session, nodeid=nodeid ) + self.name = os.path.basename(str(fspath.dirname)) - self.name = fspath.dirname - - def setup(self): - # not using fixtures to call setup_module here because autouse fixtures - # from packages are not called automatically (#4085) + def setup(self) -> None: + # Not using fixtures to call setup_module here because autouse fixtures + # from packages are not called automatically (#4085). setup_module = _get_first_non_fixture_func( self.obj, ("setUpModule", "setup_module") ) @@ -588,45 +654,84 @@ def setup(self): self.addfinalizer(func) def gethookproxy(self, fspath: py.path.local): - return super()._gethookproxy(fspath) + warnings.warn(FSCOLLECTOR_GETHOOKPROXY_ISINITPATH, stacklevel=2) + return self.session.gethookproxy(fspath) - def isinitpath(self, path): - return path in self.session._initialpaths + def isinitpath(self, path: py.path.local) -> bool: + warnings.warn(FSCOLLECTOR_GETHOOKPROXY_ISINITPATH, stacklevel=2) + return self.session.isinitpath(path) - def collect(self): + def _recurse(self, direntry: "os.DirEntry[str]") -> bool: + if direntry.name == "__pycache__": + return False + path = py.path.local(direntry.path) + ihook = self.session.gethookproxy(path.dirpath()) + if ihook.pytest_ignore_collect(path=path, config=self.config): + return False + norecursepatterns = self.config.getini("norecursedirs") + if any(path.check(fnmatch=pat) for pat in norecursepatterns): + return False + return True + + def _collectfile( + self, path: py.path.local, handle_dupes: bool = True + ) -> Sequence[nodes.Collector]: + assert ( + path.isfile() + ), "{!r} is not a file (isdir={!r}, exists={!r}, islink={!r})".format( + path, path.isdir(), path.exists(), path.islink() + ) + ihook = self.session.gethookproxy(path) + if not self.session.isinitpath(path): + if ihook.pytest_ignore_collect(path=path, config=self.config): + return () + + if handle_dupes: + keepduplicates = self.config.getoption("keepduplicates") + if not keepduplicates: + duplicate_paths = self.config.pluginmanager._duplicatepaths + if path in duplicate_paths: + return () + else: + duplicate_paths.add(path) + + return ihook.pytest_collect_file(path=path, parent=self) # type: ignore[no-any-return] + + def collect(self) -> Iterable[Union[nodes.Item, nodes.Collector]]: this_path = self.fspath.dirpath() init_module = this_path.join("__init__.py") if init_module.check(file=1) and path_matches_patterns( init_module, self.config.getini("python_files") ): yield Module.from_parent(self, fspath=init_module) - pkg_prefixes = set() - for path in this_path.visit(rec=self._recurse, bf=True, sort=True): + pkg_prefixes: Set[py.path.local] = set() + for direntry in visit(str(this_path), recurse=self._recurse): + path = py.path.local(direntry.path) + # We will visit our own __init__.py file, in which case we skip it. - is_file = path.isfile() - if is_file: - if path.basename == "__init__.py" and path.dirpath() == this_path: + if direntry.is_file(): + if direntry.name == "__init__.py" and path.dirpath() == this_path: continue - parts_ = parts(path.strpath) + parts_ = parts(direntry.path) if any( - pkg_prefix in parts_ and pkg_prefix.join("__init__.py") != path + str(pkg_prefix) in parts_ and pkg_prefix.join("__init__.py") != path for pkg_prefix in pkg_prefixes ): continue - if is_file: + if direntry.is_file(): yield from self._collectfile(path) - elif not path.isdir(): + elif not direntry.is_dir(): # Broken symlink or invalid/missing file. continue elif path.join("__init__.py").check(file=1): pkg_prefixes.add(path) -def _call_with_optional_argument(func, arg): +def _call_with_optional_argument(func, arg) -> None: """Call the given function with the given argument if func accepts one argument, otherwise - calls func without arguments""" + calls func without arguments.""" arg_count = func.__code__.co_argcount if inspect.ismethod(func): arg_count -= 1 @@ -636,11 +741,9 @@ def _call_with_optional_argument(func, arg): func() -def _get_first_non_fixture_func(obj, names): +def _get_first_non_fixture_func(obj: object, names: Iterable[str]): """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 = getattr(obj, name, None) if meth is not None and fixtures.getfixturemarker(meth) is None: @@ -648,19 +751,18 @@ def _get_first_non_fixture_func(obj, names): class Class(PyCollector): - """ Collector for test methods. """ + """Collector for test methods.""" @classmethod def from_parent(cls, parent, *, name, obj=None): - """ - The public constructor - """ + """The public constructor.""" return super().from_parent(name=name, parent=parent) - def collect(self): + def collect(self) -> Iterable[Union[nodes.Item, nodes.Collector]]: if not safe_getattr(self.obj, "__test__", True): return [] if hasinit(self.obj): + assert self.parent is not None self.warn( PytestCollectionWarning( "cannot collect test class %r because it has a " @@ -670,6 +772,7 @@ def collect(self): ) return [] elif hasnew(self.obj): + assert self.parent is not None self.warn( PytestCollectionWarning( "cannot collect test class %r because it has a " @@ -684,8 +787,8 @@ def collect(self): return [Instance.from_parent(self, name="()")] - def _inject_setup_class_fixture(self): - """Injects a hidden autouse, class scoped fixture into the collected class object + def _inject_setup_class_fixture(self) -> None: + """Inject a hidden autouse, class scoped fixture into the collected class object that invokes setup_class/teardown_class if either or both are available. Using a fixture to invoke this methods ensures we play nicely and unsurprisingly with @@ -696,8 +799,13 @@ def _inject_setup_class_fixture(self): if setup_class is None and teardown_class is None: return - @fixtures.fixture(autouse=True, scope="class") - def xunit_setup_class_fixture(cls): + @fixtures.fixture( + autouse=True, + scope="class", + # Use a unique name to speed up lookup. + name=f"xunit_setup_class_fixture_{self.obj.__qualname__}", + ) + def xunit_setup_class_fixture(cls) -> Generator[None, None, None]: if setup_class is not None: func = getimfunc(setup_class) _call_with_optional_argument(func, self.obj) @@ -708,8 +816,8 @@ def xunit_setup_class_fixture(cls): self.obj.__pytest_setup_class = xunit_setup_class_fixture - def _inject_setup_method_fixture(self): - """Injects a hidden autouse, function scoped fixture into the collected class object + def _inject_setup_method_fixture(self) -> None: + """Inject a hidden autouse, function scoped fixture into the collected class object that invokes setup_method/teardown_method if either or both are available. Using a fixture to invoke this methods ensures we play nicely and unsurprisingly with @@ -720,8 +828,13 @@ def _inject_setup_method_fixture(self): if setup_method is None and teardown_method is None: return - @fixtures.fixture(autouse=True, scope="function") - def xunit_setup_method_fixture(self, request): + @fixtures.fixture( + autouse=True, + scope="function", + # Use a unique name to speed up lookup. + name=f"xunit_setup_method_fixture_{self.obj.__qualname__}", + ) + def xunit_setup_method_fixture(self, request) -> Generator[None, None, None]: method = request.function if setup_method is not None: func = getattr(self, "setup_method") @@ -736,14 +849,17 @@ def xunit_setup_method_fixture(self, request): class Instance(PyCollector): _ALLOW_MARKERS = False # hack, destroy later - # instances share the object with their parents in a way + # Instances share the object with their parents in a way # that duplicates markers instances if not taken out - # can be removed at node structure reorganization time + # can be removed at node structure reorganization time. def _getobj(self): - return self.parent.obj() + # TODO: Improve the type of `parent` such that assert/ignore aren't needed. + assert self.parent is not None + obj = self.parent.obj # type: ignore[attr-defined] + return obj() - def collect(self): + def collect(self) -> Iterable[Union[nodes.Item, nodes.Collector]]: self.session._fixturemanager.parsefactories(self) return super().collect() @@ -752,29 +868,33 @@ def newinstance(self): return self.obj -def hasinit(obj): - init = getattr(obj, "__init__", None) +def hasinit(obj: object) -> bool: + init: object = getattr(obj, "__init__", None) if init: return init != object.__init__ + return False -def hasnew(obj): - new = getattr(obj, "__new__", None) +def hasnew(obj: object) -> bool: + new: object = getattr(obj, "__new__", None) if new: return new != object.__new__ + return False +@final class CallSpec2: - def __init__(self, metafunc): + def __init__(self, metafunc: "Metafunc") -> None: self.metafunc = metafunc - self.funcargs = {} - self._idlist = [] - self.params = {} - self._arg2scopenum = {} # used for sorting parametrized resources - self.marks = [] - self.indices = {} - - def copy(self): + self.funcargs: Dict[str, object] = {} + self._idlist: List[str] = [] + self.params: Dict[str, object] = {} + # Used for sorting parametrized resources. + self._arg2scopenum: Dict[str, int] = {} + self.marks: List[Mark] = [] + self.indices: Dict[str, int] = {} + + def copy(self) -> "CallSpec2": cs = CallSpec2(self.metafunc) cs.funcargs.update(self.funcargs) cs.params.update(self.params) @@ -784,34 +904,49 @@ def copy(self): cs._idlist = list(self._idlist) return cs - def _checkargnotcontained(self, arg): + def _checkargnotcontained(self, arg: str) -> None: if arg in self.params or arg in self.funcargs: - raise ValueError("duplicate {!r}".format(arg)) + raise ValueError(f"duplicate {arg!r}") - def getparam(self, name): + def getparam(self, name: str) -> object: try: return self.params[name] - except KeyError: - raise ValueError(name) + except KeyError as e: + raise ValueError(name) from e @property - def id(self): + def id(self) -> str: return "-".join(map(str, self._idlist)) - def setmulti2(self, valtypes, argnames, valset, id, marks, scopenum, param_index): + def setmulti2( + self, + valtypes: Mapping[str, "Literal['params', 'funcargs']"], + argnames: Sequence[str], + valset: Iterable[object], + id: str, + marks: Iterable[Union[Mark, MarkDecorator]], + scopenum: int, + param_index: int, + ) -> None: for arg, val in zip(argnames, valset): self._checkargnotcontained(arg) valtype_for_arg = valtypes[arg] - getattr(self, valtype_for_arg)[arg] = val + if valtype_for_arg == "params": + self.params[arg] = val + elif valtype_for_arg == "funcargs": + self.funcargs[arg] = val + else: # pragma: no cover + assert False, f"Unhandled valtype for arg: {valtype_for_arg}" self.indices[arg] = param_index self._arg2scopenum[arg] = scopenum self._idlist.append(id) self.marks.extend(normalize_mark_list(marks)) +@final class Metafunc: - """ - Metafunc objects are passed to the :func:`pytest_generate_tests <_pytest.hookspec.pytest_generate_tests>` hook. + """Objects passed to the :func:`pytest_generate_tests <_pytest.hookspec.pytest_generate_tests>` hook. + They help to inspect a test function and to generate tests according to test configuration or values specified in the class or module where a test function is defined. @@ -825,71 +960,71 @@ def __init__( cls=None, module=None, ) -> None: + #: Access to the underlying :class:`_pytest.python.FunctionDefinition`. self.definition = definition - #: access to the :class:`_pytest.config.Config` object for the test session + #: Access to the :class:`_pytest.config.Config` object for the test session. self.config = config - #: the module object where the test function is defined in. + #: The module object where the test function is defined in. self.module = module - #: underlying python test function + #: Underlying Python test function. self.function = definition.obj - #: set of fixture names required by the test function + #: Set of fixture names required by the test function. self.fixturenames = fixtureinfo.names_closure - #: class object where the test function is defined in or ``None``. + #: Class object where the test function is defined in or ``None``. self.cls = cls - self._calls = [] # type: List[CallSpec2] + self._calls: List[CallSpec2] = [] self._arg2fixturedefs = fixtureinfo.name2fixturedefs - @property - def funcargnames(self): - """ alias attribute for ``fixturenames`` for pre-2.3 compatibility""" - warnings.warn(FUNCARGNAMES, stacklevel=2) - return self.fixturenames - def parametrize( self, argnames: Union[str, List[str], Tuple[str, ...]], - argvalues: Iterable[Union[ParameterSet, typing.Sequence[object], object]], - indirect: Union[bool, typing.Sequence[str]] = False, + argvalues: Iterable[Union[ParameterSet, Sequence[object], object]], + indirect: Union[bool, Sequence[str]] = False, ids: Optional[ Union[ Iterable[Union[None, str, float, int, bool]], - Callable[[object], Optional[object]], + Callable[[Any], Optional[object]], ] ] = None, - scope: "Optional[str]" = None, + scope: "Optional[_Scope]" = None, *, - _param_mark: Optional[Mark] = None + _param_mark: Optional[Mark] = None, ) -> None: - """ Add new invocations to the underlying test function using the list + """Add new invocations to the underlying test function using the list of argvalues for the given argnames. Parametrization is performed during the collection phase. If you need to setup expensive resources see about setting indirect to do it rather at test setup time. - :arg argnames: a comma-separated string denoting one or more argument - names, or a list/tuple of argument strings. + :param argnames: + A comma-separated string denoting one or more argument names, or + a list/tuple of argument strings. + + :param argvalues: + The list of argvalues determines how often a test is invoked with + different argument values. - :arg argvalues: The list of argvalues determines how often a - test is invoked with different argument values. If only one - argname was specified argvalues is a list of values. If N - argnames were specified, argvalues must be a list of N-tuples, - where each tuple-element specifies a value for its respective - argname. + If only one argname was specified argvalues is a list of values. + If N argnames were specified, argvalues must be a list of + N-tuples, where each tuple-element specifies a value for its + respective argname. - :arg indirect: The list of argnames or boolean. A list of arguments' - names (subset of argnames). If True the list contains all names from - the argnames. Each argvalue corresponding to an argname in this list will + :param indirect: + A list of arguments' names (subset of argnames) or a boolean. + If True the list contains all names from the argnames. Each + argvalue corresponding to an argname in this list will be passed as request.param to its respective argname fixture function so that it can perform more expensive setups during the setup phase of a test rather than at collection time. - :arg ids: sequence of (or generator for) ids for ``argvalues``, - or a callable to return part of the id for each argvalue. + :param ids: + Sequence of (or generator for) ids for ``argvalues``, + or a callable to return part of the id for each argvalue. With sequences (and generators like ``itertools.count()``) the returned ids should be of type ``string``, ``int``, ``float``, @@ -907,7 +1042,8 @@ def parametrize( If no ids are provided they will be generated automatically from the argvalues. - :arg scope: if specified it denotes the scope of the parameters. + :param scope: + If specified it denotes the scope of the parameters. The scope is used for grouping tests by parameter instances. It will also override any fixture-function defined scope, allowing to set a dynamic scope using test context or configuration. @@ -919,7 +1055,7 @@ def parametrize( argvalues, self.function, self.config, - function_definition=self.definition, + nodeid=self.definition.nodeid, ) del argvalues @@ -936,27 +1072,27 @@ def parametrize( arg_values_types = self._resolve_arg_value_types(argnames, indirect) - self._validate_explicit_parameters(argnames, indirect) - # Use any already (possibly) generated ids with parametrize Marks. if _param_mark and _param_mark._param_ids_from: generated_ids = _param_mark._param_ids_from._param_ids_generated if generated_ids is not None: ids = generated_ids - ids = self._resolve_arg_ids(argnames, ids, parameters, item=self.definition) + ids = self._resolve_arg_ids( + argnames, ids, parameters, nodeid=self.definition.nodeid + ) # Store used (possibly generated) ids with parametrize Marks. if _param_mark and _param_mark._param_ids_from and generated_ids is None: object.__setattr__(_param_mark._param_ids_from, "_param_ids_generated", ids) scopenum = scope2index( - scope, descr="parametrize() call in {}".format(self.function.__name__) + scope, descr=f"parametrize() call in {self.function.__name__}" ) - # create the new calls: if we are parametrize() multiple times (by applying the decorator + # Create the new calls: if we are parametrize() multiple times (by applying the decorator # more than once) then we accumulate those calls generating the cartesian product - # of all calls + # of all calls. newcalls = [] for callspec in self._calls or [CallSpec2(self)]: for param_index, (param_id, param_set) in enumerate(zip(ids, parameters)): @@ -975,25 +1111,25 @@ def parametrize( def _resolve_arg_ids( self, - argnames: typing.Sequence[str], + argnames: Sequence[str], ids: Optional[ Union[ Iterable[Union[None, str, float, int, bool]], - Callable[[object], Optional[object]], + Callable[[Any], Optional[object]], ] ], - parameters: typing.Sequence[ParameterSet], - item, + parameters: Sequence[ParameterSet], + nodeid: str, ) -> List[str]: - """Resolves the actual ids for the given argnames, based on the ``ids`` parameter given + """Resolve the actual ids for the given argnames, based on the ``ids`` parameter given to ``parametrize``. - :param List[str] argnames: list of argument names passed to ``parametrize()``. - :param ids: the ids parameter of the parametrized call (see docs). - :param List[ParameterSet] parameters: the list of parameter values, same size as ``argnames``. - :param Item item: the item that generated this parametrized call. + :param List[str] argnames: List of argument names passed to ``parametrize()``. + :param ids: The ids parameter of the parametrized call (see docs). + :param List[ParameterSet] parameters: The list of parameter values, same size as ``argnames``. + :param str str: The nodeid of the item that generated this parametrized call. :rtype: List[str] - :return: the list of ids for each argname given + :returns: The list of ids for each argname given. """ if ids is None: idfn = None @@ -1004,21 +1140,21 @@ def _resolve_arg_ids( else: idfn = None ids_ = self._validate_ids(ids, parameters, self.function.__name__) - return idmaker(argnames, parameters, idfn, ids_, self.config, item=item) + return idmaker(argnames, parameters, idfn, ids_, self.config, nodeid=nodeid) def _validate_ids( self, ids: Iterable[Union[None, str, float, int, bool]], - parameters: typing.Sequence[ParameterSet], + parameters: Sequence[ParameterSet], func_name: str, ) -> List[Union[None, str]]: try: - num_ids = len(ids) # type: ignore[arg-type] # noqa: F821 + num_ids = len(ids) # type: ignore[arg-type] except TypeError: try: iter(ids) - except TypeError: - raise TypeError("ids must be a callable or an iterable") + except TypeError as e: + raise TypeError("ids must be a callable or an iterable") from e num_ids = len(parameters) # num_ids == 0 is a special case: https://github.com/pytest-dev/pytest/issues/1849 @@ -1033,7 +1169,10 @@ def _validate_ids( elif isinstance(id_value, (float, int, bool)): new_ids.append(str(id_value)) else: - msg = "In {}: ids must be list of string/float/int/bool, found: {} (type: {!r}) at index {}" + msg = ( # type: ignore[unreachable] + "In {}: ids must be list of string/float/int/bool, " + "found: {} (type: {!r}) at index {}" + ) fail( msg.format(func_name, saferepr(id_value), type(id_value), idx), pytrace=False, @@ -1041,22 +1180,23 @@ def _validate_ids( return new_ids def _resolve_arg_value_types( - self, - argnames: typing.Sequence[str], - indirect: Union[bool, typing.Sequence[str]], - ) -> Dict[str, str]: - """Resolves if each parametrized argument must be considered a parameter to a fixture or a "funcarg" - to the function, based on the ``indirect`` parameter of the parametrized() call. - - :param List[str] argnames: list of argument names passed to ``parametrize()``. - :param indirect: same ``indirect`` parameter of ``parametrize()``. + self, argnames: Sequence[str], indirect: Union[bool, Sequence[str]], + ) -> Dict[str, "Literal['params', 'funcargs']"]: + """Resolve if each parametrized argument must be considered a + parameter to a fixture or a "funcarg" to the function, based on the + ``indirect`` parameter of the parametrized() call. + + :param List[str] argnames: List of argument names passed to ``parametrize()``. + :param indirect: Same as the ``indirect`` parameter of ``parametrize()``. :rtype: Dict[str, str] A dict mapping each arg name to either: * "params" if the argname should be the parameter of a fixture of the same name. * "funcargs" if the argname should be a parameter to the parametrized test function. """ if isinstance(indirect, bool): - valtypes = dict.fromkeys(argnames, "params" if indirect else "funcargs") + valtypes: Dict[str, Literal["params", "funcargs"]] = dict.fromkeys( + argnames, "params" if indirect else "funcargs" + ) elif isinstance(indirect, Sequence): valtypes = dict.fromkeys(argnames, "funcargs") for arg in indirect: @@ -1078,16 +1218,13 @@ def _resolve_arg_value_types( return valtypes def _validate_if_using_arg_names( - self, - argnames: typing.Sequence[str], - indirect: Union[bool, typing.Sequence[str]], + self, argnames: Sequence[str], indirect: Union[bool, Sequence[str]], ) -> None: - """ - Check if all argnames are being used, by default values, or directly/indirectly. + """Check if all argnames are being used, by default values, or directly/indirectly. - :param List[str] argnames: list of argument names passed to ``parametrize()``. - :param indirect: same ``indirect`` parameter of ``parametrize()``. - :raise ValueError: if validation fails. + :param List[str] argnames: List of argument names passed to ``parametrize()``. + :param indirect: Same as the ``indirect`` parameter of ``parametrize()``. + :raises ValueError: If validation fails. """ default_arg_names = set(get_default_arg_names(self.function)) func_name = self.function.__name__ @@ -1106,45 +1243,16 @@ def _validate_if_using_arg_names( else: name = "fixture" if indirect else "argument" fail( - "In {}: function uses no {} '{}'".format(func_name, name, arg), + f"In {func_name}: function uses no {name} '{arg}'", pytrace=False, ) - def _validate_explicit_parameters( - self, - argnames: typing.Sequence[str], - indirect: Union[bool, typing.Sequence[str]], - ) -> None: - """ - The argnames in *parametrize* should either be declared explicitly via - indirect list or in the function signature - - :param List[str] argnames: list of argument names passed to ``parametrize()``. - :param indirect: same ``indirect`` parameter of ``parametrize()``. - :raise ValueError: if validation fails - """ - if isinstance(indirect, bool): - parametrized_argnames = [] if indirect else argnames - else: - parametrized_argnames = [arg for arg in argnames if arg not in indirect] - - if not parametrized_argnames: - return - funcargnames = _pytest.compat.getfuncargnames(self.function) - usefixtures = fixtures.get_use_fixtures_for_node(self.definition) - - for arg in parametrized_argnames: - if arg not in funcargnames and arg not in usefixtures: - func_name = self.function.__name__ - msg = ( - 'In function "{func_name}":\n' - 'Parameter "{arg}" should be declared explicitly via indirect or in function itself' - ).format(func_name=func_name, arg=arg) - fail(msg, pytrace=False) - - -def _find_parametrized_scope(argnames, arg2fixturedefs, indirect): +def _find_parametrized_scope( + argnames: Sequence[str], + arg2fixturedefs: Mapping[str, Sequence[fixtures.FixtureDef[object]]], + indirect: Union[bool, Sequence[str]], +) -> "fixtures._Scope": """Find the most appropriate scope for a parametrized call based on its arguments. When there's at least one direct argument, always use "function" scope. @@ -1154,9 +1262,7 @@ def _find_parametrized_scope(argnames, arg2fixturedefs, indirect): Related to issue #1832, based on code posted by @Kingdread. """ - from _pytest.fixtures import scopes - - if isinstance(indirect, (list, tuple)): + if isinstance(indirect, Sequence): all_arguments_are_fixtures = len(indirect) == len(argnames) else: all_arguments_are_fixtures = bool(indirect) @@ -1169,8 +1275,8 @@ def _find_parametrized_scope(argnames, arg2fixturedefs, indirect): if name in argnames ] if used_scopes: - # Takes the most narrow scope from used fixtures - for scope in reversed(scopes): + # Takes the most narrow scope from used fixtures. + for scope in reversed(fixtures.scopes): if scope in used_scopes: return scope @@ -1194,8 +1300,8 @@ def _idval( val: object, argname: str, idx: int, - idfn: Optional[Callable[[object], Optional[object]]], - item, + idfn: Optional[Callable[[Any], Optional[object]]], + nodeid: Optional[str], config: Optional[Config], ) -> str: if idfn: @@ -1204,13 +1310,14 @@ def _idval( if generated_id is not None: val = generated_id except Exception as e: - msg = "{}: error raised while trying to determine id of parameter '{}' at position {}" - msg = msg.format(item.nodeid, argname, idx) + prefix = f"{nodeid}: " if nodeid is not None else "" + msg = "error raised while trying to determine id of parameter '{}' at position {}" + msg = prefix + msg.format(argname, idx) raise ValueError(msg) from e elif config: - hook_id = config.hook.pytest_make_parametrize_id( + hook_id: Optional[str] = config.hook.pytest_make_parametrize_id( config=config, val=val, argname=argname - ) # type: Optional[str] + ) if hook_id: return hook_id @@ -1220,11 +1327,14 @@ def _idval( return str(val) elif isinstance(val, REGEX_TYPE): return ascii_escaped(val.pattern) + elif val is NOTSET: + # Fallback to default. Note that NOTSET is an enum.Enum. + pass elif isinstance(val, enum.Enum): return str(val) elif isinstance(getattr(val, "__name__", None), str): - # name of a class, function, module, etc. - name = getattr(val, "__name__") # type: str + # Name of a class, function, module, etc. + name: str = getattr(val, "__name__") return name return str(argname) + str(idx) @@ -1233,17 +1343,17 @@ def _idvalset( idx: int, parameterset: ParameterSet, argnames: Iterable[str], - idfn: Optional[Callable[[object], Optional[object]]], + idfn: Optional[Callable[[Any], Optional[object]]], ids: Optional[List[Union[None, str]]], - item, + nodeid: Optional[str], config: Optional[Config], -): +) -> str: if parameterset.id is not None: return parameterset.id id = None if ids is None or idx >= len(ids) else ids[idx] if id is None: this_id = [ - _idval(val, argname, idx, idfn, item=item, config=config) + _idval(val, argname, idx, idfn, nodeid=nodeid, config=config) for val, argname in zip(parameterset.values, argnames) ] return "-".join(this_id) @@ -1254,13 +1364,15 @@ def _idvalset( def idmaker( argnames: Iterable[str], parametersets: Iterable[ParameterSet], - idfn: Optional[Callable[[object], Optional[object]]] = None, + idfn: Optional[Callable[[Any], Optional[object]]] = None, ids: Optional[List[Union[None, str]]] = None, config: Optional[Config] = None, - item=None, + nodeid: Optional[str] = None, ) -> List[str]: resolved_ids = [ - _idvalset(valindex, parameterset, argnames, idfn, ids, config=config, item=item) + _idvalset( + valindex, parameterset, argnames, idfn, ids, config=config, nodeid=nodeid + ) for valindex, parameterset in enumerate(parametersets) ] @@ -1268,13 +1380,13 @@ def idmaker( unique_ids = set(resolved_ids) if len(unique_ids) != len(resolved_ids): - # Record the number of occurrences of each test ID + # Record the number of occurrences of each test ID. test_id_counts = Counter(resolved_ids) - # Map the test ID to its next suffix - test_id_suffixes = defaultdict(int) # type: Dict[str, int] + # Map the test ID to its next suffix. + test_id_suffixes: Dict[str, int] = defaultdict(int) - # Suffix non-unique IDs to make them unique + # Suffix non-unique IDs to make them unique. for index, test_id in enumerate(resolved_ids): if test_id_counts[test_id] > 1: resolved_ids[index] = "{}{}".format(test_id, test_id_suffixes[test_id]) @@ -1289,7 +1401,7 @@ def show_fixtures_per_test(config): return wrap_session(config, _show_fixtures_per_test) -def _show_fixtures_per_test(config, session): +def _show_fixtures_per_test(config: Config, session: Session) -> None: import _pytest.config session.perform_collect() @@ -1298,16 +1410,16 @@ def _show_fixtures_per_test(config, session): verbose = config.getvalue("verbose") def get_best_relpath(func): - loc = getlocation(func, curdir) - return curdir.bestrelpath(loc) + loc = getlocation(func, str(curdir)) + return curdir.bestrelpath(py.path.local(loc)) - def write_fixture(fixture_def): + def write_fixture(fixture_def: fixtures.FixtureDef[object]) -> None: argname = fixture_def.argname if verbose <= 0 and argname.startswith("_"): return if verbose > 0: bestrel = get_best_relpath(fixture_def.func) - funcargspec = "{} -- {}".format(argname, bestrel) + funcargspec = f"{argname} -- {bestrel}" else: funcargspec = argname tw.line(funcargspec, green=True) @@ -1317,37 +1429,35 @@ def write_fixture(fixture_def): else: tw.line(" no docstring available", red=True) - def write_item(item): - try: - info = item._fixtureinfo - except AttributeError: - # doctests items have no _fixtureinfo attribute - return - if not info.name2fixturedefs: - # this test item does not use any fixtures + def write_item(item: nodes.Item) -> None: + # Not all items have _fixtureinfo attribute. + info: Optional[FuncFixtureInfo] = getattr(item, "_fixtureinfo", None) + if info is None or not info.name2fixturedefs: + # This test item does not use any fixtures. return tw.line() - tw.sep("-", "fixtures used by {}".format(item.name)) - tw.sep("-", "({})".format(get_best_relpath(item.function))) - # dict key not used in loop but needed for sorting + tw.sep("-", f"fixtures used by {item.name}") + # TODO: Fix this type ignore. + tw.sep("-", "({})".format(get_best_relpath(item.function))) # type: ignore[attr-defined] + # dict key not used in loop but needed for sorting. for _, fixturedefs in sorted(info.name2fixturedefs.items()): assert fixturedefs is not None if not fixturedefs: continue - # last item is expected to be the one used by the test item + # Last item is expected to be the one used by the test item. write_fixture(fixturedefs[-1]) for session_item in session.items: write_item(session_item) -def showfixtures(config): +def showfixtures(config: Config) -> Union[int, ExitCode]: from _pytest.main import wrap_session return wrap_session(config, _showfixtures_main) -def _showfixtures_main(config, session): +def _showfixtures_main(config: Config, session: Session) -> None: import _pytest.config session.perform_collect() @@ -1358,14 +1468,14 @@ def _showfixtures_main(config, session): fm = session._fixturemanager available = [] - seen = set() + seen: Set[Tuple[str, str]] = set() for argname, fixturedefs in fm._arg2fixturedefs.items(): assert fixturedefs is not None if not fixturedefs: continue for fixturedef in fixturedefs: - loc = getlocation(fixturedef.func, curdir) + loc = getlocation(fixturedef.func, str(curdir)) if (fixturedef.argname, loc) in seen: continue seen.add((fixturedef.argname, loc)) @@ -1373,7 +1483,7 @@ def _showfixtures_main(config, session): ( len(fixturedef.baseid), fixturedef.func.__module__, - curdir.bestrelpath(loc), + curdir.bestrelpath(py.path.local(loc)), fixturedef.argname, fixturedef, ) @@ -1385,7 +1495,7 @@ def _showfixtures_main(config, session): if currentmodule != module: if not module.startswith("_pytest."): tw.line() - tw.sep("-", "fixtures defined from {}".format(module)) + tw.sep("-", f"fixtures defined from {module}") currentmodule = module if verbose <= 0 and argname[0] == "_": continue @@ -1395,46 +1505,80 @@ def _showfixtures_main(config, session): if verbose > 0: tw.write(" -- %s" % bestrel, yellow=True) tw.write("\n") - loc = getlocation(fixturedef.func, curdir) + loc = getlocation(fixturedef.func, str(curdir)) doc = inspect.getdoc(fixturedef.func) if doc: write_docstring(tw, doc) else: - tw.line(" {}: no docstring available".format(loc), red=True) + tw.line(f" {loc}: no docstring available", red=True) tw.line() def write_docstring(tw: TerminalWriter, doc: str, indent: str = " ") -> None: for line in doc.split("\n"): - tw.write(indent + line + "\n") + tw.line(indent + line) class Function(PyobjMixin, nodes.Item): - """ a Function Item is responsible for setting up and executing a - Python test function. + """An Item responsible for setting up and executing a Python test function. + + param name: + The full function name, including any decorations like those + added by parametrization (``my_func[my_param]``). + param parent: + The parent Node. + param config: + The pytest Config object. + param callspec: + If given, this is function has been parametrized and the callspec contains + meta information about the parametrization. + param callobj: + If given, the object which will be called when the Function is invoked, + otherwise the callobj will be obtained from ``parent`` using ``originalname``. + param keywords: + Keywords bound to the function object for "-k" matching. + param session: + The pytest Session object. + param fixtureinfo: + Fixture information already resolved at this fixture node.. + param originalname: + The attribute name to use for accessing the underlying function object. + Defaults to ``name``. Set this if name is different from the original name, + for example when it contains decorations like those added by parametrization + (``my_func[my_param]``). """ - # disable since functions handle it themselves + # Disable since functions handle it themselves. _ALLOW_MARKERS = False def __init__( self, - name, + name: str, parent, - args=None, - config=None, + config: Optional[Config] = None, callspec: Optional[CallSpec2] = None, callobj=NOTSET, keywords=None, - session=None, + session: Optional[Session] = None, fixtureinfo: Optional[FuncFixtureInfo] = None, - originalname=None, + originalname: Optional[str] = None, ) -> None: super().__init__(name, parent, config=config, session=session) - self._args = args + if callobj is not NOTSET: self.obj = callobj + #: Original function name, without any decorations (for example + #: parametrization adds a ``"[...]"`` suffix to function names), used to access + #: the underlying function object from ``parent`` (in case ``callobj`` is not given + #: explicitly). + #: + #: .. versionadded:: 3.0 + self.originalname = originalname or name + + # Note: when FunctionDefinition is introduced, we should change ``originalname`` + # to a readonly property that returns FunctionDefinition.name. + self.keywords.update(self.obj.__dict__) self.own_markers.extend(get_unpacked_marks(self.obj)) if callspec: @@ -1465,63 +1609,46 @@ def __init__( fixtureinfo = self.session._fixturemanager.getfixtureinfo( self, self.obj, self.cls, funcargs=True ) - self._fixtureinfo = fixtureinfo # type: FuncFixtureInfo + self._fixtureinfo: FuncFixtureInfo = fixtureinfo self.fixturenames = fixtureinfo.names_closure self._initrequest() - #: original function name, without any decorations (for example - #: parametrization adds a ``"[...]"`` suffix to function names). - #: - #: .. versionadded:: 3.0 - self.originalname = originalname - @classmethod def from_parent(cls, parent, **kw): # todo: determine sound type limitations - """ - The public constructor - """ + """The public constructor.""" return super().from_parent(parent=parent, **kw) - def _initrequest(self): - self.funcargs = {} - self._request = fixtures.FixtureRequest(self) + def _initrequest(self) -> None: + self.funcargs: Dict[str, object] = {} + self._request = fixtures.FixtureRequest(self, _ispytest=True) @property def function(self): - "underlying python 'function' object" + """Underlying python 'function' object.""" return getimfunc(self.obj) def _getobj(self): - name = self.name - i = name.find("[") # parametrization - if i != -1: - name = name[:i] - return getattr(self.parent.obj, name) + assert self.parent is not None + return getattr(self.parent.obj, self.originalname) # type: ignore[attr-defined] @property def _pyfuncitem(self): - "(compatonly) for code expecting pytest-2.2 style request objects" + """(compatonly) for code expecting pytest-2.2 style request objects.""" return self - @property - def funcargnames(self): - """ alias attribute for ``fixturenames`` for pre-2.3 compatibility""" - warnings.warn(FUNCARGNAMES, stacklevel=2) - return self.fixturenames - def runtest(self) -> None: - """ execute the underlying test function. """ + """Execute the underlying test function.""" self.ihook.pytest_pyfunc_call(pyfuncitem=self) def setup(self) -> None: if isinstance(self.parent, Instance): self.parent.newinstance() self.obj = self._getobj() - fixtures.fillfixtures(self) + self._request._fillfixtures() - def _prunetraceback(self, excinfo: ExceptionInfo) -> None: + def _prunetraceback(self, excinfo: ExceptionInfo[BaseException]) -> None: if hasattr(self, "_obj") and not self.config.getoption("fulltrace", False): - code = _pytest._code.Code(get_real_func(self.obj)) + code = _pytest._code.Code.from_function(get_real_func(self.obj)) path, firstlineno = code.path, code.firstlineno traceback = excinfo.traceback ntraceback = traceback.cut(path=path, firstlineno=firstlineno) @@ -1534,14 +1661,16 @@ def _prunetraceback(self, excinfo: ExceptionInfo) -> None: excinfo.traceback = ntraceback.filter() # issue364: mark all but first and last frames to - # only show a single-line message for each frame + # 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") - def repr_failure(self, excinfo, outerr=None): - assert outerr is None, "XXX outerr usage is deprecated" + # TODO: Type ignored -- breaks Liskov Substitution. + def repr_failure( # type: ignore[override] + self, excinfo: ExceptionInfo[BaseException], + ) -> Union[str, TerminalRepr]: style = self.config.getoption("tbstyle", "auto") if style == "auto": style = "long" @@ -1550,11 +1679,11 @@ def repr_failure(self, excinfo, outerr=None): class FunctionDefinition(Function): """ - internal hack until we get actual definition nodes instead of the - crappy metafunc hack + This class is a step gap solution until we evolve to have actual function definition nodes + and manage to get rid of ``metafunc``. """ def runtest(self) -> None: - raise RuntimeError("function definitions are not supposed to be used") + raise RuntimeError("function definitions are not supposed to be run as tests") setup = runtest diff --git a/src/_pytest/python_api.py b/src/_pytest/python_api.py index df97181f4fc..81ce4f89539 100644 --- a/src/_pytest/python_api.py +++ b/src/_pytest/python_api.py @@ -1,40 +1,36 @@ -import inspect import math import pprint from collections.abc import Iterable from collections.abc import Mapping from collections.abc import Sized from decimal import Decimal -from itertools import filterfalse -from numbers import Number +from numbers import Complex from types import TracebackType from typing import Any from typing import Callable from typing import cast from typing import Generic from typing import Optional +from typing import overload from typing import Pattern from typing import Tuple +from typing import Type +from typing import TYPE_CHECKING from typing import TypeVar from typing import Union -from more_itertools.more import always_iterable +if TYPE_CHECKING: + from numpy import ndarray + import _pytest._code -from _pytest.compat import overload +from _pytest.compat import final from _pytest.compat import STRING_TYPES -from _pytest.compat import TYPE_CHECKING from _pytest.outcomes import fail -if TYPE_CHECKING: - from typing import Type # noqa: F401 (used in type string) - -BASE_TYPE = (type, STRING_TYPES) - - -def _non_numeric_type_error(value, at): - at_str = " at {}".format(at) if at else "" +def _non_numeric_type_error(value, at: Optional[str]) -> TypeError: + at_str = f" at {at}" if at else "" return TypeError( "cannot make approximate comparisons to non-numeric values: {!r} {}".format( value, at_str @@ -46,16 +42,14 @@ def _non_numeric_type_error(value, at): class ApproxBase: - """ - Provide shared utilities for making approximate comparisons between numbers - or sequences of numbers. - """ + """Provide shared utilities for making approximate comparisons between + numbers or sequences of numbers.""" # Tell numpy to use our `__eq__` operator instead of its. __array_ufunc__ = None __array_priority__ = 100 - def __init__(self, expected, rel=None, abs=None, nan_ok=False): + def __init__(self, expected, rel=None, abs=None, nan_ok: bool = False) -> None: __tracebackhide__ = True self.expected = expected self.abs = abs @@ -63,10 +57,10 @@ def __init__(self, expected, rel=None, abs=None, nan_ok=False): self.nan_ok = nan_ok self._check_type() - def __repr__(self): + def __repr__(self) -> str: raise NotImplementedError - def __eq__(self, actual): + def __eq__(self, actual) -> bool: return all( a == self._approx_scalar(x) for a, x in self._yield_comparisons(actual) ) @@ -74,23 +68,21 @@ def __eq__(self, actual): # Ignore type because of https://github.com/python/mypy/issues/4266. __hash__ = None # type: ignore - def __ne__(self, actual): + def __ne__(self, actual) -> bool: return not (actual == self) - def _approx_scalar(self, x): + def _approx_scalar(self, x) -> "ApproxScalar": return ApproxScalar(x, rel=self.rel, abs=self.abs, nan_ok=self.nan_ok) def _yield_comparisons(self, actual): - """ - Yield all the pairs of numbers to be compared. This is used to - implement the `__eq__` method. + """Yield all the pairs of numbers to be compared. + + This is used to implement the `__eq__` method. """ raise NotImplementedError - def _check_type(self): - """ - Raise a TypeError if the expected value is not a valid type. - """ + def _check_type(self) -> None: + """Raise a TypeError if the expected value is not a valid type.""" # This is only a concern if the expected value is a sequence. In every # other case, the approx() function ensures that the expected value has # a numeric type. For this reason, the default is to do nothing. The @@ -107,24 +99,22 @@ def _recursive_list_map(f, x): class ApproxNumpy(ApproxBase): - """ - Perform approximate comparisons where the expected value is numpy array. - """ + """Perform approximate comparisons where the expected value is numpy array.""" - def __repr__(self): + def __repr__(self) -> str: list_scalars = _recursive_list_map(self._approx_scalar, self.expected.tolist()) - return "approx({!r})".format(list_scalars) + return f"approx({list_scalars!r})" - def __eq__(self, actual): + def __eq__(self, actual) -> bool: import numpy as np - # self.expected is supposed to always be an array here + # self.expected is supposed to always be an array here. if not np.isscalar(actual): try: actual = np.asarray(actual) - except: # noqa - raise TypeError("cannot compare '{}' to numpy.ndarray".format(actual)) + except Exception as e: + raise TypeError(f"cannot compare '{actual}' to numpy.ndarray") from e if not np.isscalar(actual) and actual.shape != self.expected.shape: return False @@ -147,18 +137,19 @@ def _yield_comparisons(self, actual): class ApproxMapping(ApproxBase): - """ - Perform approximate comparisons where the expected value is a mapping with - numeric values (the keys can be anything). - """ + """Perform approximate comparisons where the expected value is a mapping + with numeric values (the keys can be anything).""" - def __repr__(self): + def __repr__(self) -> str: return "approx({!r})".format( {k: self._approx_scalar(v) for k, v in self.expected.items()} ) - def __eq__(self, actual): - if set(actual.keys()) != set(self.expected.keys()): + def __eq__(self, actual) -> bool: + try: + if set(actual.keys()) != set(self.expected.keys()): + return False + except AttributeError: return False return ApproxBase.__eq__(self, actual) @@ -167,23 +158,18 @@ def _yield_comparisons(self, actual): for k in self.expected.keys(): yield actual[k], self.expected[k] - def _check_type(self): + def _check_type(self) -> None: __tracebackhide__ = True for key, value in self.expected.items(): if isinstance(value, type(self.expected)): msg = "pytest.approx() does not support nested dictionaries: key={!r} value={!r}\n full mapping={}" raise TypeError(msg.format(key, value, pprint.pformat(self.expected))) - elif not isinstance(value, Number): - raise _non_numeric_type_error(self.expected, at="key={!r}".format(key)) class ApproxSequencelike(ApproxBase): - """ - Perform approximate comparisons where the expected value is a sequence of - numbers. - """ + """Perform approximate comparisons where the expected value is a sequence of numbers.""" - def __repr__(self): + def __repr__(self) -> str: seq_type = type(self.expected) if seq_type not in (tuple, list, set): seq_type = list @@ -191,77 +177,90 @@ def __repr__(self): seq_type(self._approx_scalar(x) for x in self.expected) ) - def __eq__(self, actual): - if len(actual) != len(self.expected): + def __eq__(self, actual) -> bool: + try: + if len(actual) != len(self.expected): + return False + except TypeError: return False return ApproxBase.__eq__(self, actual) def _yield_comparisons(self, actual): return zip(actual, self.expected) - def _check_type(self): + def _check_type(self) -> None: __tracebackhide__ = True for index, x in enumerate(self.expected): if isinstance(x, type(self.expected)): msg = "pytest.approx() does not support nested data structures: {!r} at index {}\n full sequence: {}" raise TypeError(msg.format(x, index, pprint.pformat(self.expected))) - elif not isinstance(x, Number): - raise _non_numeric_type_error( - self.expected, at="index {}".format(index) - ) class ApproxScalar(ApproxBase): - """ - Perform approximate comparisons where the expected value is a single number. - """ + """Perform approximate comparisons where the expected value is a single number.""" # Using Real should be better than this Union, but not possible yet: # https://github.com/python/typeshed/pull/3108 - DEFAULT_ABSOLUTE_TOLERANCE = 1e-12 # type: Union[float, Decimal] - DEFAULT_RELATIVE_TOLERANCE = 1e-6 # type: Union[float, Decimal] + DEFAULT_ABSOLUTE_TOLERANCE: Union[float, Decimal] = 1e-12 + DEFAULT_RELATIVE_TOLERANCE: Union[float, Decimal] = 1e-6 - def __repr__(self): - """ - Return a string communicating both the expected value and the tolerance - for the comparison being made, e.g. '1.0 ± 1e-6', '(3+4j) ± 5e-6 ∠ ±180°'. + def __repr__(self) -> str: + """Return a string communicating both the expected value and the + tolerance for the comparison being made. + + For example, ``1.0 ± 1e-6``, ``(3+4j) ± 5e-6 ∠ ±180°``. """ - # Infinities aren't compared using tolerances, so don't show a - # tolerance. Need to call abs to handle complex numbers, e.g. (inf + 1j) - if math.isinf(abs(self.expected)): + # Don't show a tolerance for values that aren't compared using + # tolerances, i.e. non-numerics and infinities. Need to call abs to + # handle complex numbers, e.g. (inf + 1j). + if (not isinstance(self.expected, (Complex, Decimal))) or math.isinf( + abs(self.expected) # type: ignore[arg-type] + ): return str(self.expected) # If a sensible tolerance can't be calculated, self.tolerance will # raise a ValueError. In this case, display '???'. try: - vetted_tolerance = "{:.1e}".format(self.tolerance) - if isinstance(self.expected, complex) and not math.isinf(self.tolerance): + vetted_tolerance = f"{self.tolerance:.1e}" + if ( + isinstance(self.expected, Complex) + and self.expected.imag + and not math.isinf(self.tolerance) + ): vetted_tolerance += " ∠ ±180°" except ValueError: vetted_tolerance = "???" - return "{} ± {}".format(self.expected, vetted_tolerance) + return f"{self.expected} ± {vetted_tolerance}" - def __eq__(self, actual): - """ - Return true if the given value is equal to the expected value within - the pre-specified tolerance. - """ - if _is_numpy_array(actual): + def __eq__(self, actual) -> bool: + """Return whether the given value is equal to the expected value + within the pre-specified tolerance.""" + asarray = _as_numpy_array(actual) + if asarray is not None: # Call ``__eq__()`` manually to prevent infinite-recursion with # numpy<1.13. See #3748. - return all(self.__eq__(a) for a in actual.flat) + return all(self.__eq__(a) for a in asarray.flat) # Short-circuit exact equality. if actual == self.expected: return True + # If either type is non-numeric, fall back to strict equality. + # NB: we need Complex, rather than just Number, to ensure that __abs__, + # __sub__, and __float__ are defined. + if not ( + isinstance(self.expected, (Complex, Decimal)) + and isinstance(actual, (Complex, Decimal)) + ): + return False + # Allow the user to control whether NaNs are considered equal to each # other or not. The abs() calls are for compatibility with complex # numbers. - if math.isnan(abs(self.expected)): - return self.nan_ok and math.isnan(abs(actual)) + if math.isnan(abs(self.expected)): # type: ignore[arg-type] + return self.nan_ok and math.isnan(abs(actual)) # type: ignore[arg-type] # Infinity shouldn't be approximately equal to anything but itself, but # if there's a relative tolerance, it will be infinite and infinity @@ -269,21 +268,22 @@ def __eq__(self, actual): # case would have been short circuited above, so here we can just # return false if the expected value is infinite. The abs() call is # for compatibility with complex numbers. - if math.isinf(abs(self.expected)): + if math.isinf(abs(self.expected)): # type: ignore[arg-type] return False # Return true if the two numbers are within the tolerance. - return abs(self.expected - actual) <= self.tolerance + result: bool = abs(self.expected - actual) <= self.tolerance + return result # Ignore type because of https://github.com/python/mypy/issues/4266. __hash__ = None # type: ignore @property def tolerance(self): - """ - Return the tolerance for the comparison. This could be either an - absolute tolerance or a relative tolerance, depending on what the user - specified or which would be larger. + """Return the tolerance for the comparison. + + This could be either an absolute tolerance or a relative tolerance, + depending on what the user specified or which would be larger. """ def set_default(x, default): @@ -295,7 +295,7 @@ def set_default(x, default): if absolute_tolerance < 0: raise ValueError( - "absolute tolerance can't be negative: {}".format(absolute_tolerance) + f"absolute tolerance can't be negative: {absolute_tolerance}" ) if math.isnan(absolute_tolerance): raise ValueError("absolute tolerance can't be NaN.") @@ -317,7 +317,7 @@ def set_default(x, default): if relative_tolerance < 0: raise ValueError( - "relative tolerance can't be negative: {}".format(absolute_tolerance) + f"relative tolerance can't be negative: {absolute_tolerance}" ) if math.isnan(relative_tolerance): raise ValueError("relative tolerance can't be NaN.") @@ -327,17 +327,14 @@ def set_default(x, default): class ApproxDecimal(ApproxScalar): - """ - Perform approximate comparisons where the expected value is a decimal. - """ + """Perform approximate comparisons where the expected value is a Decimal.""" DEFAULT_ABSOLUTE_TOLERANCE = Decimal("1e-12") DEFAULT_RELATIVE_TOLERANCE = Decimal("1e-6") -def approx(expected, rel=None, abs=None, nan_ok=False): - """ - Assert that two numbers (or two sets of numbers) are equal to each other +def approx(expected, rel=None, abs=None, nan_ok: bool = False) -> ApproxBase: + """Assert that two numbers (or two sets of numbers) are equal to each other within some tolerance. Due to the `intricacies of floating-point arithmetic`__, numbers that we @@ -429,6 +426,18 @@ def approx(expected, rel=None, abs=None, nan_ok=False): >>> 1 + 1e-8 == approx(1, rel=1e-6, abs=1e-12) True + You can also use ``approx`` to compare nonnumeric types, or dicts and + sequences containing nonnumeric types, in which case it falls back to + strict equality. This can be useful for comparing dicts and sequences that + can contain optional values:: + + >>> {"required": 1.0000005, "optional": None} == approx({"required": 1, "optional": None}) + True + >>> [None, 1.0000005] == approx([None,1]) + True + >>> ["foo", 1.0000005] == approx([None,1]) + False + If you're thinking about using ``approx``, then you might want to know how it compares to other good ways of comparing floating-point numbers. All of these algorithms are based on relative and absolute tolerances and should @@ -440,7 +449,7 @@ def approx(expected, rel=None, abs=None, nan_ok=False): both ``a`` and ``b``, this test is symmetric (i.e. neither ``a`` nor ``b`` is a "reference value"). You have to specify an absolute tolerance if you want to compare to ``0.0`` because there is no tolerance by - default. Only available in python>=3.5. `More information...`__ + default. `More information...`__ __ https://docs.python.org/3/library/math.html#math.isclose @@ -451,7 +460,7 @@ def approx(expected, rel=None, abs=None, nan_ok=False): think of ``b`` as the reference value. Support for comparing sequences is provided by ``numpy.allclose``. `More information...`__ - __ http://docs.scipy.org/doc/numpy-1.10.0/reference/generated/numpy.isclose.html + __ https://numpy.org/doc/stable/reference/generated/numpy.isclose.html - ``unittest.TestCase.assertAlmostEqual(a, b)``: True if ``a`` and ``b`` are within an absolute tolerance of ``1e-7``. No relative tolerance is @@ -486,6 +495,14 @@ def approx(expected, rel=None, abs=None, nan_ok=False): follows a fixed behavior. `More information...`__ __ https://docs.python.org/3/reference/datamodel.html#object.__ge__ + + .. versionchanged:: 3.7.1 + ``approx`` raises ``TypeError`` when it encounters a dict value or + sequence element of nonnumeric type. + + .. versionchanged:: 6.1.0 + ``approx`` falls back to strict equality for nonnumeric types instead + of raising ``TypeError``. """ # Delegate the comparison to a class that knows how to deal with the type @@ -506,36 +523,50 @@ def approx(expected, rel=None, abs=None, nan_ok=False): __tracebackhide__ = True if isinstance(expected, Decimal): - cls = ApproxDecimal - elif isinstance(expected, Number): - cls = ApproxScalar + cls: Type[ApproxBase] = ApproxDecimal elif isinstance(expected, Mapping): cls = ApproxMapping elif _is_numpy_array(expected): + expected = _as_numpy_array(expected) cls = ApproxNumpy elif ( isinstance(expected, Iterable) and isinstance(expected, Sized) - and not isinstance(expected, STRING_TYPES) + # Type ignored because the error is wrong -- not unreachable. + and not isinstance(expected, STRING_TYPES) # type: ignore[unreachable] ): cls = ApproxSequencelike else: - raise _non_numeric_type_error(expected, at=None) + cls = ApproxScalar return cls(expected, rel, abs, nan_ok) -def _is_numpy_array(obj): +def _is_numpy_array(obj: object) -> bool: + """ + Return true if the given object is implicitly convertible to ndarray, + and numpy is already imported. """ - Return true if the given object is a numpy array. Make a special effort to - avoid importing numpy unless it's really necessary. + return _as_numpy_array(obj) is not None + + +def _as_numpy_array(obj: object) -> Optional["ndarray"]: + """ + Return an ndarray if the given object is implicitly convertible to ndarray, + and numpy is already imported, otherwise None. """ import sys - np = sys.modules.get("numpy") + np: Any = sys.modules.get("numpy") if np is not None: - return isinstance(obj, np.ndarray) - return False + # avoid infinite recursion on numpy scalars, which have __array__ + if np.isscalar(obj): + return None + elif isinstance(obj, np.ndarray): + return obj + elif hasattr(obj, "__array__") or hasattr("obj", "__array_interface__"): + return np.asarray(obj) + return None # builtin pytest.raises helper @@ -545,33 +576,31 @@ def _is_numpy_array(obj): @overload def raises( - expected_exception: Union["Type[_E]", Tuple["Type[_E]", ...]], + expected_exception: Union[Type[_E], Tuple[Type[_E], ...]], *, - match: "Optional[Union[str, Pattern]]" = ... + match: Optional[Union[str, Pattern[str]]] = ..., ) -> "RaisesContext[_E]": - ... # pragma: no cover + ... -@overload # noqa: F811 -def raises( # noqa: F811 - expected_exception: Union["Type[_E]", Tuple["Type[_E]", ...]], - func: Callable, +@overload +def raises( + expected_exception: Union[Type[_E], Tuple[Type[_E], ...]], + func: Callable[..., Any], *args: Any, - **kwargs: Any + **kwargs: Any, ) -> _pytest._code.ExceptionInfo[_E]: - ... # pragma: no cover + ... -def raises( # noqa: F811 - expected_exception: Union["Type[_E]", Tuple["Type[_E]", ...]], - *args: Any, - **kwargs: Any +def raises( + expected_exception: Union[Type[_E], Tuple[Type[_E], ...]], *args: Any, **kwargs: Any ) -> Union["RaisesContext[_E]", _pytest._code.ExceptionInfo[_E]]: - r""" - Assert that a code block/function call raises ``expected_exception`` + r"""Assert that a code block/function call raises ``expected_exception`` or raise a failure exception otherwise. - :kwparam match: if specified, a string containing a regular expression, + :kwparam match: + If specified, a string containing a regular expression, or a regular expression object, that is tested against the string representation of the exception using ``re.search``. To match a literal string that may contain `special characters`__, the pattern can @@ -589,7 +618,8 @@ def raises( # noqa: F811 Use ``pytest.raises`` as a context manager, which will capture the exception of the given type:: - >>> with raises(ZeroDivisionError): + >>> import pytest + >>> with pytest.raises(ZeroDivisionError): ... 1/0 If the code block does not raise the expected exception (``ZeroDivisionError`` in the example @@ -598,16 +628,16 @@ def raises( # noqa: F811 You can also use the keyword argument ``match`` to assert that the exception matches a text or regex:: - >>> with raises(ValueError, match='must be 0 or None'): + >>> with pytest.raises(ValueError, match='must be 0 or None'): ... raise ValueError("value must be 0 or None") - >>> with raises(ValueError, match=r'must be \d+$'): + >>> with pytest.raises(ValueError, match=r'must be \d+$'): ... raise ValueError("value must be 42") The context manager produces an :class:`ExceptionInfo` object which can be used to inspect the details of the captured exception:: - >>> with raises(ValueError) as exc_info: + >>> with pytest.raises(ValueError) as exc_info: ... raise ValueError("value must be 42") >>> assert exc_info.type is ValueError >>> assert exc_info.value.args[0] == "value must be 42" @@ -621,7 +651,7 @@ def raises( # noqa: F811 not be executed. For example:: >>> value = 15 - >>> with raises(ValueError) as exc_info: + >>> with pytest.raises(ValueError) as exc_info: ... if value > 10: ... raise ValueError("value must be <= 10") ... assert exc_info.type is ValueError # this will not execute @@ -629,7 +659,7 @@ def raises( # noqa: F811 Instead, the following approach must be taken (note the difference in scope):: - >>> with raises(ValueError) as exc_info: + >>> with pytest.raises(ValueError) as exc_info: ... if value > 10: ... raise ValueError("value must be <= 10") ... @@ -677,16 +707,21 @@ def raises( # noqa: F811 documentation for :ref:`the try statement `. """ __tracebackhide__ = True - for exc in filterfalse( - inspect.isclass, always_iterable(expected_exception, BASE_TYPE) # type: ignore[arg-type] # noqa: F821 - ): - msg = "exceptions must be derived from BaseException, not %s" - raise TypeError(msg % type(exc)) - message = "DID NOT RAISE {}".format(expected_exception) + if isinstance(expected_exception, type): + excepted_exceptions: Tuple[Type[_E], ...] = (expected_exception,) + else: + excepted_exceptions = expected_exception + for exc in excepted_exceptions: + if not isinstance(exc, type) or not issubclass(exc, BaseException): # type: ignore[unreachable] + msg = "expected exception must be a BaseException type, not {}" # type: ignore[unreachable] + not_a = exc.__name__ if isinstance(exc, type) else type(exc).__name__ + raise TypeError(msg.format(not_a)) + + message = f"DID NOT RAISE {expected_exception}" if not args: - match = kwargs.pop("match", None) + match: Optional[Union[str, Pattern[str]]] = kwargs.pop("match", None) if kwargs: msg = "Unexpected keyword arguments passed to pytest.raises: " msg += ", ".join(sorted(kwargs)) @@ -710,20 +745,22 @@ def raises( # noqa: F811 fail(message) +# This doesn't work with mypy for now. Use fail.Exception instead. raises.Exception = fail.Exception # type: ignore +@final class RaisesContext(Generic[_E]): def __init__( self, - expected_exception: Union["Type[_E]", Tuple["Type[_E]", ...]], + expected_exception: Union[Type[_E], Tuple[Type[_E], ...]], message: str, - match_expr: Optional[Union[str, "Pattern"]] = None, + match_expr: Optional[Union[str, Pattern[str]]] = None, ) -> None: self.expected_exception = expected_exception self.message = message self.match_expr = match_expr - self.excinfo = None # type: Optional[_pytest._code.ExceptionInfo[_E]] + self.excinfo: Optional[_pytest._code.ExceptionInfo[_E]] = None def __enter__(self) -> _pytest._code.ExceptionInfo[_E]: self.excinfo = _pytest._code.ExceptionInfo.for_later() @@ -731,7 +768,7 @@ def __enter__(self) -> _pytest._code.ExceptionInfo[_E]: def __exit__( self, - exc_type: Optional["Type[BaseException]"], + exc_type: Optional[Type[BaseException]], exc_val: Optional[BaseException], exc_tb: Optional[TracebackType], ) -> bool: @@ -742,9 +779,7 @@ def __exit__( if not issubclass(exc_type, self.expected_exception): return False # Cast to narrow the exception type now that it's verified. - exc_info = cast( - Tuple["Type[_E]", _E, TracebackType], (exc_type, exc_val, exc_tb) - ) + exc_info = cast(Tuple[Type[_E], _E, TracebackType], (exc_type, exc_val, exc_tb)) self.excinfo.fill_unfilled(exc_info) if self.match_expr is not None: self.excinfo.match(self.match_expr) diff --git a/src/_pytest/recwarn.py b/src/_pytest/recwarn.py index c57c94b1cb1..d872d9da401 100644 --- a/src/_pytest/recwarn.py +++ b/src/_pytest/recwarn.py @@ -1,53 +1,79 @@ -""" recording warnings during test function execution. """ +"""Record warnings during test function execution.""" import re import warnings from types import TracebackType from typing import Any from typing import Callable +from typing import Generator from typing import Iterator from typing import List from typing import Optional +from typing import overload from typing import Pattern from typing import Tuple +from typing import Type +from typing import TypeVar from typing import Union -from _pytest.compat import overload -from _pytest.compat import TYPE_CHECKING -from _pytest.fixtures import yield_fixture +from _pytest.compat import final +from _pytest.deprecated import check_ispytest +from _pytest.fixtures import fixture from _pytest.outcomes import fail -if TYPE_CHECKING: - from typing import Type +T = TypeVar("T") -@yield_fixture -def recwarn(): + +@fixture +def recwarn() -> Generator["WarningsRecorder", None, None]: """Return a :class:`WarningsRecorder` instance that records all warnings emitted by test functions. See http://docs.python.org/library/warnings.html for information on warning categories. """ - wrec = WarningsRecorder() + wrec = WarningsRecorder(_ispytest=True) with wrec: warnings.simplefilter("default") yield wrec -def deprecated_call(func=None, *args, **kwargs): - """context manager that can be used to ensure a block of code triggers a - ``DeprecationWarning`` or ``PendingDeprecationWarning``:: +@overload +def deprecated_call( + *, match: Optional[Union[str, Pattern[str]]] = ... +) -> "WarningsRecorder": + ... + + +@overload +def deprecated_call(func: Callable[..., T], *args: Any, **kwargs: Any) -> T: + ... + + +def deprecated_call( + func: Optional[Callable[..., Any]] = None, *args: Any, **kwargs: Any +) -> Union["WarningsRecorder", Any]: + """Assert that code produces a ``DeprecationWarning`` or ``PendingDeprecationWarning``. + + This function can be used as a context manager:: >>> import warnings >>> def api_call_v2(): ... warnings.warn('use v3 of this api', DeprecationWarning) ... return 200 - >>> with deprecated_call(): + >>> import pytest + >>> with pytest.deprecated_call(): ... assert api_call_v2() == 200 - ``deprecated_call`` can also be used by passing a function and ``*args`` and ``*kwargs``, - in which case it will ensure calling ``func(*args, **kwargs)`` produces one of the warnings - types above. + It can also be used by passing a function and ``*args`` and ``**kwargs``, + in which case it will ensure calling ``func(*args, **kwargs)`` produces one of + the warnings types above. The return value is the return value of the function. + + In the context manager form you may use the keyword argument ``match`` to assert + that the warning matches a text or regex. + + The context manager produces a list of :class:`warnings.WarningMessage` objects, + one for each warning raised. """ __tracebackhide__ = True if func is not None: @@ -57,29 +83,28 @@ def deprecated_call(func=None, *args, **kwargs): @overload def warns( - expected_warning: Optional[Union["Type[Warning]", Tuple["Type[Warning]", ...]]], + expected_warning: Optional[Union[Type[Warning], Tuple[Type[Warning], ...]]], *, - match: "Optional[Union[str, Pattern]]" = ... + match: Optional[Union[str, Pattern[str]]] = ..., ) -> "WarningsChecker": - raise NotImplementedError() + ... -@overload # noqa: F811 -def warns( # noqa: F811 - expected_warning: Optional[Union["Type[Warning]", Tuple["Type[Warning]", ...]]], - func: Callable, +@overload +def warns( + expected_warning: Optional[Union[Type[Warning], Tuple[Type[Warning], ...]]], + func: Callable[..., T], *args: Any, - match: Optional[Union[str, "Pattern"]] = ..., - **kwargs: Any -) -> Union[Any]: - raise NotImplementedError() + **kwargs: Any, +) -> T: + ... -def warns( # noqa: F811 - expected_warning: Optional[Union["Type[Warning]", Tuple["Type[Warning]", ...]]], +def warns( + expected_warning: Optional[Union[Type[Warning], Tuple[Type[Warning], ...]]], *args: Any, - match: Optional[Union[str, "Pattern"]] = None, - **kwargs: Any + match: Optional[Union[str, Pattern[str]]] = None, + **kwargs: Any, ) -> Union["WarningsChecker", Any]: r"""Assert that code raises a particular class of warning. @@ -91,21 +116,22 @@ def warns( # noqa: F811 one for each warning raised. This function can be used as a context manager, or any of the other ways - ``pytest.raises`` can be used:: + :func:`pytest.raises` can be used:: - >>> with warns(RuntimeWarning): + >>> import pytest + >>> with pytest.warns(RuntimeWarning): ... warnings.warn("my warning", RuntimeWarning) In the context manager form you may use the keyword argument ``match`` to assert - that the exception matches a text or regex:: + that the warning matches a text or regex:: - >>> with warns(UserWarning, match='must be 0 or None'): + >>> with pytest.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 pytest.warns(UserWarning, match=r'must be \d+$'): ... warnings.warn("value must be 42", UserWarning) - >>> with warns(UserWarning, match=r'must be \d+$'): + >>> with pytest.warns(UserWarning, match=r'must be \d+$'): ... warnings.warn("this is not here", UserWarning) Traceback (most recent call last): ... @@ -119,14 +145,14 @@ def warns( # noqa: F811 msg += ", ".join(sorted(kwargs)) msg += "\nUse context-manager form instead?" raise TypeError(msg) - return WarningsChecker(expected_warning, match_expr=match) + return WarningsChecker(expected_warning, match_expr=match, _ispytest=True) else: func = args[0] if not callable(func): raise TypeError( "{!r} object (type: {}) must be callable".format(func, type(func)) ) - with WarningsChecker(expected_warning): + with WarningsChecker(expected_warning, _ispytest=True): return func(*args[1:], **kwargs) @@ -136,21 +162,23 @@ class WarningsRecorder(warnings.catch_warnings): Adapted from `warnings.catch_warnings`. """ - def __init__(self): - super().__init__(record=True) + def __init__(self, *, _ispytest: bool = False) -> None: + check_ispytest(_ispytest) + # Type ignored due to the way typeshed handles warnings.catch_warnings. + super().__init__(record=True) # type: ignore[call-arg] self._entered = False - self._list = [] # type: List[warnings._Record] + self._list: List[warnings.WarningMessage] = [] @property - def list(self) -> List["warnings._Record"]: + def list(self) -> List["warnings.WarningMessage"]: """The list of recorded warnings.""" return self._list - def __getitem__(self, i: int) -> "warnings._Record": + def __getitem__(self, i: int) -> "warnings.WarningMessage": """Get a recorded warning by index.""" return self._list[i] - def __iter__(self) -> Iterator["warnings._Record"]: + def __iter__(self) -> Iterator["warnings.WarningMessage"]: """Iterate through the recorded warnings.""" return iter(self._list) @@ -158,7 +186,7 @@ def __len__(self) -> int: """The number of recorded warnings.""" return len(self._list) - def pop(self, cls: "Type[Warning]" = Warning) -> "warnings._Record": + def pop(self, cls: Type[Warning] = Warning) -> "warnings.WarningMessage": """Pop the first recorded warning, raise exception if not exists.""" for i, w in enumerate(self._list): if issubclass(w.category, cls): @@ -185,7 +213,7 @@ def __enter__(self) -> "WarningsRecorder": # type: ignore def __exit__( self, - exc_type: Optional["Type[BaseException]"], + exc_type: Optional[Type[BaseException]], exc_val: Optional[BaseException], exc_tb: Optional[TracebackType], ) -> None: @@ -200,15 +228,19 @@ def __exit__( self._entered = False +@final class WarningsChecker(WarningsRecorder): def __init__( self, expected_warning: Optional[ - Union["Type[Warning]", Tuple["Type[Warning]", ...]] + Union[Type[Warning], Tuple[Type[Warning], ...]] ] = None, - match_expr: Optional[Union[str, "Pattern"]] = None, + match_expr: Optional[Union[str, Pattern[str]]] = None, + *, + _ispytest: bool = False, ) -> None: - super().__init__() + check_ispytest(_ispytest) + super().__init__(_ispytest=True) msg = "exceptions must be derived from Warning, not %s" if expected_warning is None: @@ -228,7 +260,7 @@ def __init__( def __exit__( self, - exc_type: Optional["Type[BaseException]"], + exc_type: Optional[Type[BaseException]], exc_val: Optional[BaseException], exc_tb: Optional[TracebackType], ) -> None: diff --git a/src/_pytest/reports.py b/src/_pytest/reports.py index 4fa465ea71c..58f12517c5b 100644 --- a/src/_pytest/reports.py +++ b/src/_pytest/reports.py @@ -1,9 +1,17 @@ from io import StringIO +from pathlib import Path from pprint import pprint from typing import Any +from typing import cast +from typing import Dict +from typing import Iterable +from typing import Iterator from typing import List from typing import Optional from typing import Tuple +from typing import Type +from typing import TYPE_CHECKING +from typing import TypeVar from typing import Union import attr @@ -11,6 +19,7 @@ from _pytest._code.code import ExceptionChainRepr from _pytest._code.code import ExceptionInfo +from _pytest._code.code import ExceptionRepr from _pytest._code.code import ReprEntry from _pytest._code.code import ReprEntryNative from _pytest._code.code import ReprExceptionInfo @@ -20,30 +29,42 @@ from _pytest._code.code import ReprTraceback from _pytest._code.code import TerminalRepr from _pytest._io import TerminalWriter -from _pytest.compat import TYPE_CHECKING -from _pytest.nodes import Node +from _pytest.compat import final +from _pytest.config import Config +from _pytest.nodes import Collector +from _pytest.nodes import Item from _pytest.outcomes import skip -from _pytest.pathlib import Path +if TYPE_CHECKING: + from typing import NoReturn + from typing_extensions import Literal -def getslaveinfoline(node): + from _pytest.runner import CallInfo + + +def getworkerinfoline(node): try: - return node._slaveinfocache + return node._workerinfocache except AttributeError: - d = node.slaveinfo + d = node.workerinfo ver = "%s.%s.%s" % d["version_info"][:3] - node._slaveinfocache = s = "[{}] {} -- Python {} {}".format( + node._workerinfocache = s = "[{}] {} -- Python {} {}".format( d["id"], d["sysplatform"], ver, d["executable"] ) return s +_R = TypeVar("_R", bound="BaseReport") + + class BaseReport: - when = None # type: Optional[str] - location = None # type: Optional[Tuple[str, Optional[int], str]] - longrepr = None - sections = [] # type: List[Tuple[str, str]] - nodeid = None # type: str + when: Optional[str] + location: Optional[Tuple[str, Optional[int], str]] + longrepr: Union[ + None, ExceptionInfo[BaseException], Tuple[str, int, str], str, TerminalRepr + ] + sections: List[Tuple[str, str]] + nodeid: str def __init__(self, **kw: Any) -> None: self.__dict__.update(kw) @@ -51,46 +72,48 @@ def __init__(self, **kw: Any) -> None: if TYPE_CHECKING: # Can have arbitrary fields given to __init__(). def __getattr__(self, key: str) -> Any: - raise NotImplementedError() + ... - def toterminal(self, out) -> None: + def toterminal(self, out: TerminalWriter) -> None: if hasattr(self, "node"): - out.line(getslaveinfoline(self.node)) # type: ignore + out.line(getworkerinfoline(self.node)) longrepr = self.longrepr if longrepr is None: return if hasattr(longrepr, "toterminal"): - longrepr.toterminal(out) + longrepr_terminal = cast(TerminalRepr, longrepr) + longrepr_terminal.toterminal(out) else: try: - out.line(longrepr) + s = str(longrepr) except UnicodeEncodeError: - out.line("") + s = "" + out.line(s) - def get_sections(self, prefix): + def get_sections(self, prefix: str) -> Iterator[Tuple[str, str]]: for name, content in self.sections: if name.startswith(prefix): yield prefix, content @property - def longreprtext(self): - """ - Read-only property that returns the full string representation - of ``longrepr``. + def longreprtext(self) -> str: + """Read-only property that returns the full string representation of + ``longrepr``. .. versionadded:: 3.0 """ - tw = TerminalWriter(stringio=True) + file = StringIO() + tw = TerminalWriter(file) tw.hasmarkup = False self.toterminal(tw) - exc = tw.stringio.getvalue() + exc = file.getvalue() return exc.strip() @property - def caplog(self): - """Return captured log lines, if log capturing is enabled + def caplog(self) -> str: + """Return captured log lines, if log capturing is enabled. .. versionadded:: 3.5 """ @@ -99,8 +122,8 @@ def caplog(self): ) @property - def capstdout(self): - """Return captured text from stdout, if capturing is enabled + def capstdout(self) -> str: + """Return captured text from stdout, if capturing is enabled. .. versionadded:: 3.0 """ @@ -109,8 +132,8 @@ def capstdout(self): ) @property - def capstderr(self): - """Return captured text from stderr, if capturing is enabled + def capstderr(self) -> str: + """Return captured text from stderr, if capturing is enabled. .. versionadded:: 3.0 """ @@ -127,12 +150,9 @@ def fspath(self) -> str: return self.nodeid.split("::")[0] @property - def count_towards_summary(self): - """ - **Experimental** - - Returns True if this report should be counted towards the totals shown at the end of the - test session: "1 passed, 1 failure, etc". + def count_towards_summary(self) -> bool: + """**Experimental** Whether this report should be counted towards the + totals shown at the end of the test session: "1 passed, 1 failure, etc". .. note:: @@ -142,12 +162,10 @@ def count_towards_summary(self): return True @property - def head_line(self): - """ - **Experimental** - - Returns the head line shown with longrepr output for this report, more commonly during - traceback representation during failures:: + def head_line(self) -> Optional[str]: + """**Experimental** The head line shown with longrepr output for this + report, more commonly during traceback representation during + failures:: ________ Test.foo ________ @@ -162,31 +180,31 @@ def head_line(self): if self.location is not None: fspath, lineno, domain = self.location return domain + return None - def _get_verbose_word(self, config): + def _get_verbose_word(self, config: Config): _category, _short, verbose = config.hook.pytest_report_teststatus( report=self, config=config ) return verbose - def _to_json(self): - """ - This was originally the serialize_report() function from xdist (ca03269). + def _to_json(self) -> Dict[str, Any]: + """Return the contents of this report as a dict of builtin entries, + suitable for serialization. - Returns the contents of this report as a dict of builtin entries, suitable for - serialization. + This was originally the serialize_report() function from xdist (ca03269). Experimental method. """ return _report_to_json(self) @classmethod - def _from_json(cls, reportdict): - """ - This was originally the serialize_report() function from xdist (ca03269). + def _from_json(cls: Type[_R], reportdict: Dict[str, object]) -> _R: + """Create either a TestReport or CollectReport, depending on the calling class. - Factory method that returns either a TestReport or CollectReport, depending on the calling - class. It's the callers responsibility to know which class to pass here. + It is the callers responsibility to know which class to pass here. + + This was originally the serialize_report() function from xdist (ca03269). Experimental method. """ @@ -194,7 +212,9 @@ def _from_json(cls, reportdict): return cls(**kwargs) -def _report_unserialization_failure(type_name, report_class, reportdict): +def _report_unserialization_failure( + type_name: str, report_class: Type[BaseReport], reportdict +) -> "NoReturn": url = "https://github.com/pytest-dev/pytest/issues" stream = StringIO() pprint("-" * 100, stream=stream) @@ -206,85 +226,93 @@ def _report_unserialization_failure(type_name, report_class, reportdict): raise RuntimeError(stream.getvalue()) +@final class TestReport(BaseReport): - """ Basic test report object (also used for setup and teardown calls if - they fail). - """ + """Basic test report object (also used for setup and teardown calls if + they fail).""" __test__ = False def __init__( self, - nodeid, + nodeid: str, location: Tuple[str, Optional[int], str], keywords, - outcome, - longrepr, - when, - sections=(), - duration=0, - user_properties=None, - **extra + outcome: "Literal['passed', 'failed', 'skipped']", + longrepr: Union[ + None, ExceptionInfo[BaseException], Tuple[str, int, str], str, TerminalRepr + ], + when: "Literal['setup', 'call', 'teardown']", + sections: Iterable[Tuple[str, str]] = (), + duration: float = 0, + user_properties: Optional[Iterable[Tuple[str, object]]] = None, + **extra, ) -> None: - #: normalized collection node id + #: Normalized collection nodeid. self.nodeid = nodeid - #: a (filesystempath, lineno, domaininfo) tuple indicating the + #: 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. - self.location = location # type: Tuple[str, Optional[int], str] + self.location: Tuple[str, Optional[int], str] = location - #: a name -> value dictionary containing all keywords and + #: A name -> value dictionary containing all keywords and #: markers associated with a test invocation. self.keywords = keywords - #: test outcome, always one of "passed", "failed", "skipped". + #: Test outcome, always one of "passed", "failed", "skipped". self.outcome = outcome #: None or a failure representation. self.longrepr = longrepr - #: one of 'setup', 'call', 'teardown' to indicate runtest phase. + #: One of 'setup', 'call', 'teardown' to indicate runtest phase. self.when = when - #: user properties is a list of tuples (name, value) that holds user - #: defined properties of the test + #: User properties is a list of tuples (name, value) that holds user + #: defined properties of the test. self.user_properties = list(user_properties or []) - #: list of pairs ``(str, str)`` of extra information which needs to + #: List of pairs ``(str, str)`` of extra information which needs to #: marshallable. Used by pytest to add captured text #: from ``stdout`` and ``stderr``, but may be used by other plugins #: to add arbitrary information to reports. self.sections = list(sections) - #: time it took to run just the test + #: Time it took to run just the test. self.duration = duration self.__dict__.update(extra) - def __repr__(self): + def __repr__(self) -> str: return "<{} {!r} when={!r} outcome={!r}>".format( self.__class__.__name__, self.nodeid, self.when, self.outcome ) @classmethod - def from_item_and_call(cls, item, call) -> "TestReport": - """ - Factory method to create and fill a TestReport with standard item and call info. - """ + def from_item_and_call(cls, item: Item, call: "CallInfo[None]") -> "TestReport": + """Create and fill a TestReport with standard item and call info.""" when = call.when - duration = call.stop - call.start + # Remove "collect" from the Literal type -- only for collection calls. + assert when != "collect" + duration = call.duration keywords = {x: 1 for x in item.keywords} excinfo = call.excinfo sections = [] if not call.excinfo: - outcome = "passed" - longrepr = None + outcome: Literal["passed", "failed", "skipped"] = "passed" + longrepr: Union[ + None, + ExceptionInfo[BaseException], + Tuple[str, int, str], + str, + TerminalRepr, + ] = (None) else: if not isinstance(excinfo, ExceptionInfo): outcome = "failed" longrepr = excinfo - elif excinfo.errisinstance(skip.Exception): + elif isinstance(excinfo.value, skip.Exception): outcome = "skipped" r = excinfo._getreprcrash() longrepr = (str(r.path), r.lineno, r.message) @@ -297,7 +325,7 @@ def from_item_and_call(cls, item, call) -> "TestReport": excinfo, style=item.config.getoption("tbstyle", "auto") ) for rwhen, key, content in item._report_sections: - sections.append(("Captured {} {}".format(key, rwhen), content)) + sections.append((f"Captured {key} {rwhen}", content)) return cls( item.nodeid, item.location, @@ -311,45 +339,74 @@ def from_item_and_call(cls, item, call) -> "TestReport": ) +@final class CollectReport(BaseReport): + """Collection report object.""" + when = "collect" def __init__( - self, nodeid: str, outcome, longrepr, result: List[Node], sections=(), **extra + self, + nodeid: str, + outcome: "Literal['passed', 'skipped', 'failed']", + longrepr, + result: Optional[List[Union[Item, Collector]]], + sections: Iterable[Tuple[str, str]] = (), + **extra, ) -> None: + #: Normalized collection nodeid. self.nodeid = nodeid + + #: Test outcome, always one of "passed", "failed", "skipped". self.outcome = outcome + + #: None or a failure representation. self.longrepr = longrepr + + #: The collected items and collection nodes. self.result = result or [] + + #: List of pairs ``(str, str)`` of extra information which needs to + #: marshallable. + # Used by pytest to add captured text : from ``stdout`` and ``stderr``, + # but may be used by other plugins : to add arbitrary information to + # reports. self.sections = list(sections) + self.__dict__.update(extra) @property def location(self): return (self.fspath, None, self.fspath) - def __repr__(self): + def __repr__(self) -> str: return "".format( self.nodeid, len(self.result), self.outcome ) class CollectErrorRepr(TerminalRepr): - def __init__(self, msg): + def __init__(self, msg: str) -> None: self.longrepr = msg - def toterminal(self, out) -> None: + def toterminal(self, out: TerminalWriter) -> None: out.line(self.longrepr, red=True) -def pytest_report_to_serializable(report): +def pytest_report_to_serializable( + report: Union[CollectReport, TestReport] +) -> Optional[Dict[str, Any]]: if isinstance(report, (TestReport, CollectReport)): data = report._to_json() data["$report_type"] = report.__class__.__name__ return data + # TODO: Check if this is actually reachable. + return None # type: ignore[unreachable] -def pytest_report_from_serializable(data): +def pytest_report_from_serializable( + data: Dict[str, Any], +) -> Optional[Union[CollectReport, TestReport]]: if "$report_type" in data: if data["$report_type"] == "TestReport": return TestReport._from_json(data) @@ -358,45 +415,53 @@ def pytest_report_from_serializable(data): assert False, "Unknown report_type unserialize data: {}".format( data["$report_type"] ) + return None -def _report_to_json(report): - """ - This was originally the serialize_report() function from xdist (ca03269). +def _report_to_json(report: BaseReport) -> Dict[str, Any]: + """Return the contents of this report as a dict of builtin entries, + suitable for serialization. - Returns the contents of this report as a dict of builtin entries, suitable for - serialization. + This was originally the serialize_report() function from xdist (ca03269). """ - def serialize_repr_entry(entry): - entry_data = {"type": type(entry).__name__, "data": attr.asdict(entry)} - for key, value in entry_data["data"].items(): + def serialize_repr_entry( + entry: Union[ReprEntry, ReprEntryNative] + ) -> Dict[str, Any]: + data = attr.asdict(entry) + for key, value in data.items(): if hasattr(value, "__dict__"): - entry_data["data"][key] = attr.asdict(value) + data[key] = attr.asdict(value) + entry_data = {"type": type(entry).__name__, "data": data} return entry_data - def serialize_repr_traceback(reprtraceback: ReprTraceback): + def serialize_repr_traceback(reprtraceback: ReprTraceback) -> Dict[str, Any]: result = attr.asdict(reprtraceback) result["reprentries"] = [ serialize_repr_entry(x) for x in reprtraceback.reprentries ] return result - def serialize_repr_crash(reprcrash: Optional[ReprFileLocation]): + def serialize_repr_crash( + reprcrash: Optional[ReprFileLocation], + ) -> Optional[Dict[str, Any]]: if reprcrash is not None: return attr.asdict(reprcrash) else: return None - def serialize_longrepr(rep): - result = { - "reprcrash": serialize_repr_crash(rep.longrepr.reprcrash), - "reprtraceback": serialize_repr_traceback(rep.longrepr.reprtraceback), - "sections": rep.longrepr.sections, + def serialize_exception_longrepr(rep: BaseReport) -> Dict[str, Any]: + assert rep.longrepr is not None + # TODO: Investigate whether the duck typing is really necessary here. + longrepr = cast(ExceptionRepr, rep.longrepr) + result: Dict[str, Any] = { + "reprcrash": serialize_repr_crash(longrepr.reprcrash), + "reprtraceback": serialize_repr_traceback(longrepr.reprtraceback), + "sections": longrepr.sections, } - if isinstance(rep.longrepr, ExceptionChainRepr): + if isinstance(longrepr, ExceptionChainRepr): result["chain"] = [] - for repr_traceback, repr_crash, description in rep.longrepr.chain: + for repr_traceback, repr_crash, description in longrepr.chain: result["chain"].append( ( serialize_repr_traceback(repr_traceback), @@ -413,7 +478,7 @@ def serialize_longrepr(rep): if hasattr(report.longrepr, "reprtraceback") and hasattr( report.longrepr, "reprcrash" ): - d["longrepr"] = serialize_longrepr(report) + d["longrepr"] = serialize_exception_longrepr(report) else: d["longrepr"] = str(report.longrepr) else: @@ -426,11 +491,11 @@ def serialize_longrepr(rep): return d -def _report_kwargs_from_json(reportdict): - """ - This was originally the serialize_report() function from xdist (ca03269). +def _report_kwargs_from_json(reportdict: Dict[str, Any]) -> Dict[str, Any]: + """Return **kwargs that can be used to construct a TestReport or + CollectReport instance. - Returns **kwargs that can be used to construct a TestReport or CollectReport instance. + This was originally the serialize_report() function from xdist (ca03269). """ def deserialize_repr_entry(entry_data): @@ -447,13 +512,13 @@ def deserialize_repr_entry(entry_data): if data["reprlocals"]: reprlocals = ReprLocals(data["reprlocals"]["lines"]) - reprentry = ReprEntry( + reprentry: Union[ReprEntry, ReprEntryNative] = ReprEntry( lines=data["lines"], reprfuncargs=reprfuncargs, reprlocals=reprlocals, reprfileloc=reprfileloc, style=data["style"], - ) # type: Union[ReprEntry, ReprEntryNative] + ) elif entry_type == "ReprEntryNative": reprentry = ReprEntryNative(data["lines"]) else: @@ -466,7 +531,7 @@ def deserialize_repr_traceback(repr_traceback_dict): ] return ReprTraceback(**repr_traceback_dict) - def deserialize_repr_crash(repr_crash_dict: Optional[dict]): + def deserialize_repr_crash(repr_crash_dict: Optional[Dict[str, Any]]): if repr_crash_dict is not None: return ReprFileLocation(**repr_crash_dict) else: @@ -494,9 +559,9 @@ def deserialize_repr_crash(repr_crash_dict: Optional[dict]): description, ) ) - exception_info = ExceptionChainRepr( - chain - ) # type: Union[ExceptionChainRepr,ReprExceptionInfo] + exception_info: Union[ + ExceptionChainRepr, ReprExceptionInfo + ] = ExceptionChainRepr(chain) else: exception_info = ReprExceptionInfo(reprtraceback, reprcrash) diff --git a/src/_pytest/resultlog.py b/src/_pytest/resultlog.py deleted file mode 100644 index 6269c16f2be..00000000000 --- a/src/_pytest/resultlog.py +++ /dev/null @@ -1,102 +0,0 @@ -""" log machine-parseable test session result information in a plain -text file. -""" -import os - -import py - -from _pytest.store import StoreKey - - -resultlog_key = StoreKey["ResultLog"]() - - -def pytest_addoption(parser): - group = parser.getgroup("terminal reporting", "resultlog plugin options") - group.addoption( - "--resultlog", - "--result-log", - action="store", - metavar="path", - default=None, - help="DEPRECATED path for machine-readable result log.", - ) - - -def pytest_configure(config): - resultlog = config.option.resultlog - # prevent opening resultlog on slave nodes (xdist) - if resultlog and not hasattr(config, "slaveinput"): - dirname = os.path.dirname(os.path.abspath(resultlog)) - if not os.path.isdir(dirname): - os.makedirs(dirname) - logfile = open(resultlog, "w", 1) # line buffered - config._store[resultlog_key] = ResultLog(config, logfile) - config.pluginmanager.register(config._store[resultlog_key]) - - from _pytest.deprecated import RESULT_LOG - from _pytest.warnings import _issue_warning_captured - - _issue_warning_captured(RESULT_LOG, config.hook, stacklevel=2) - - -def pytest_unconfigure(config): - resultlog = config._store.get(resultlog_key, None) - if resultlog: - resultlog.logfile.close() - del config._store[resultlog_key] - config.pluginmanager.unregister(resultlog) - - -class ResultLog: - def __init__(self, config, logfile): - self.config = config - self.logfile = logfile # preferably line buffered - - def write_log_entry(self, testpath, lettercode, longrepr): - print("{} {}".format(lettercode, testpath), file=self.logfile) - for line in longrepr.splitlines(): - print(" %s" % line, file=self.logfile) - - def log_outcome(self, report, lettercode, longrepr): - testpath = getattr(report, "nodeid", None) - if testpath is None: - testpath = report.fspath - self.write_log_entry(testpath, lettercode, longrepr) - - def pytest_runtest_logreport(self, report): - if report.when != "call" and report.passed: - return - res = self.config.hook.pytest_report_teststatus( - report=report, config=self.config - ) - code = res[1] - if code == "x": - longrepr = str(report.longrepr) - elif code == "X": - longrepr = "" - elif report.passed: - longrepr = "" - elif report.failed: - longrepr = str(report.longrepr) - elif report.skipped: - longrepr = str(report.longrepr[2]) - self.log_outcome(report, code, longrepr) - - def pytest_collectreport(self, report): - if not report.passed: - if report.failed: - code = "F" - longrepr = str(report.longrepr) - else: - assert report.skipped - code = "S" - longrepr = "%s:%d: %s" % report.longrepr - self.log_outcome(report, code, longrepr) - - def pytest_internalerror(self, excrepr): - reprcrash = getattr(excrepr, "reprcrash", None) - path = getattr(reprcrash, "path", None) - if path is None: - path = "cwd:%s" % py.path.local() - self.write_log_entry(path, "!", str(excrepr)) diff --git a/src/_pytest/runner.py b/src/_pytest/runner.py index 412ea44a87d..794690ddb0b 100644 --- a/src/_pytest/runner.py +++ b/src/_pytest/runner.py @@ -1,37 +1,49 @@ -""" basic collect and runtest protocol implementations """ +"""Basic collect and runtest protocol implementations.""" import bdb import os import sys -from time import time from typing import Callable +from typing import cast from typing import Dict +from typing import Generic from typing import List from typing import Optional from typing import Tuple +from typing import Type +from typing import TYPE_CHECKING +from typing import TypeVar +from typing import Union import attr +from .reports import BaseReport from .reports import CollectErrorRepr from .reports import CollectReport from .reports import TestReport +from _pytest import timing from _pytest._code.code import ExceptionChainRepr from _pytest._code.code import ExceptionInfo -from _pytest.compat import TYPE_CHECKING +from _pytest._code.code import TerminalRepr +from _pytest.compat import final +from _pytest.config.argparsing import Parser from _pytest.nodes import Collector +from _pytest.nodes import Item from _pytest.nodes import Node from _pytest.outcomes import Exit from _pytest.outcomes import Skipped from _pytest.outcomes import TEST_OUTCOME if TYPE_CHECKING: - from typing import Type from typing_extensions import Literal + from _pytest.main import Session + from _pytest.terminal import TerminalReporter + # -# pytest plugin hooks +# pytest plugin hooks. -def pytest_addoption(parser): +def pytest_addoption(parser: Parser) -> None: group = parser.getgroup("terminal reporting", "reporting", after="general") group.addoption( "--durations", @@ -41,10 +53,19 @@ def pytest_addoption(parser): metavar="N", help="show N slowest setup/test durations (N=0 for all).", ) + group.addoption( + "--durations-min", + action="store", + type=float, + default=0.005, + metavar="N", + help="Minimal duration in seconds for inclusion in slowest list. Default 0.005", + ) -def pytest_terminal_summary(terminalreporter): +def pytest_terminal_summary(terminalreporter: "TerminalReporter") -> None: durations = terminalreporter.config.option.durations + durations_min = terminalreporter.config.option.durations_min verbose = terminalreporter.config.getvalue("verbose") if durations is None: return @@ -56,41 +77,46 @@ def pytest_terminal_summary(terminalreporter): dlist.append(rep) if not dlist: return - dlist.sort(key=lambda x: x.duration) - dlist.reverse() + dlist.sort(key=lambda x: x.duration, reverse=True) # type: ignore[no-any-return] if not durations: - tr.write_sep("=", "slowest test durations") + tr.write_sep("=", "slowest durations") else: - tr.write_sep("=", "slowest %s test durations" % durations) + tr.write_sep("=", "slowest %s durations" % durations) dlist = dlist[:durations] - for rep in dlist: - if verbose < 2 and rep.duration < 0.005: + for i, rep in enumerate(dlist): + if verbose < 2 and rep.duration < durations_min: tr.write_line("") - tr.write_line("(0.00 durations hidden. Use -vv to show these durations.)") + tr.write_line( + "(%s durations < %gs hidden. Use -vv to show these durations.)" + % (len(dlist) - i, durations_min) + ) break - tr.write_line("{:02.2f}s {:<8} {}".format(rep.duration, rep.when, rep.nodeid)) + tr.write_line(f"{rep.duration:02.2f}s {rep.when:<8} {rep.nodeid}") -def pytest_sessionstart(session): +def pytest_sessionstart(session: "Session") -> None: session._setupstate = SetupState() -def pytest_sessionfinish(session): +def pytest_sessionfinish(session: "Session") -> None: session._setupstate.teardown_all() -def pytest_runtest_protocol(item, nextitem): - item.ihook.pytest_runtest_logstart(nodeid=item.nodeid, location=item.location) +def pytest_runtest_protocol(item: Item, nextitem: Optional[Item]) -> bool: + ihook = item.ihook + ihook.pytest_runtest_logstart(nodeid=item.nodeid, location=item.location) runtestprotocol(item, nextitem=nextitem) - item.ihook.pytest_runtest_logfinish(nodeid=item.nodeid, location=item.location) + ihook.pytest_runtest_logfinish(nodeid=item.nodeid, location=item.location) return True -def runtestprotocol(item, log=True, nextitem=None): +def runtestprotocol( + item: Item, log: bool = True, nextitem: Optional[Item] = None +) -> List[TestReport]: hasrequest = hasattr(item, "_request") - if hasrequest and not item._request: - item._initrequest() + if hasrequest and not item._request: # type: ignore[attr-defined] + item._initrequest() # type: ignore[attr-defined] rep = call_and_report(item, "setup", log) reports = [rep] if rep.passed: @@ -99,15 +125,15 @@ def runtestprotocol(item, log=True, nextitem=None): if not item.config.getoption("setuponly", False): reports.append(call_and_report(item, "call", log)) reports.append(call_and_report(item, "teardown", log, nextitem=nextitem)) - # after all teardown hooks have been called - # want funcargs and request info to go away + # After all teardown hooks have been called + # want funcargs and request info to go away. if hasrequest: - item._request = False - item.funcargs = None + item._request = False # type: ignore[attr-defined] + item.funcargs = None # type: ignore[attr-defined] return reports -def show_test_item(item): +def show_test_item(item: Item) -> None: """Show test function, parameters and the fixtures of the test item.""" tw = item.config.get_terminal_writer() tw.line() @@ -116,14 +142,15 @@ def show_test_item(item): used_fixtures = sorted(getattr(item, "fixturenames", [])) if used_fixtures: tw.write(" (fixtures used: {})".format(", ".join(used_fixtures))) + tw.flush() -def pytest_runtest_setup(item): +def pytest_runtest_setup(item: Item) -> None: _update_current_test_var(item, "setup") item.session._setupstate.prepare(item) -def pytest_runtest_call(item): +def pytest_runtest_call(item: Item) -> None: _update_current_test_var(item, "call") try: del sys.last_type @@ -143,21 +170,22 @@ def pytest_runtest_call(item): raise e -def pytest_runtest_teardown(item, nextitem): +def pytest_runtest_teardown(item: Item, nextitem: Optional[Item]) -> None: _update_current_test_var(item, "teardown") item.session._setupstate.teardown_exact(item, nextitem) _update_current_test_var(item, None) -def _update_current_test_var(item, when): - """ - Update PYTEST_CURRENT_TEST to reflect the current item and stage. +def _update_current_test_var( + item: Item, when: Optional["Literal['setup', 'call', 'teardown']"] +) -> None: + """Update :envvar:`PYTEST_CURRENT_TEST` to reflect the current item and stage. - If ``when`` is None, delete PYTEST_CURRENT_TEST from the environment. + If ``when`` is None, delete ``PYTEST_CURRENT_TEST`` from the environment. """ var_name = "PYTEST_CURRENT_TEST" if when: - value = "{} ({})".format(item.nodeid, when) + value = f"{item.nodeid} ({when})" # don't allow null bytes on environment variables (see #2644, #2957) value = value.replace("\x00", "(null)") os.environ[var_name] = value @@ -165,7 +193,7 @@ def _update_current_test_var(item, when): os.environ.pop(var_name) -def pytest_report_teststatus(report): +def pytest_report_teststatus(report: BaseReport) -> Optional[Tuple[str, str, str]]: if report.when in ("setup", "teardown"): if report.failed: # category, shortletter, verbose-word @@ -174,6 +202,7 @@ def pytest_report_teststatus(report): return "skipped", "s", "SKIPPED" else: return "", "", "" + return None # @@ -181,11 +210,11 @@ def pytest_report_teststatus(report): def call_and_report( - item, when: "Literal['setup', 'call', 'teardown']", log=True, **kwds -): + item: Item, when: "Literal['setup', 'call', 'teardown']", log: bool = True, **kwds +) -> TestReport: call = call_runtest_hook(item, when, **kwds) hook = item.ihook - report = hook.pytest_runtest_makereport(item=item, call=call) + report: TestReport = hook.pytest_runtest_makereport(item=item, call=call) if log: hook.pytest_runtest_logreport(report=report) if check_interactive_exception(call, report): @@ -193,24 +222,33 @@ def call_and_report( return report -def check_interactive_exception(call, report): - return call.excinfo and not ( - hasattr(report, "wasxfail") - or call.excinfo.errisinstance(Skipped) - or call.excinfo.errisinstance(bdb.BdbQuit) - ) +def check_interactive_exception(call: "CallInfo[object]", report: BaseReport) -> bool: + """Check whether the call raised an exception that should be reported as + interactive.""" + if call.excinfo is None: + # Didn't raise. + return False + if hasattr(report, "wasxfail"): + # Exception was expected. + return False + if isinstance(call.excinfo.value, (Skipped, bdb.BdbQuit)): + # Special control flow exception. + return False + return True -def call_runtest_hook(item, when: "Literal['setup', 'call', 'teardown']", **kwds): +def call_runtest_hook( + item: Item, when: "Literal['setup', 'call', 'teardown']", **kwds +) -> "CallInfo[None]": if when == "setup": - ihook = item.ihook.pytest_runtest_setup + ihook: Callable[..., None] = item.ihook.pytest_runtest_setup elif when == "call": ihook = item.ihook.pytest_runtest_call elif when == "teardown": ihook = item.ihook.pytest_runtest_teardown else: - assert False, "Unhandled runtest hook case: {}".format(when) - reraise = (Exit,) # type: Tuple[Type[BaseException], ...] + assert False, f"Unhandled runtest hook case: {when}" + reraise: Tuple[Type[BaseException], ...] = (Exit,) if not item.config.getoption("usepdb", False): reraise += (KeyboardInterrupt,) return CallInfo.from_call( @@ -218,60 +256,99 @@ def call_runtest_hook(item, when: "Literal['setup', 'call', 'teardown']", **kwds ) +TResult = TypeVar("TResult", covariant=True) + + +@final @attr.s(repr=False) -class CallInfo: - """ Result/Exception info a function invocation. """ +class CallInfo(Generic[TResult]): + """Result/Exception info a function invocation. + + :param T result: + The return value of the call, if it didn't raise. Can only be + accessed if excinfo is None. + :param Optional[ExceptionInfo] excinfo: + The captured exception of the call, if it raised. + :param float start: + The system time when the call started, in seconds since the epoch. + :param float stop: + The system time when the call ended, in seconds since the epoch. + :param float duration: + The call duration, in seconds. + :param str when: + The context of invocation: "setup", "call", "teardown", ... + """ - _result = attr.ib() - excinfo = attr.ib(type=Optional[ExceptionInfo]) - start = attr.ib() - stop = attr.ib() - when = attr.ib() + _result = attr.ib(type="Optional[TResult]") + excinfo = attr.ib(type=Optional[ExceptionInfo[BaseException]]) + start = attr.ib(type=float) + stop = attr.ib(type=float) + duration = attr.ib(type=float) + when = attr.ib(type="Literal['collect', 'setup', 'call', 'teardown']") @property - def result(self): + def result(self) -> TResult: if self.excinfo is not None: - raise AttributeError("{!r} has no valid result".format(self)) - return self._result + raise AttributeError(f"{self!r} has no valid result") + # The cast is safe because an exception wasn't raised, hence + # _result has the expected function return type (which may be + # None, that's why a cast and not an assert). + return cast(TResult, self._result) @classmethod - def from_call(cls, func, when, reraise=None) -> "CallInfo": - #: context of invocation: one of "setup", "call", - #: "teardown", "memocollect" - start = time() + def from_call( + cls, + func: "Callable[[], TResult]", + when: "Literal['collect', 'setup', 'call', 'teardown']", + reraise: Optional[ + Union[Type[BaseException], Tuple[Type[BaseException], ...]] + ] = None, + ) -> "CallInfo[TResult]": excinfo = None + start = timing.time() + precise_start = timing.perf_counter() try: - result = func() - except: # noqa + result: Optional[TResult] = func() + except BaseException: excinfo = ExceptionInfo.from_current() - if reraise is not None and excinfo.errisinstance(reraise): + if reraise is not None and isinstance(excinfo.value, reraise): raise result = None - stop = time() - return cls(start=start, stop=stop, when=when, result=result, excinfo=excinfo) - - def __repr__(self): + # use the perf counter + precise_stop = timing.perf_counter() + duration = precise_stop - precise_start + stop = timing.time() + return cls( + start=start, + stop=stop, + duration=duration, + when=when, + result=result, + excinfo=excinfo, + ) + + def __repr__(self) -> str: if self.excinfo is None: - return "".format(self.when, self._result) - return "".format(self.when, self.excinfo) + return f"" + return f"" -def pytest_runtest_makereport(item, call): +def pytest_runtest_makereport(item: Item, call: CallInfo[None]) -> TestReport: return TestReport.from_item_and_call(item, call) def pytest_make_collect_report(collector: Collector) -> CollectReport: call = CallInfo.from_call(lambda: list(collector.collect()), "collect") - longrepr = None + longrepr: Union[None, Tuple[str, int, str], str, TerminalRepr] = None if not call.excinfo: - outcome = "passed" + outcome: Literal["passed", "skipped", "failed"] = "passed" else: skip_exceptions = [Skipped] unittest = sys.modules.get("unittest") if unittest is not None: # Type ignored because unittest is loaded dynamically. skip_exceptions.append(unittest.SkipTest) # type: ignore - if call.excinfo.errisinstance(tuple(skip_exceptions)): + if isinstance(call.excinfo.value, tuple(skip_exceptions)): outcome = "skipped" r_ = collector._repr_failure_py(call.excinfo, "line") assert isinstance(r_, ExceptionChainRepr), repr(r_) @@ -282,24 +359,24 @@ def pytest_make_collect_report(collector: Collector) -> CollectReport: outcome = "failed" errorinfo = collector.repr_failure(call.excinfo) if not hasattr(errorinfo, "toterminal"): + assert isinstance(errorinfo, str) errorinfo = CollectErrorRepr(errorinfo) longrepr = errorinfo - rep = CollectReport( - collector.nodeid, outcome, longrepr, getattr(call, "result", None) - ) + result = call.result if not call.excinfo else None + rep = CollectReport(collector.nodeid, outcome, longrepr, result) rep.call = call # type: ignore # see collect_one_node return rep class SetupState: - """ shared state for setting up/tearing down test items or collectors. """ + """Shared state for setting up/tearing down test items or collectors.""" def __init__(self): - self.stack = [] # type: List[Node] - self._finalizers = {} # type: Dict[Node, List[Callable[[], None]]] + self.stack: List[Node] = [] + self._finalizers: Dict[Node, List[Callable[[], object]]] = {} - def addfinalizer(self, finalizer, colitem): - """ attach a finalizer to the given colitem. """ + def addfinalizer(self, finalizer: Callable[[], object], colitem) -> None: + """Attach a finalizer to the given colitem.""" assert colitem and not isinstance(colitem, tuple) assert callable(finalizer) # assert colitem in self.stack # some unit tests don't setup stack :/ @@ -309,7 +386,7 @@ def _pop_and_teardown(self): colitem = self.stack.pop() self._teardown_with_finalization(colitem) - def _callfinalizers(self, colitem): + def _callfinalizers(self, colitem) -> None: finalizers = self._finalizers.pop(colitem, None) exc = None while finalizers: @@ -324,24 +401,24 @@ def _callfinalizers(self, colitem): if exc: raise exc - def _teardown_with_finalization(self, colitem): + def _teardown_with_finalization(self, colitem) -> None: self._callfinalizers(colitem) colitem.teardown() for colitem in self._finalizers: assert colitem in self.stack - def teardown_all(self): + def teardown_all(self) -> None: while self.stack: self._pop_and_teardown() for key in list(self._finalizers): self._teardown_with_finalization(key) assert not self._finalizers - def teardown_exact(self, item, nextitem): + def teardown_exact(self, item, nextitem) -> None: needed_collectors = nextitem and nextitem.listchain() or [] self._teardown_towards(needed_collectors) - def _teardown_towards(self, needed_collectors): + def _teardown_towards(self, needed_collectors) -> None: exc = None while self.stack: if self.stack == needed_collectors[: len(self.stack)]: @@ -356,30 +433,29 @@ def _teardown_towards(self, needed_collectors): if exc: raise exc - def prepare(self, colitem): - """ setup objects along the collector chain to the test-method - and teardown previously setup objects.""" - needed_collectors = colitem.listchain() - self._teardown_towards(needed_collectors) + def prepare(self, colitem) -> None: + """Setup objects along the collector chain to the test-method.""" - # check if the last collection node has raised an error + # Check if the last collection node has raised an error. for col in self.stack: if hasattr(col, "_prepare_exc"): - exc = col._prepare_exc + exc = col._prepare_exc # type: ignore[attr-defined] raise exc + + needed_collectors = colitem.listchain() for col in needed_collectors[len(self.stack) :]: self.stack.append(col) try: col.setup() except TEST_OUTCOME as e: - col._prepare_exc = e + col._prepare_exc = e # type: ignore[attr-defined] raise e -def collect_one_node(collector): +def collect_one_node(collector: Collector) -> CollectReport: ihook = collector.ihook ihook.pytest_collectstart(collector=collector) - rep = ihook.pytest_make_collect_report(collector=collector) + rep: CollectReport = ihook.pytest_make_collect_report(collector=collector) call = rep.__dict__.pop("call", None) if call and check_interactive_exception(call, rep): ihook.pytest_exception_interact(node=collector, call=call, report=rep) diff --git a/src/_pytest/setuponly.py b/src/_pytest/setuponly.py index aa5a95ff920..44a1094c0d2 100644 --- a/src/_pytest/setuponly.py +++ b/src/_pytest/setuponly.py @@ -1,7 +1,17 @@ +from typing import Generator +from typing import Optional +from typing import Union + import pytest +from _pytest._io.saferepr import saferepr +from _pytest.config import Config +from _pytest.config import ExitCode +from _pytest.config.argparsing import Parser +from _pytest.fixtures import FixtureDef +from _pytest.fixtures import SubRequest -def pytest_addoption(parser): +def pytest_addoption(parser: Parser) -> None: group = parser.getgroup("debugconfig") group.addoption( "--setuponly", @@ -18,7 +28,9 @@ def pytest_addoption(parser): @pytest.hookimpl(hookwrapper=True) -def pytest_fixture_setup(fixturedef, request): +def pytest_fixture_setup( + fixturedef: FixtureDef[object], request: SubRequest +) -> Generator[None, None, None]: yield if request.config.option.setupshow: if hasattr(request, "param"): @@ -26,24 +38,25 @@ def pytest_fixture_setup(fixturedef, request): # display it now and during the teardown (in .finish()). if fixturedef.ids: if callable(fixturedef.ids): - fixturedef.cached_param = fixturedef.ids(request.param) + param = fixturedef.ids(request.param) else: - fixturedef.cached_param = fixturedef.ids[request.param_index] + param = fixturedef.ids[request.param_index] else: - fixturedef.cached_param = request.param + param = request.param + fixturedef.cached_param = param # type: ignore[attr-defined] _show_fixture_action(fixturedef, "SETUP") -def pytest_fixture_post_finalizer(fixturedef) -> None: +def pytest_fixture_post_finalizer(fixturedef: FixtureDef[object]) -> None: if fixturedef.cached_result is not None: config = fixturedef._fixturemanager.config if config.option.setupshow: _show_fixture_action(fixturedef, "TEARDOWN") if hasattr(fixturedef, "cached_param"): - del fixturedef.cached_param + del fixturedef.cached_param # type: ignore[attr-defined] -def _show_fixture_action(fixturedef, msg): +def _show_fixture_action(fixturedef: FixtureDef[object], msg: str) -> None: config = fixturedef._fixturemanager.config capman = config.pluginmanager.getplugin("capturemanager") if capman: @@ -66,13 +79,16 @@ def _show_fixture_action(fixturedef, msg): tw.write(" (fixtures used: {})".format(", ".join(deps))) if hasattr(fixturedef, "cached_param"): - tw.write("[{}]".format(fixturedef.cached_param)) + tw.write("[{}]".format(saferepr(fixturedef.cached_param, maxsize=42))) # type: ignore[attr-defined] + + tw.flush() if capman: capman.resume_global_capture() @pytest.hookimpl(tryfirst=True) -def pytest_cmdline_main(config): +def pytest_cmdline_main(config: Config) -> Optional[Union[int, ExitCode]]: if config.option.setuponly: config.option.setupshow = True + return None diff --git a/src/_pytest/setupplan.py b/src/_pytest/setupplan.py index 6fdd3aed064..9ba81ccaf0a 100644 --- a/src/_pytest/setupplan.py +++ b/src/_pytest/setupplan.py @@ -1,7 +1,15 @@ +from typing import Optional +from typing import Union + import pytest +from _pytest.config import Config +from _pytest.config import ExitCode +from _pytest.config.argparsing import Parser +from _pytest.fixtures import FixtureDef +from _pytest.fixtures import SubRequest -def pytest_addoption(parser): +def pytest_addoption(parser: Parser) -> None: group = parser.getgroup("debugconfig") group.addoption( "--setupplan", @@ -13,16 +21,20 @@ def pytest_addoption(parser): @pytest.hookimpl(tryfirst=True) -def pytest_fixture_setup(fixturedef, request): +def pytest_fixture_setup( + fixturedef: FixtureDef[object], request: SubRequest +) -> Optional[object]: # Will return a dummy fixture if the setuponly option is provided. if request.config.option.setupplan: my_cache_key = fixturedef.cache_key(request) fixturedef.cached_result = (None, my_cache_key, None) return fixturedef.cached_result + return None @pytest.hookimpl(tryfirst=True) -def pytest_cmdline_main(config): +def pytest_cmdline_main(config: Config) -> Optional[Union[int, ExitCode]]: if config.option.setupplan: config.option.setuponly = True config.option.setupshow = True + return None diff --git a/src/_pytest/skipping.py b/src/_pytest/skipping.py index fe8742c6675..9aacfecee7a 100644 --- a/src/_pytest/skipping.py +++ b/src/_pytest/skipping.py @@ -1,18 +1,30 @@ -""" support for skip/xfail functions and markers. """ +"""Support for skip/xfail functions and markers.""" +import os +import platform +import sys +import traceback +from collections.abc import Mapping +from typing import Generator +from typing import Optional +from typing import Tuple +from typing import Type + +import attr + +from _pytest.config import Config from _pytest.config import hookimpl -from _pytest.mark.evaluate import MarkEvaluator +from _pytest.config.argparsing import Parser +from _pytest.mark.structures import Mark +from _pytest.nodes import Item from _pytest.outcomes import fail from _pytest.outcomes import skip from _pytest.outcomes import xfail +from _pytest.reports import BaseReport +from _pytest.runner import CallInfo from _pytest.store import StoreKey -skipped_by_mark_key = StoreKey[bool]() -evalxfail_key = StoreKey[MarkEvaluator]() -unexpectedsuccess_key = StoreKey[str]() - - -def pytest_addoption(parser): +def pytest_addoption(parser: Parser) -> None: group = parser.getgroup("general") group.addoption( "--runxfail", @@ -31,7 +43,7 @@ def pytest_addoption(parser): ) -def pytest_configure(config): +def pytest_configure(config: Config) -> None: if config.option.runxfail: # yay a hack import pytest @@ -42,7 +54,7 @@ def pytest_configure(config): def nop(*args, **kwargs): pass - nop.Exception = xfail.Exception + nop.Exception = xfail.Exception # type: ignore[attr-defined] setattr(pytest, "xfail", nop) config.addinivalue_line( @@ -53,131 +65,260 @@ def nop(*args, **kwargs): ) config.addinivalue_line( "markers", - "skipif(condition): skip the given test function if eval(condition) " - "results in a True value. Evaluation happens within the " - "module global context. Example: skipif('sys.platform == \"win32\"') " - "skips the test if we are on the win32 platform. see " - "https://docs.pytest.org/en/latest/skipping.html", + "skipif(condition, ..., *, reason=...): " + "skip the given test function if any of the conditions evaluate to True. " + "Example: skipif(sys.platform == 'win32') skips the test if we are on the win32 platform. " + "See https://docs.pytest.org/en/stable/reference.html#pytest-mark-skipif", ) config.addinivalue_line( "markers", - "xfail(condition, reason=None, run=True, raises=None, strict=False): " - "mark the test function as an expected failure if eval(condition) " - "has a True value. Optionally specify a reason for better reporting " + "xfail(condition, ..., *, reason=..., run=True, raises=None, strict=xfail_strict): " + "mark the test function as an expected failure if any of the conditions " + "evaluate to True. Optionally specify a reason for better reporting " "and run=False if you don't even want to execute the test function. " "If only specific exception(s) are expected, you can list them in " "raises, and if the test fails in other ways, it will be reported as " - "a true failure. See https://docs.pytest.org/en/latest/skipping.html", + "a true failure. See https://docs.pytest.org/en/stable/reference.html#pytest-mark-xfail", ) -@hookimpl(tryfirst=True) -def pytest_runtest_setup(item): - # Check if skip or skipif are specified as pytest marks - item._store[skipped_by_mark_key] = False - eval_skipif = MarkEvaluator(item, "skipif") - if eval_skipif.istrue(): - item._store[skipped_by_mark_key] = True - skip(eval_skipif.getexplanation()) - - for skip_info in item.iter_markers(name="skip"): - item._store[skipped_by_mark_key] = True - if "reason" in skip_info.kwargs: - skip(skip_info.kwargs["reason"]) - elif skip_info.args: - skip(skip_info.args[0]) +def evaluate_condition(item: Item, mark: Mark, condition: object) -> Tuple[bool, str]: + """Evaluate a single skipif/xfail condition. + + If an old-style string condition is given, it is eval()'d, otherwise the + condition is bool()'d. If this fails, an appropriately formatted pytest.fail + is raised. + + Returns (result, reason). The reason is only relevant if the result is True. + """ + # String condition. + if isinstance(condition, str): + globals_ = { + "os": os, + "sys": sys, + "platform": platform, + "config": item.config, + } + for dictionary in reversed( + item.ihook.pytest_markeval_namespace(config=item.config) + ): + if not isinstance(dictionary, Mapping): + raise ValueError( + "pytest_markeval_namespace() needs to return a dict, got {!r}".format( + dictionary + ) + ) + globals_.update(dictionary) + if hasattr(item, "obj"): + globals_.update(item.obj.__globals__) # type: ignore[attr-defined] + try: + filename = f"<{mark.name} condition>" + condition_code = compile(condition, filename, "eval") + result = eval(condition_code, globals_) + except SyntaxError as exc: + msglines = [ + "Error evaluating %r condition" % mark.name, + " " + condition, + " " + " " * (exc.offset or 0) + "^", + "SyntaxError: invalid syntax", + ] + fail("\n".join(msglines), pytrace=False) + except Exception as exc: + msglines = [ + "Error evaluating %r condition" % mark.name, + " " + condition, + *traceback.format_exception_only(type(exc), exc), + ] + fail("\n".join(msglines), pytrace=False) + + # Boolean condition. + else: + try: + result = bool(condition) + except Exception as exc: + msglines = [ + "Error evaluating %r condition as a boolean" % mark.name, + *traceback.format_exception_only(type(exc), exc), + ] + fail("\n".join(msglines), pytrace=False) + + reason = mark.kwargs.get("reason", None) + if reason is None: + if isinstance(condition, str): + reason = "condition: " + condition else: - skip("unconditional skip") + # XXX better be checked at collection time + msg = ( + "Error evaluating %r: " % mark.name + + "you need to specify reason=STRING when using booleans as conditions." + ) + fail(msg, pytrace=False) - item._store[evalxfail_key] = MarkEvaluator(item, "xfail") - check_xfail_no_run(item) + return result, reason -@hookimpl(hookwrapper=True) -def pytest_pyfunc_call(pyfuncitem): - check_xfail_no_run(pyfuncitem) - outcome = yield - passed = outcome.excinfo is None - if passed: - check_strict_xfail(pyfuncitem) +@attr.s(slots=True, frozen=True) +class Skip: + """The result of evaluate_skip_marks().""" + + reason = attr.ib(type=str) + +def evaluate_skip_marks(item: Item) -> Optional[Skip]: + """Evaluate skip and skipif marks on item, returning Skip if triggered.""" + for mark in item.iter_markers(name="skipif"): + if "condition" not in mark.kwargs: + conditions = mark.args + else: + conditions = (mark.kwargs["condition"],) + + # Unconditional. + if not conditions: + reason = mark.kwargs.get("reason", "") + return Skip(reason) -def check_xfail_no_run(item): - """check xfail(run=False)""" - if not item.config.option.runxfail: - evalxfail = item._store[evalxfail_key] - if evalxfail.istrue(): - if not evalxfail.get("run", True): - xfail("[NOTRUN] " + evalxfail.getexplanation()) + # If any of the conditions are true. + for condition in conditions: + result, reason = evaluate_condition(item, mark, condition) + if result: + return Skip(reason) + for mark in item.iter_markers(name="skip"): + if "reason" in mark.kwargs: + reason = mark.kwargs["reason"] + elif mark.args: + reason = mark.args[0] + else: + reason = "unconditional skip" + return Skip(reason) -def check_strict_xfail(pyfuncitem): - """check xfail(strict=True) for the given PASSING test""" - evalxfail = pyfuncitem._store[evalxfail_key] - if evalxfail.istrue(): - strict_default = pyfuncitem.config.getini("xfail_strict") - is_strict_xfail = evalxfail.get("strict", strict_default) - if is_strict_xfail: - del pyfuncitem._store[evalxfail_key] - explanation = evalxfail.getexplanation() - fail("[XPASS(strict)] " + explanation, pytrace=False) + return None + + +@attr.s(slots=True, frozen=True) +class Xfail: + """The result of evaluate_xfail_marks().""" + + reason = attr.ib(type=str) + run = attr.ib(type=bool) + strict = attr.ib(type=bool) + raises = attr.ib(type=Optional[Tuple[Type[BaseException], ...]]) + + +def evaluate_xfail_marks(item: Item) -> Optional[Xfail]: + """Evaluate xfail marks on item, returning Xfail if triggered.""" + for mark in item.iter_markers(name="xfail"): + run = mark.kwargs.get("run", True) + strict = mark.kwargs.get("strict", item.config.getini("xfail_strict")) + raises = mark.kwargs.get("raises", None) + if "condition" not in mark.kwargs: + conditions = mark.args + else: + conditions = (mark.kwargs["condition"],) + + # Unconditional. + if not conditions: + reason = mark.kwargs.get("reason", "") + return Xfail(reason, run, strict, raises) + + # If any of the conditions are true. + for condition in conditions: + result, reason = evaluate_condition(item, mark, condition) + if result: + return Xfail(reason, run, strict, raises) + + return None + + +# Whether skipped due to skip or skipif marks. +skipped_by_mark_key = StoreKey[bool]() +# Saves the xfail mark evaluation. Can be refreshed during call if None. +xfailed_key = StoreKey[Optional[Xfail]]() +unexpectedsuccess_key = StoreKey[str]() + + +@hookimpl(tryfirst=True) +def pytest_runtest_setup(item: Item) -> None: + skipped = evaluate_skip_marks(item) + item._store[skipped_by_mark_key] = skipped is not None + if skipped: + skip(skipped.reason) + + item._store[xfailed_key] = xfailed = evaluate_xfail_marks(item) + if xfailed and not item.config.option.runxfail and not xfailed.run: + xfail("[NOTRUN] " + xfailed.reason) @hookimpl(hookwrapper=True) -def pytest_runtest_makereport(item, call): +def pytest_runtest_call(item: Item) -> Generator[None, None, None]: + xfailed = item._store.get(xfailed_key, None) + if xfailed is None: + item._store[xfailed_key] = xfailed = evaluate_xfail_marks(item) + + if xfailed and not item.config.option.runxfail and not xfailed.run: + xfail("[NOTRUN] " + xfailed.reason) + + yield + + # The test run may have added an xfail mark dynamically. + xfailed = item._store.get(xfailed_key, None) + if xfailed is None: + item._store[xfailed_key] = xfailed = evaluate_xfail_marks(item) + + +@hookimpl(hookwrapper=True) +def pytest_runtest_makereport(item: Item, call: CallInfo[None]): outcome = yield rep = outcome.get_result() - evalxfail = item._store.get(evalxfail_key, None) + xfailed = item._store.get(xfailed_key, None) # unittest special case, see setting of unexpectedsuccess_key if unexpectedsuccess_key in item._store and rep.when == "call": reason = item._store[unexpectedsuccess_key] if reason: - rep.longrepr = "Unexpected success: {}".format(reason) + rep.longrepr = f"Unexpected success: {reason}" else: rep.longrepr = "Unexpected success" rep.outcome = "failed" - elif item.config.option.runxfail: pass # don't interfere - elif call.excinfo and call.excinfo.errisinstance(xfail.Exception): + elif call.excinfo and isinstance(call.excinfo.value, xfail.Exception): + assert call.excinfo.value.msg is not None rep.wasxfail = "reason: " + call.excinfo.value.msg rep.outcome = "skipped" - elif evalxfail and not rep.skipped and evalxfail.wasvalid() and evalxfail.istrue(): + elif not rep.skipped and xfailed: if call.excinfo: - if evalxfail.invalidraise(call.excinfo.value): + raises = xfailed.raises + if raises is not None and not isinstance(call.excinfo.value, raises): rep.outcome = "failed" else: rep.outcome = "skipped" - rep.wasxfail = evalxfail.getexplanation() + rep.wasxfail = xfailed.reason elif call.when == "call": - strict_default = item.config.getini("xfail_strict") - is_strict_xfail = evalxfail.get("strict", strict_default) - explanation = evalxfail.getexplanation() - if is_strict_xfail: + if xfailed.strict: rep.outcome = "failed" - rep.longrepr = "[XPASS(strict)] {}".format(explanation) + rep.longrepr = "[XPASS(strict)] " + xfailed.reason else: rep.outcome = "passed" - rep.wasxfail = explanation - elif ( + rep.wasxfail = xfailed.reason + + if ( item._store.get(skipped_by_mark_key, True) and rep.skipped and type(rep.longrepr) is tuple ): - # skipped by mark.skipif; change the location of the failure + # Skipped by mark.skipif; change the location of the failure # to point to the item definition, otherwise it will display - # the location of where the skip exception was raised within pytest + # the location of where the skip exception was raised within pytest. _, _, reason = rep.longrepr - filename, line = item.location[:2] - rep.longrepr = filename, line + 1, reason - - -# called by terminalreporter progress reporting + filename, line = item.reportinfo()[:2] + assert line is not None + rep.longrepr = str(filename), line + 1, reason -def pytest_report_teststatus(report): +def pytest_report_teststatus(report: BaseReport) -> Optional[Tuple[str, str, str]]: if hasattr(report, "wasxfail"): if report.skipped: return "xfailed", "x", "XFAIL" elif report.passed: return "xpassed", "X", "XPASS" + return None diff --git a/src/_pytest/stepwise.py b/src/_pytest/stepwise.py index 6fa21cd1c65..197577c790f 100644 --- a/src/_pytest/stepwise.py +++ b/src/_pytest/stepwise.py @@ -1,79 +1,92 @@ +from typing import List +from typing import Optional +from typing import TYPE_CHECKING + import pytest +from _pytest import nodes +from _pytest.config import Config +from _pytest.config.argparsing import Parser +from _pytest.main import Session +from _pytest.reports import TestReport + +if TYPE_CHECKING: + from _pytest.cacheprovider import Cache +STEPWISE_CACHE_DIR = "cache/stepwise" -def pytest_addoption(parser): + +def pytest_addoption(parser: Parser) -> None: group = parser.getgroup("general") group.addoption( "--sw", "--stepwise", action="store_true", + default=False, dest="stepwise", help="exit on test failure and continue from last failing test next time", ) group.addoption( + "--sw-skip", "--stepwise-skip", action="store_true", + default=False, dest="stepwise_skip", help="ignore the first failing test but stop on the next failing test", ) @pytest.hookimpl -def pytest_configure(config): - config.pluginmanager.register(StepwisePlugin(config), "stepwiseplugin") +def pytest_configure(config: Config) -> None: + # We should always have a cache as cache provider plugin uses tryfirst=True + if config.getoption("stepwise"): + config.pluginmanager.register(StepwisePlugin(config), "stepwiseplugin") + + +def pytest_sessionfinish(session: Session) -> None: + if not session.config.getoption("stepwise"): + assert session.config.cache is not None + # Clear the list of failing tests if the plugin is not active. + session.config.cache.set(STEPWISE_CACHE_DIR, []) class StepwisePlugin: - def __init__(self, config): + def __init__(self, config: Config) -> None: self.config = config - self.active = config.getvalue("stepwise") - self.session = None + self.session: Optional[Session] = None self.report_status = "" + assert config.cache is not None + self.cache: Cache = config.cache + self.lastfailed: Optional[str] = self.cache.get(STEPWISE_CACHE_DIR, None) + self.skip: bool = config.getoption("stepwise_skip") - if self.active: - self.lastfailed = config.cache.get("cache/stepwise", None) - self.skip = config.getvalue("stepwise_skip") - - def pytest_sessionstart(self, session): + def pytest_sessionstart(self, session: Session) -> None: self.session = session - def pytest_collection_modifyitems(self, session, config, items): - if not self.active: - return + def pytest_collection_modifyitems( + self, config: Config, items: List[nodes.Item] + ) -> None: if not self.lastfailed: self.report_status = "no previously failed tests, not skipping." return - already_passed = [] - found = False - - # Make a list of all tests that have been run before the last failing one. - for item in items: + # check all item nodes until we find a match on last failed + failed_index = None + for index, item in enumerate(items): if item.nodeid == self.lastfailed: - found = True + failed_index = index break - else: - already_passed.append(item) # If the previously failed test was not found among the test items, # do not skip any tests. - if not found: + if failed_index is None: self.report_status = "previously failed test not found, not skipping." - already_passed = [] else: - self.report_status = "skipping {} already passed items.".format( - len(already_passed) - ) - - for item in already_passed: - items.remove(item) - - config.hook.pytest_deselected(items=already_passed) - - def pytest_runtest_logreport(self, report): - if not self.active: - return + self.report_status = f"skipping {failed_index} already passed items." + deselected = items[:failed_index] + del items[:failed_index] + config.hook.pytest_deselected(items=deselected) + def pytest_runtest_logreport(self, report: TestReport) -> None: if report.failed: if self.skip: # Remove test from the failed ones (if it exists) and unset the skip option @@ -85,6 +98,7 @@ def pytest_runtest_logreport(self, report): else: # Mark test as the last failing and interrupt the test session. self.lastfailed = report.nodeid + assert self.session is not None self.session.shouldstop = ( "Test failed, continuing from this test next run." ) @@ -96,13 +110,10 @@ def pytest_runtest_logreport(self, report): if report.nodeid == self.lastfailed: self.lastfailed = None - def pytest_report_collectionfinish(self): - if self.active and self.config.getoption("verbose") >= 0 and self.report_status: - return "stepwise: %s" % self.report_status + def pytest_report_collectionfinish(self) -> Optional[str]: + if self.config.getoption("verbose") >= 0 and self.report_status: + return f"stepwise: {self.report_status}" + return None - def pytest_sessionfinish(self, session): - if self.active: - self.config.cache.set("cache/stepwise", self.lastfailed) - else: - # Clear the list of failing tests if the plugin is not active. - self.config.cache.set("cache/stepwise", []) + def pytest_sessionfinish(self) -> None: + self.cache.set(STEPWISE_CACHE_DIR, self.lastfailed) diff --git a/src/_pytest/store.py b/src/_pytest/store.py index eed50d103aa..e5008cfc5a1 100644 --- a/src/_pytest/store.py +++ b/src/_pytest/store.py @@ -27,7 +27,7 @@ class StoreKey(Generic[T]): class Store: """Store is a type-safe heterogenous mutable mapping that allows keys and value types to be defined separately from - where it is defined. + where it (the Store) is created. Usually you will be given an object which has a ``Store``: @@ -77,13 +77,13 @@ class Store: Good solution: module Internal adds a ``Store`` to the object. Module External mints StoreKeys for its own keys. Module External stores and - retrieves its data using its keys. + retrieves its data using these keys. """ __slots__ = ("_store",) def __init__(self) -> None: - self._store = {} # type: Dict[StoreKey[Any], object] + self._store: Dict[StoreKey[Any], object] = {} def __setitem__(self, key: StoreKey[T], value: T) -> None: """Set a value for key.""" @@ -92,7 +92,7 @@ def __setitem__(self, key: StoreKey[T], value: T) -> None: def __getitem__(self, key: StoreKey[T]) -> T: """Get the value for key. - Raises KeyError if the key wasn't set before. + Raises ``KeyError`` if the key wasn't set before. """ return cast(T, self._store[key]) @@ -116,10 +116,10 @@ def setdefault(self, key: StoreKey[T], default: T) -> T: def __delitem__(self, key: StoreKey[T]) -> None: """Delete the value for key. - Raises KeyError if the key wasn't set before. + Raises ``KeyError`` if the key wasn't set before. """ del self._store[key] def __contains__(self, key: StoreKey[T]) -> bool: - """Returns whether key was set.""" + """Return whether key was set.""" return key in self._store diff --git a/src/_pytest/terminal.py b/src/_pytest/terminal.py index 7127ac74bcd..0e0ed70e5be 100644 --- a/src/_pytest/terminal.py +++ b/src/_pytest/terminal.py @@ -1,38 +1,61 @@ -""" terminal reporting of the full testing process. +"""Terminal reporting of the full testing process. This is a good source for looking at the various reporting hooks. """ import argparse -import collections import datetime +import inspect import platform import sys -import time import warnings +from collections import Counter from functools import partial +from pathlib import Path from typing import Any from typing import Callable +from typing import cast from typing import Dict +from typing import Generator from typing import List from typing import Mapping from typing import Optional +from typing import Sequence from typing import Set +from typing import TextIO from typing import Tuple +from typing import TYPE_CHECKING +from typing import Union import attr import pluggy import py -from more_itertools import collapse -import pytest +import _pytest._version from _pytest import nodes -from _pytest._io import TerminalWriter +from _pytest import timing +from _pytest._code import ExceptionInfo +from _pytest._code.code import ExceptionRepr +from _pytest._io.wcwidth import wcswidth +from _pytest.compat import final +from _pytest.config import _PluggyPlugin from _pytest.config import Config from _pytest.config import ExitCode -from _pytest.main import Session +from _pytest.config import hookimpl +from _pytest.config.argparsing import Parser +from _pytest.nodes import Item +from _pytest.nodes import Node +from _pytest.pathlib import absolutepath +from _pytest.pathlib import bestrelpath +from _pytest.reports import BaseReport from _pytest.reports import CollectReport from _pytest.reports import TestReport +if TYPE_CHECKING: + from typing_extensions import Literal + + from _pytest.main import Session + + REPORT_COLLECTING_RESOLUTION = 0.5 KNOWN_TYPES = ( @@ -50,14 +73,20 @@ class MoreQuietAction(argparse.Action): - """ - a modified copy of the argparse count action which counts down and updates - the legacy quiet attribute at the same time + """A modified copy of the argparse count action which counts down and updates + the legacy quiet attribute at the same time. - used to unify verbosity handling + Used to unify verbosity handling. """ - def __init__(self, option_strings, dest, default=None, required=False, help=None): + def __init__( + self, + option_strings: Sequence[str], + dest: str, + default: object = None, + required: bool = False, + help: Optional[str] = None, + ) -> None: super().__init__( option_strings=option_strings, dest=dest, @@ -67,14 +96,20 @@ def __init__(self, option_strings, dest, default=None, required=False, help=None help=help, ) - def __call__(self, parser, namespace, values, option_string=None): + def __call__( + self, + parser: argparse.ArgumentParser, + namespace: argparse.Namespace, + values: Union[str, Sequence[object], None], + option_string: Optional[str] = None, + ) -> None: new_count = getattr(namespace, self.dest, 0) - 1 setattr(namespace, self.dest, new_count) # todo Deprecate config.quiet namespace.quiet = getattr(namespace, "quiet", 0) + 1 -def pytest_addoption(parser): +def pytest_addoption(parser: Parser) -> None: group = parser.getgroup("terminal reporting", "reporting", after="general") group._addoption( "-v", @@ -84,6 +119,20 @@ def pytest_addoption(parser): dest="verbose", help="increase verbosity.", ) + group._addoption( + "--no-header", + action="store_true", + default=False, + dest="no_header", + help="disable header", + ) + group._addoption( + "--no-summary", + action="store_true", + default=False, + dest="no_summary", + help="disable summary", + ) group._addoption( "-q", "--quiet", @@ -161,6 +210,12 @@ def pytest_addoption(parser): choices=["yes", "no", "auto"], help="color terminal output (yes/no/auto).", ) + group._addoption( + "--code-highlight", + default="yes", + choices=["yes", "no"], + help="Whether code should be highlighted (only if --color is also enabled)", + ) parser.addini( "console_output_style", @@ -182,7 +237,7 @@ def mywriter(tags, args): def getreportopt(config: Config) -> str: - reportchars = config.option.reportchars + reportchars: str = config.option.reportchars old_aliases = {"F", "S"} reportopts = "" @@ -206,15 +261,15 @@ def getreportopt(config: Config) -> str: return reportopts -@pytest.hookimpl(trylast=True) # after _pytest.runner -def pytest_report_teststatus(report: TestReport) -> Tuple[str, str, str]: +@hookimpl(trylast=True) # after _pytest.runner +def pytest_report_teststatus(report: BaseReport) -> Tuple[str, str, str]: letter = "F" if report.passed: letter = "." elif report.skipped: letter = "s" - outcome = report.outcome + outcome: str = report.outcome if report.when in ("collect", "setup", "teardown") and outcome == "failed": outcome = "error" letter = "E" @@ -224,127 +279,131 @@ def pytest_report_teststatus(report: TestReport) -> Tuple[str, str, str]: @attr.s class WarningReport: - """ - Simple structure to hold warnings information captured by ``pytest_warning_captured``. + """Simple structure to hold warnings information captured by ``pytest_warning_recorded``. - :ivar str message: user friendly message about the warning - :ivar str|None nodeid: node id that generated the warning (see ``get_location``). + :ivar str message: + User friendly message about the warning. + :ivar str|None nodeid: + nodeid that generated the warning (see ``get_location``). :ivar tuple|py.path.local fslocation: - file system location of the source of the warning (see ``get_location``). + File system location of the source of the warning (see ``get_location``). """ message = attr.ib(type=str) nodeid = attr.ib(type=Optional[str], default=None) - fslocation = attr.ib(default=None) + fslocation = attr.ib( + type=Optional[Union[Tuple[str, int], py.path.local]], default=None + ) count_towards_summary = True - def get_location(self, config): - """ - Returns the more user-friendly information about the location - of a warning, or None. - """ + def get_location(self, config: Config) -> Optional[str]: + """Return the more user-friendly information about the location of a warning, or None.""" if self.nodeid: return self.nodeid if self.fslocation: if isinstance(self.fslocation, tuple) and len(self.fslocation) >= 2: filename, linenum = self.fslocation[:2] - relpath = py.path.local(filename).relto(config.invocation_dir) - if not relpath: - relpath = str(filename) - return "{}:{}".format(relpath, linenum) + relpath = bestrelpath( + config.invocation_params.dir, absolutepath(filename) + ) + return f"{relpath}:{linenum}" else: return str(self.fslocation) return None +@final class TerminalReporter: - def __init__(self, config: Config, file=None) -> None: + def __init__(self, config: Config, file: Optional[TextIO] = None) -> None: import _pytest.config self.config = config self._numcollected = 0 - self._session = None # type: Optional[Session] - self._showfspath = None + self._session: Optional[Session] = None + self._showfspath: Optional[bool] = None - self.stats = {} # type: Dict[str, List[Any]] - self._main_color = None # type: Optional[str] - self._known_types = None # type: Optional[List] + self.stats: Dict[str, List[Any]] = {} + self._main_color: Optional[str] = None + self._known_types: Optional[List[str]] = None self.startdir = config.invocation_dir + self.startpath = config.invocation_params.dir if file is None: file = sys.stdout self._tw = _pytest.config.create_terminal_writer(config, file) self._screen_width = self._tw.fullwidth - self.currentfspath = None # type: Any + self.currentfspath: Union[None, Path, str, int] = None self.reportchars = getreportopt(config) self.hasmarkup = self._tw.hasmarkup self.isatty = file.isatty() - self._progress_nodeids_reported = set() # type: Set[str] + self._progress_nodeids_reported: Set[str] = set() self._show_progress_info = self._determine_show_progress_info() - self._collect_report_last_write = None # type: Optional[float] + self._collect_report_last_write: Optional[float] = None + self._already_displayed_warnings: Optional[int] = None + self._keyboardinterrupt_memo: Optional[ExceptionRepr] = None - @property - def writer(self) -> TerminalWriter: - warnings.warn( - pytest.PytestDeprecationWarning( - "TerminalReporter.writer attribute is deprecated, use TerminalReporter._tw instead at your own risk.\n" - "See https://docs.pytest.org/en/latest/deprecations.html#terminalreporter-writer for more information." - ) - ) - return self._tw - - def _determine_show_progress_info(self): - """Return True if we should display progress information based on the current config""" + 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": return False # do not show progress if we are showing fixture setup/teardown if self.config.getoption("setupshow", False): return False - cfg = self.config.getini("console_output_style") - if cfg in ("progress", "count"): - return cfg - return False + cfg: str = self.config.getini("console_output_style") + if cfg == "progress": + return "progress" + elif cfg == "count": + return "count" + else: + return False @property - def verbosity(self): - return self.config.option.verbose + def verbosity(self) -> int: + verbosity: int = self.config.option.verbose + return verbosity @property - def showheader(self): + def showheader(self) -> bool: return self.verbosity >= 0 @property - def showfspath(self): + def no_header(self) -> bool: + return bool(self.config.option.no_header) + + @property + def no_summary(self) -> bool: + return bool(self.config.option.no_summary) + + @property + def showfspath(self) -> bool: if self._showfspath is None: return self.verbosity >= 0 return self._showfspath @showfspath.setter - def showfspath(self, value): + def showfspath(self, value: Optional[bool]) -> None: self._showfspath = value @property - def showlongtestinfo(self): + def showlongtestinfo(self) -> bool: return self.verbosity > 0 - def hasopt(self, char): + def hasopt(self, char: str) -> bool: char = {"xfailed": "x", "skipped": "s"}.get(char, char) return char in self.reportchars - def write_fspath_result(self, nodeid, res, **markup): - fspath = self.config.rootdir.join(nodeid.split("::")[0]) - # NOTE: explicitly check for None to work around py bug, and for less - # overhead in general (https://github.com/pytest-dev/py/pull/207). + def write_fspath_result(self, nodeid: str, res, **markup: bool) -> None: + fspath = self.config.rootpath / nodeid.split("::")[0] if self.currentfspath is None or fspath != self.currentfspath: if self.currentfspath is not None and self._show_progress_info: self._write_progress_information_filling_space() self.currentfspath = fspath - fspath = self.startdir.bestrelpath(fspath) + relfspath = bestrelpath(self.startpath, fspath) self._tw.line() - self._tw.write(fspath + " ") - self._tw.write(res, **markup) + self._tw.write(relfspath + " ") + self._tw.write(res, flush=True, **markup) - def write_ensure_prefix(self, prefix, extra="", **kwargs): + def write_ensure_prefix(self, prefix: str, extra: str = "", **kwargs) -> None: if self.currentfspath != prefix: self._tw.line() self.currentfspath = prefix @@ -353,25 +412,28 @@ def write_ensure_prefix(self, prefix, extra="", **kwargs): self._tw.write(extra, **kwargs) self.currentfspath = -2 - def ensure_newline(self): + def ensure_newline(self) -> None: if self.currentfspath: self._tw.line() self.currentfspath = None - def write(self, content, **markup): - self._tw.write(content, **markup) + def write(self, content: str, *, flush: bool = False, **markup: bool) -> None: + self._tw.write(content, flush=flush, **markup) - def write_line(self, line, **markup): + def flush(self) -> None: + self._tw.flush() + + def write_line(self, line: Union[str, bytes], **markup: bool) -> None: if not isinstance(line, str): line = str(line, errors="replace") self.ensure_newline() self._tw.line(line, **markup) - def rewrite(self, line, **markup): - """ - Rewinds the terminal cursor to the beginning and writes the given line. + def rewrite(self, line: str, **markup: bool) -> None: + """Rewinds the terminal cursor to the beginning and writes the given line. - :kwarg erase: if True, will also add spaces until the full terminal width to ensure + :param erase: + If True, will also add spaces until the full terminal width to ensure previous lines are properly erased. The rest of the keyword arguments are markup instructions. @@ -385,73 +447,84 @@ def rewrite(self, line, **markup): line = str(line) self._tw.write("\r" + line + fill, **markup) - def write_sep(self, sep, title=None, **markup): + def write_sep( + self, + sep: str, + title: Optional[str] = None, + fullwidth: Optional[int] = None, + **markup: bool, + ) -> None: self.ensure_newline() - self._tw.sep(sep, title, **markup) + self._tw.sep(sep, title, fullwidth, **markup) - def section(self, title, sep="=", **kw): + def section(self, title: str, sep: str = "=", **kw: bool) -> None: self._tw.sep(sep, title, **kw) - def line(self, msg, **kw): + def line(self, msg: str, **kw: bool) -> None: self._tw.line(msg, **kw) - def _add_stats(self, category: str, items: List) -> None: + def _add_stats(self, category: str, items: Sequence[Any]) -> None: set_main_color = category not in self.stats - self.stats.setdefault(category, []).extend(items[:]) + self.stats.setdefault(category, []).extend(items) if set_main_color: self._set_main_color() - def pytest_internalerror(self, excrepr): + def pytest_internalerror(self, excrepr: ExceptionRepr) -> bool: for line in str(excrepr).split("\n"): self.write_line("INTERNALERROR> " + line) - return 1 + return True - def pytest_warning_captured(self, warning_message, item): - # from _pytest.nodes import get_fslocation_from_item + def pytest_warning_recorded( + self, warning_message: warnings.WarningMessage, nodeid: str, + ) -> None: from _pytest.warnings import warning_record_to_str fslocation = warning_message.filename, warning_message.lineno message = warning_record_to_str(warning_message) - nodeid = item.nodeid if item is not None else "" warning_report = WarningReport( fslocation=fslocation, message=message, nodeid=nodeid ) self._add_stats("warnings", [warning_report]) - def pytest_plugin_registered(self, plugin): + def pytest_plugin_registered(self, plugin: _PluggyPlugin) -> None: if self.config.option.traceconfig: - msg = "PLUGIN registered: {}".format(plugin) - # XXX this event may happen during setup/teardown time + msg = f"PLUGIN registered: {plugin}" + # XXX This event may happen during setup/teardown time # which unfortunately captures our output here - # which garbles our output if we use self.write_line + # which garbles our output if we use self.write_line. self.write_line(msg) - def pytest_deselected(self, items): + def pytest_deselected(self, items: Sequence[Item]) -> None: self._add_stats("deselected", items) - def pytest_runtest_logstart(self, nodeid, location): - # ensure that the path is printed before the - # 1st test of a module starts running + def pytest_runtest_logstart( + self, nodeid: str, location: Tuple[str, Optional[int], str] + ) -> None: + # Ensure that the path is printed before the + # 1st test of a module starts running. if self.showlongtestinfo: line = self._locationline(nodeid, *location) self.write_ensure_prefix(line, "") + self.flush() elif self.showfspath: - fsid = nodeid.split("::")[0] - self.write_fspath_result(fsid, "") + self.write_fspath_result(nodeid, "") + self.flush() def pytest_runtest_logreport(self, report: TestReport) -> None: self._tests_ran = True rep = report - res = self.config.hook.pytest_report_teststatus(report=rep, config=self.config) + 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 - if isinstance(word, tuple): - word, markup = word - else: + if not isinstance(word, tuple): markup = None + else: + word, markup = word self._add_stats(category, [rep]) if not letter and not word: - # probably passed setup/teardown + # Probably passed setup/teardown. return running_xdist = hasattr(rep, "node") if markup is None: @@ -467,20 +540,27 @@ def pytest_runtest_logreport(self, report: TestReport) -> None: else: markup = {} if self.verbosity <= 0: - if not running_xdist and self.showfspath: - self.write_fspath_result(rep.nodeid, letter, **markup) - else: - self._tw.write(letter, **markup) + self._tw.write(letter, **markup) else: self._progress_nodeids_reported.add(rep.nodeid) line = self._locationline(rep.nodeid, *rep.location) if not running_xdist: self.write_ensure_prefix(line, word, **markup) + if rep.skipped or hasattr(report, "wasxfail"): + available_width = ( + (self._tw.fullwidth - self._tw.width_of_current_line) + - len(" [100%]") + - 1 + ) + reason = _get_raw_skip_reason(rep) + reason_ = _format_trimmed(" ({})", reason, available_width) + if reason and reason_ is not None: + self._tw.write(reason_) if self._show_progress_info: self._write_progress_information_filling_space() else: self.ensure_newline() - self._tw.write("[%s]" % rep.node.gateway.id) # type: ignore + self._tw.write("[%s]" % rep.node.gateway.id) if self._show_progress_info: self._tw.write( self._get_progress_information_message() + " ", cyan=True @@ -490,12 +570,14 @@ def pytest_runtest_logreport(self, report: TestReport) -> None: self._tw.write(word, **markup) self._tw.write(" " + line) self.currentfspath = -2 + self.flush() @property - def _is_last_item(self): + def _is_last_item(self) -> bool: + assert self._session is not None return len(self._progress_nodeids_reported) == self._session.testscollected - def pytest_runtest_logfinish(self, nodeid): + def pytest_runtest_logfinish(self, nodeid: str) -> None: assert self._session if self.verbosity <= 0 and self._show_progress_info: if self._show_progress_info == "count": @@ -523,9 +605,9 @@ def _get_progress_information_message(self) -> str: if collected: progress = self._progress_nodeids_reported counter_format = "{{:{}d}}".format(len(str(collected))) - format_string = " [{}/{{}}]".format(counter_format) + format_string = f" [{counter_format}/{{}}]" return format_string.format(len(progress), collected) - return " [ {} / {} ]".format(collected, collected) + return f" [ {collected} / {collected} ]" else: if collected: return " [{:3d}%]".format( @@ -533,47 +615,43 @@ def _get_progress_information_message(self) -> str: ) return " [100%]" - def _write_progress_information_filling_space(self): + def _write_progress_information_filling_space(self) -> None: color, _ = self._get_main_color() msg = self._get_progress_information_message() w = self._width_of_current_line fill = self._tw.fullwidth - w - 1 - self.write(msg.rjust(fill), **{color: True}) + self.write(msg.rjust(fill), flush=True, **{color: True}) @property - def _width_of_current_line(self): - """Return the width of current line, using the superior implementation of py-1.6 when available""" - try: - return self._tw.width_of_current_line - except AttributeError: - # py < 1.6.0 - return self._tw.chars_on_current_line + def _width_of_current_line(self) -> int: + """Return the width of the current line.""" + return self._tw.width_of_current_line def pytest_collection(self) -> None: if self.isatty: if self.config.option.verbose >= 0: - self.write("collecting ... ", bold=True) - self._collect_report_last_write = time.time() + self.write("collecting ... ", flush=True, bold=True) + self._collect_report_last_write = timing.time() elif self.config.option.verbose >= 1: - self.write("collecting ... ", bold=True) + self.write("collecting ... ", flush=True, bold=True) def pytest_collectreport(self, report: CollectReport) -> None: if report.failed: self._add_stats("error", [report]) elif report.skipped: self._add_stats("skipped", [report]) - items = [x for x in report.result if isinstance(x, pytest.Item)] + items = [x for x in report.result if isinstance(x, Item)] self._numcollected += len(items) if self.isatty: self.report_collect() - def report_collect(self, final=False): + def report_collect(self, final: bool = False) -> None: if self.config.option.verbose < 0: return if not final: # Only write "collecting" report every 0.5s. - t = time.time() + t = timing.time() if ( self._collect_report_last_write is not None and self._collect_report_last_write > t - REPORT_COLLECTING_RESOLUTION @@ -607,49 +685,55 @@ def report_collect(self, final=False): else: self.write_line(line) - @pytest.hookimpl(trylast=True) - def pytest_sessionstart(self, session: Session) -> None: + @hookimpl(trylast=True) + def pytest_sessionstart(self, session: "Session") -> None: self._session = session - self._sessionstarttime = time.time() + self._sessionstarttime = timing.time() if not self.showheader: return self.write_sep("=", "test session starts", bold=True) verinfo = platform.python_version() - msg = "platform {} -- Python {}".format(sys.platform, verinfo) - pypy_version_info = getattr(sys, "pypy_version_info", None) - if pypy_version_info: - verinfo = ".".join(map(str, pypy_version_info[:3])) - msg += "[pypy-{}-{}]".format(verinfo, pypy_version_info[3]) - msg += ", pytest-{}, py-{}, pluggy-{}".format( - pytest.__version__, py.__version__, pluggy.__version__ - ) - if ( - self.verbosity > 0 - or self.config.option.debug - or getattr(self.config.option, "pastebin", None) - ): - msg += " -- " + str(sys.executable) - self.write_line(msg) - lines = self.config.hook.pytest_report_header( - config=self.config, startdir=self.startdir - ) - self._write_report_lines_from_hooks(lines) + if not self.no_header: + msg = f"platform {sys.platform} -- Python {verinfo}" + pypy_version_info = getattr(sys, "pypy_version_info", None) + if pypy_version_info: + verinfo = ".".join(map(str, pypy_version_info[:3])) + msg += "[pypy-{}-{}]".format(verinfo, pypy_version_info[3]) + msg += ", pytest-{}, py-{}, pluggy-{}".format( + _pytest._version.version, py.__version__, pluggy.__version__ + ) + if ( + self.verbosity > 0 + or self.config.option.debug + or getattr(self.config.option, "pastebin", None) + ): + msg += " -- " + str(sys.executable) + self.write_line(msg) + lines = self.config.hook.pytest_report_header( + config=self.config, startdir=self.startdir + ) + self._write_report_lines_from_hooks(lines) + + def _write_report_lines_from_hooks( + self, lines: Sequence[Union[str, Sequence[str]]] + ) -> None: + for line_or_lines in reversed(lines): + if isinstance(line_or_lines, str): + self.write_line(line_or_lines) + else: + for line in line_or_lines: + self.write_line(line) - def _write_report_lines_from_hooks(self, lines): - lines.reverse() - for line in collapse(lines): - self.write_line(line) + def pytest_report_header(self, config: Config) -> List[str]: + line = "rootdir: %s" % config.rootpath - def pytest_report_header(self, config): - line = "rootdir: %s" % config.rootdir + if config.inipath: + line += ", configfile: " + bestrelpath(config.rootpath, config.inipath) - if config.inifile: - line += ", inifile: " + config.rootdir.bestrelpath(config.inifile) + testpaths: List[str] = config.getini("testpaths") + if config.invocation_params.dir == config.rootpath and config.args == testpaths: + line += ", testpaths: {}".format(", ".join(testpaths)) - testpaths = config.getini("testpaths") - if testpaths and config.args == testpaths: - rel_paths = [config.rootdir.bestrelpath(x) for x in testpaths] - line += ", testpaths: {}".format(", ".join(rel_paths)) result = [line] plugininfo = config.pluginmanager.list_plugin_distinfo() @@ -657,41 +741,40 @@ def pytest_report_header(self, config): result.append("plugins: %s" % ", ".join(_plugin_nameversions(plugininfo))) return result - def pytest_collection_finish(self, session): + def pytest_collection_finish(self, session: "Session") -> None: self.report_collect(True) - if self.config.getoption("collectonly"): - self._printcollecteditems(session.items) - lines = self.config.hook.pytest_report_collectionfinish( config=self.config, startdir=self.startdir, items=session.items ) self._write_report_lines_from_hooks(lines) if self.config.getoption("collectonly"): + if session.items: + if self.config.option.verbose > -1: + self._tw.line("") + self._printcollecteditems(session.items) + failed = self.stats.get("failed") if failed: self._tw.sep("!", "collection failures") for rep in failed: rep.toterminal(self._tw) - def _printcollecteditems(self, items): - # to print out items and their parent collectors + def _printcollecteditems(self, items: Sequence[Item]) -> None: + # To print out items and their parent collectors # we take care to leave out Instances aka () - # because later versions are going to get rid of them anyway + # because later versions are going to get rid of them anyway. if self.config.option.verbose < 0: if self.config.option.verbose < -1: - counts = {} # type: Dict[str, int] - for item in items: - name = item.nodeid.split("::", 1)[0] - counts[name] = counts.get(name, 0) + 1 + counts = Counter(item.nodeid.split("::", 1)[0] for item in items) for name, count in sorted(counts.items()): self._tw.line("%s: %d" % (name, count)) else: for item in items: self._tw.line(item.nodeid) return - stack = [] + stack: List[Node] = [] indent = "" for item in items: needed_collectors = item.listchain()[1:] # strip root node @@ -704,14 +787,18 @@ def _printcollecteditems(self, items): if col.name == "()": # Skip Instances. continue indent = (len(stack) - 1) * " " - self._tw.line("{}{}".format(indent, col)) + self._tw.line(f"{indent}{col}") if self.config.option.verbose >= 1: - if hasattr(col, "_obj") and col._obj.__doc__: - for line in col._obj.__doc__.strip().splitlines(): - self._tw.line("{}{}".format(indent + " ", line.strip())) - - @pytest.hookimpl(hookwrapper=True) - def pytest_sessionfinish(self, session: Session, exitstatus: ExitCode): + obj = getattr(col, "obj", None) + doc = inspect.getdoc(obj) if obj else None + if doc: + for line in doc.splitlines(): + self._tw.line("{}{}".format(indent + " ", line)) + + @hookimpl(hookwrapper=True) + def pytest_sessionfinish( + self, session: "Session", exitstatus: Union[int, ExitCode] + ): outcome = yield outcome.get_result() self._tw.line("") @@ -722,21 +809,21 @@ def pytest_sessionfinish(self, session: Session, exitstatus: ExitCode): ExitCode.USAGE_ERROR, ExitCode.NO_TESTS_COLLECTED, ) - if exitstatus in summary_exit_codes: + if exitstatus in summary_exit_codes and not self.no_summary: self.config.hook.pytest_terminal_summary( terminalreporter=self, exitstatus=exitstatus, config=self.config ) if session.shouldfail: - self.write_sep("!", session.shouldfail, red=True) + self.write_sep("!", str(session.shouldfail), red=True) if exitstatus == ExitCode.INTERRUPTED: self._report_keyboardinterrupt() - del self._keyboardinterrupt_memo + self._keyboardinterrupt_memo = None elif session.shouldstop: - self.write_sep("!", session.shouldstop, red=True) + self.write_sep("!", str(session.shouldstop), red=True) self.summary_stats() - @pytest.hookimpl(hookwrapper=True) - def pytest_terminal_summary(self): + @hookimpl(hookwrapper=True) + def pytest_terminal_summary(self) -> Generator[None, None, None]: self.summary_errors() self.summary_failures() self.summary_warnings() @@ -746,15 +833,17 @@ def pytest_terminal_summary(self): # Display any extra warnings from teardown here (if any). self.summary_warnings() - def pytest_keyboard_interrupt(self, excinfo): + def pytest_keyboard_interrupt(self, excinfo: ExceptionInfo[BaseException]) -> None: self._keyboardinterrupt_memo = excinfo.getrepr(funcargs=True) - def pytest_unconfigure(self): - if hasattr(self, "_keyboardinterrupt_memo"): + def pytest_unconfigure(self) -> None: + if self._keyboardinterrupt_memo is not None: self._report_keyboardinterrupt() - def _report_keyboardinterrupt(self): + def _report_keyboardinterrupt(self) -> None: excrepr = self._keyboardinterrupt_memo + assert excrepr is not None + assert excrepr.reprcrash is not None msg = excrepr.reprcrash.message self.write_sep("!", msg) if "KeyboardInterrupt" in msg: @@ -777,14 +866,14 @@ def mkrel(nodeid): line += "[".join(values) return line - # collect_fspath comes from testid which has a "/"-normalized path + # collect_fspath comes from testid which has a "/"-normalized path. if fspath: res = mkrel(nodeid) if self.verbosity >= 2 and nodeid.split("::")[0] != fspath.replace( "\\", nodes.SEP ): - res += " <- " + self.startdir.bestrelpath(fspath) + res += " <- " + bestrelpath(self.startpath, fspath) else: res = "[location]" return res + " " @@ -805,24 +894,22 @@ def _getcrashline(self, rep): return "" # - # summaries for sessionfinish + # Summaries for sessionfinish. # - def getreports(self, name): + def getreports(self, name: str): values = [] for x in self.stats.get(name, []): if not hasattr(x, "_pdbshown"): values.append(x) return values - def summary_warnings(self): + def summary_warnings(self) -> None: if self.hasopt("w"): - all_warnings = self.stats.get( - "warnings" - ) # type: Optional[List[WarningReport]] + all_warnings: Optional[List[WarningReport]] = self.stats.get("warnings") if not all_warnings: return - final = hasattr(self, "_already_displayed_warnings") + final = self._already_displayed_warnings is not None if final: warning_reports = all_warnings[self._already_displayed_warnings :] else: @@ -831,15 +918,13 @@ def summary_warnings(self): if not warning_reports: return - reports_grouped_by_message = ( - collections.OrderedDict() - ) # type: collections.OrderedDict[str, List[WarningReport]] + reports_grouped_by_message: Dict[str, List[WarningReport]] = {} for wr in warning_reports: reports_grouped_by_message.setdefault(wr.message, []).append(wr) - def collapsed_location_report(reports: List[WarningReport]): + def collapsed_location_report(reports: List[WarningReport]) -> str: locations = [] - for w in warning_reports: + for w in reports: location = w.get_location(self.config) if location: locations.append(location) @@ -847,20 +932,18 @@ def collapsed_location_report(reports: List[WarningReport]): if len(locations) < 10: return "\n".join(map(str, locations)) - counts_by_filename = collections.Counter( + counts_by_filename = Counter( str(loc).split("::", 1)[0] for loc in locations ) return "\n".join( - "{0}: {1} test{2} with warning{2}".format( - k, v, "s" if v > 1 else "" - ) + "{}: {} warning{}".format(k, v, "s" if v > 1 else "") for k, v in counts_by_filename.items() ) title = "warnings summary (final)" if final else "warnings summary" self.write_sep("=", title, yellow=True, bold=False) - for message, warning_reports in reports_grouped_by_message.items(): - maybe_location = collapsed_location_report(warning_reports) + for message, message_reports in reports_grouped_by_message.items(): + maybe_location = collapsed_location_report(message_reports) if maybe_location: self._tw.line(maybe_location) lines = message.splitlines() @@ -870,12 +953,12 @@ def collapsed_location_report(reports: List[WarningReport]): message = message.rstrip() self._tw.line(message) self._tw.line() - self._tw.line("-- Docs: https://docs.pytest.org/en/latest/warnings.html") + self._tw.line("-- Docs: https://docs.pytest.org/en/stable/warnings.html") - def summary_passes(self): + def summary_passes(self) -> None: if self.config.option.tbstyle != "no": if self.hasopt("P"): - reports = self.getreports("passed") + reports: List[TestReport] = self.getreports("passed") if not reports: return self.write_sep("=", "PASSES") @@ -887,9 +970,10 @@ def summary_passes(self): self._handle_teardown_sections(rep.nodeid) def _get_teardown_reports(self, nodeid: str) -> List[TestReport]: + reports = self.getreports("") return [ report - for report in self.getreports("") + for report in reports if report.when == "teardown" and report.nodeid == nodeid ] @@ -910,9 +994,9 @@ def print_teardown_sections(self, rep: TestReport) -> None: content = content[:-1] self._tw.line(content) - def summary_failures(self): + def summary_failures(self) -> None: if self.config.option.tbstyle != "no": - reports = self.getreports("failed") + reports: List[BaseReport] = self.getreports("failed") if not reports: return self.write_sep("=", "FAILURES") @@ -927,9 +1011,9 @@ def summary_failures(self): self._outrep_summary(rep) self._handle_teardown_sections(rep.nodeid) - def summary_errors(self): + def summary_errors(self) -> None: if self.config.option.tbstyle != "no": - reports = self.getreports("error") + reports: List[BaseReport] = self.getreports("error") if not reports: return self.write_sep("=", "ERRORS") @@ -938,11 +1022,11 @@ def summary_errors(self): if rep.when == "collect": msg = "ERROR collecting " + msg else: - msg = "ERROR at {} of {}".format(rep.when, msg) + msg = f"ERROR at {rep.when} of {msg}" self.write_sep("_", msg, red=True, bold=True) self._outrep_summary(rep) - def _outrep_summary(self, rep): + def _outrep_summary(self, rep: BaseReport) -> None: rep.toterminal(self._tw) showcapture = self.config.option.showcapture if showcapture == "no": @@ -955,11 +1039,11 @@ def _outrep_summary(self, rep): content = content[:-1] self._tw.line(content) - def summary_stats(self): + def summary_stats(self) -> None: if self.verbosity < -1: return - session_duration = time.time() - self._sessionstarttime + session_duration = timing.time() - self._sessionstarttime (parts, main_color) = self.build_summary_stats_line() line_parts = [] @@ -1011,7 +1095,7 @@ def show_xfailed(lines: List[str]) -> None: for rep in xfailed: verbose_word = rep._get_verbose_word(self.config) pos = _get_pos(self.config, rep) - lines.append("{} {}".format(verbose_word, pos)) + lines.append(f"{verbose_word} {pos}") reason = rep.wasxfail if reason: lines.append(" " + str(reason)) @@ -1022,11 +1106,11 @@ def show_xpassed(lines: List[str]) -> None: verbose_word = rep._get_verbose_word(self.config) pos = _get_pos(self.config, rep) reason = rep.wasxfail - lines.append("{} {} {}".format(verbose_word, pos, reason)) + lines.append(f"{verbose_word} {pos} {reason}") def show_skipped(lines: List[str]) -> None: - skipped = self.stats.get("skipped", []) - fskips = _folded_skips(skipped) if skipped else [] + skipped: List[CollectReport] = self.stats.get("skipped", []) + fskips = _folded_skips(self.startpath, skipped) if skipped else [] if not fskips: return verbose_word = skipped[0]._get_verbose_word(self.config) @@ -1041,16 +1125,16 @@ def show_skipped(lines: List[str]) -> None: else: lines.append("%s [%d] %s: %s" % (verbose_word, num, fspath, reason)) - REPORTCHAR_ACTIONS = { + REPORTCHAR_ACTIONS: Mapping[str, Callable[[List[str]], None]] = { "x": show_xfailed, "X": show_xpassed, "f": partial(show_simple, "failed"), "s": show_skipped, "p": partial(show_simple, "passed"), "E": partial(show_simple, "error"), - } # type: Mapping[str, Callable[[List[str]], None]] + } - lines = [] # type: List[str] + lines: List[str] = [] for char in self.reportchars: action = REPORTCHAR_ACTIONS.get(char) if action: # skipping e.g. "P" (passed with output) here. @@ -1081,7 +1165,7 @@ def _determine_main_color(self, unknown_type_seen: bool) -> str: return main_color def _set_main_color(self) -> None: - unknown_types = [] # type: List[str] + unknown_types: List[str] = [] for found_type in self.stats.keys(): if found_type: # setup/teardown reports have an empty key, ignore them if found_type not in KNOWN_TYPES and found_type not in unknown_types: @@ -1090,87 +1174,168 @@ def _set_main_color(self) -> None: self._main_color = self._determine_main_color(bool(unknown_types)) def build_summary_stats_line(self) -> Tuple[List[Tuple[str, Dict[str, bool]]], str]: - main_color, known_types = self._get_main_color() + """ + Build the parts used in the last summary stats line. + + The summary stats line is the line shown at the end, "=== 12 passed, 2 errors in Xs===". + + This function builds a list of the "parts" that make up for the text in that line, in + the example above it would be: + + [ + ("12 passed", {"green": True}), + ("2 errors", {"red": True} + ] + That last dict for each line is a "markup dictionary", used by TerminalWriter to + color output. + + The final color of the line is also determined by this function, and is the second + element of the returned tuple. + """ + if self.config.getoption("collectonly"): + return self._build_collect_only_summary_stats_line() + else: + return self._build_normal_summary_stats_line() + + def _get_reports_to_display(self, key: str) -> List[Any]: + """Get test/collection reports for the given status key, such as `passed` or `error`.""" + reports = self.stats.get(key, []) + return [x for x in reports if getattr(x, "count_towards_summary", True)] + + def _build_normal_summary_stats_line( + self, + ) -> Tuple[List[Tuple[str, Dict[str, bool]]], str]: + main_color, known_types = self._get_main_color() parts = [] + for key in known_types: - reports = self.stats.get(key, None) + reports = self._get_reports_to_display(key) if reports: - count = sum( - 1 for rep in reports if getattr(rep, "count_towards_summary", True) - ) + count = len(reports) color = _color_for_type.get(key, _color_for_type_default) markup = {color: True, "bold": color == main_color} - parts.append(("%d %s" % _make_plural(count, key), markup)) + parts.append(("%d %s" % pluralize(count, key), markup)) if not parts: parts = [("no tests ran", {_color_for_type_default: True})] return parts, main_color + def _build_collect_only_summary_stats_line( + self, + ) -> Tuple[List[Tuple[str, Dict[str, bool]]], str]: + deselected = len(self._get_reports_to_display("deselected")) + errors = len(self._get_reports_to_display("error")) -def _get_pos(config, rep): + if self._numcollected == 0: + parts = [("no tests collected", {"yellow": True})] + main_color = "yellow" + + elif deselected == 0: + main_color = "green" + collected_output = "%d %s collected" % pluralize(self._numcollected, "test") + parts = [(collected_output, {main_color: True})] + else: + all_tests_were_deselected = self._numcollected == deselected + if all_tests_were_deselected: + main_color = "yellow" + collected_output = f"no tests collected ({deselected} deselected)" + else: + main_color = "green" + selected = self._numcollected - deselected + collected_output = f"{selected}/{self._numcollected} tests collected ({deselected} deselected)" + + parts = [(collected_output, {main_color: True})] + + if errors: + main_color = _color_for_type["error"] + parts += [("%d %s" % pluralize(errors, "error"), {main_color: True})] + + return parts, main_color + + +def _get_pos(config: Config, rep: BaseReport): nodeid = config.cwd_relative_nodeid(rep.nodeid) return nodeid -def _get_line_with_reprcrash_message(config, rep, termwidth): - """Get summary line for a report, trying to add reprcrash message.""" - from wcwidth import wcswidth +def _format_trimmed(format: str, msg: str, available_width: int) -> Optional[str]: + """Format msg into format, ellipsizing it if doesn't fit in available_width. + + Returns None if even the ellipsis can't fit. + """ + # Only use the first line. + i = msg.find("\n") + if i != -1: + msg = msg[:i] + + ellipsis = "..." + format_width = wcswidth(format.format("")) + if format_width + len(ellipsis) > available_width: + return None + + if format_width + wcswidth(msg) > available_width: + available_width -= len(ellipsis) + msg = msg[:available_width] + while format_width + wcswidth(msg) > available_width: + msg = msg[:-1] + msg += ellipsis + + return format.format(msg) + +def _get_line_with_reprcrash_message( + config: Config, rep: BaseReport, termwidth: int +) -> str: + """Get summary line for a report, trying to add reprcrash message.""" verbose_word = rep._get_verbose_word(config) pos = _get_pos(config, rep) - line = "{} {}".format(verbose_word, pos) - len_line = wcswidth(line) - ellipsis, len_ellipsis = "...", 3 - if len_line > termwidth - len_ellipsis: - # No space for an additional message. - return line + line = f"{verbose_word} {pos}" + line_width = wcswidth(line) try: - msg = rep.longrepr.reprcrash.message + # Type ignored intentionally -- possible AttributeError expected. + msg = rep.longrepr.reprcrash.message # type: ignore[union-attr] except AttributeError: pass else: - # Only use the first line. - i = msg.find("\n") - if i != -1: - msg = msg[:i] - len_msg = wcswidth(msg) - - sep, len_sep = " - ", 3 - max_len_msg = termwidth - len_line - len_sep - if max_len_msg >= len_ellipsis: - if len_msg > max_len_msg: - max_len_msg -= len_ellipsis - msg = msg[:max_len_msg] - while wcswidth(msg) > max_len_msg: - msg = msg[:-1] - msg += ellipsis - line += sep + msg + available_width = termwidth - line_width + msg = _format_trimmed(" - {}", msg, available_width) + if msg is not None: + line += msg + return line -def _folded_skips(skipped): - d = {} +def _folded_skips( + startpath: Path, skipped: Sequence[CollectReport], +) -> List[Tuple[int, str, Optional[int], str]]: + d: Dict[Tuple[str, Optional[int], str], List[CollectReport]] = {} for event in skipped: - key = event.longrepr - assert len(key) == 3, (event, key) + assert event.longrepr is not None + assert isinstance(event.longrepr, tuple), (event, event.longrepr) + assert len(event.longrepr) == 3, (event, event.longrepr) + fspath, lineno, reason = event.longrepr + # For consistency, report all fspaths in relative form. + fspath = bestrelpath(startpath, Path(fspath)) keywords = getattr(event, "keywords", {}) - # folding reports with global pytestmark variable - # this is workaround, because for now we cannot identify the scope of a skip marker - # TODO: revisit after marks scope would be fixed + # Folding reports with global pytestmark variable. + # This is a workaround, because for now we cannot identify the scope of a skip marker + # TODO: Revisit after marks scope would be fixed. if ( event.when == "setup" and "skip" in keywords and "pytestmark" not in keywords ): - key = (key[0], None, key[2]) + key: Tuple[str, Optional[int], str] = (fspath, None, reason) + else: + key = (fspath, lineno, reason) d.setdefault(key, []).append(event) - values = [] + values: List[Tuple[int, str, Optional[int], str]] = [] for key, events in d.items(): - values.append((len(events),) + key) + values.append((len(events), *key)) return values @@ -1183,9 +1348,9 @@ def _folded_skips(skipped): _color_for_type_default = "yellow" -def _make_plural(count, noun): +def pluralize(count: int, noun: str) -> Tuple[int, str]: # No need to pluralize words such as `failed` or `passed`. - if noun not in ["error", "warnings"]: + if noun not in ["error", "warnings", "test"]: return count, noun # The `warnings` key is plural. To avoid API breakage, we keep it that way but @@ -1197,24 +1362,42 @@ def _make_plural(count, noun): def _plugin_nameversions(plugininfo) -> List[str]: - values = [] # type: List[str] + values: List[str] = [] for plugin, dist in plugininfo: - # gets us name and version! + # Gets us name and version! name = "{dist.project_name}-{dist.version}".format(dist=dist) - # questionable convenience, but it keeps things short + # Questionable convenience, but it keeps things short. if name.startswith("pytest-"): name = name[7:] - # we decided to print python package names - # they can have more than one plugin + # We decided to print python package names they can have more than one plugin. if name not in values: values.append(name) return values def format_session_duration(seconds: float) -> str: - """Format the given seconds in a human readable manner to show in the final summary""" + """Format the given seconds in a human readable manner to show in the final summary.""" if seconds < 60: - return "{:.2f}s".format(seconds) + return f"{seconds:.2f}s" else: dt = datetime.timedelta(seconds=int(seconds)) - return "{:.2f}s ({})".format(seconds, dt) + return f"{seconds:.2f}s ({dt})" + + +def _get_raw_skip_reason(report: TestReport) -> str: + """Get the reason string of a skip/xfail/xpass test report. + + The string is just the part given by the user. + """ + if hasattr(report, "wasxfail"): + reason = cast(str, report.wasxfail) + if reason.startswith("reason: "): + reason = reason[len("reason: ") :] + return reason + else: + assert report.skipped + assert isinstance(report.longrepr, tuple) + _, _, reason = report.longrepr + if reason.startswith("Skipped: "): + reason = reason[len("Skipped: ") :] + return reason diff --git a/src/_pytest/threadexception.py b/src/_pytest/threadexception.py new file mode 100644 index 00000000000..1c1f62fdb73 --- /dev/null +++ b/src/_pytest/threadexception.py @@ -0,0 +1,90 @@ +import threading +import traceback +import warnings +from types import TracebackType +from typing import Any +from typing import Callable +from typing import Generator +from typing import Optional +from typing import Type + +import pytest + + +# Copied from cpython/Lib/test/support/threading_helper.py, with modifications. +class catch_threading_exception: + """Context manager catching threading.Thread exception using + threading.excepthook. + + Storing exc_value using a custom hook can create a reference cycle. The + reference cycle is broken explicitly when the context manager exits. + + Storing thread using a custom hook can resurrect it if it is set to an + object which is being finalized. Exiting the context manager clears the + stored object. + + Usage: + with threading_helper.catch_threading_exception() as cm: + # code spawning a thread which raises an exception + ... + # check the thread exception: use cm.args + ... + # cm.args attribute no longer exists at this point + # (to break a reference cycle) + """ + + def __init__(self) -> None: + # See https://github.com/python/typeshed/issues/4767 regarding the underscore. + self.args: Optional["threading._ExceptHookArgs"] = None + self._old_hook: Optional[Callable[["threading._ExceptHookArgs"], Any]] = None + + def _hook(self, args: "threading._ExceptHookArgs") -> None: + self.args = args + + def __enter__(self) -> "catch_threading_exception": + self._old_hook = threading.excepthook + threading.excepthook = self._hook + return self + + def __exit__( + self, + exc_type: Optional[Type[BaseException]], + exc_val: Optional[BaseException], + exc_tb: Optional[TracebackType], + ) -> None: + assert self._old_hook is not None + threading.excepthook = self._old_hook + self._old_hook = None + del self.args + + +def thread_exception_runtest_hook() -> Generator[None, None, None]: + with catch_threading_exception() as cm: + yield + if cm.args: + if cm.args.thread is not None: + thread_name = cm.args.thread.name + else: + thread_name = "" + msg = f"Exception in thread {thread_name}\n\n" + msg += "".join( + traceback.format_exception( + cm.args.exc_type, cm.args.exc_value, cm.args.exc_traceback, + ) + ) + warnings.warn(pytest.PytestUnhandledThreadExceptionWarning(msg)) + + +@pytest.hookimpl(hookwrapper=True, trylast=True) +def pytest_runtest_setup() -> Generator[None, None, None]: + yield from thread_exception_runtest_hook() + + +@pytest.hookimpl(hookwrapper=True, tryfirst=True) +def pytest_runtest_call() -> Generator[None, None, None]: + yield from thread_exception_runtest_hook() + + +@pytest.hookimpl(hookwrapper=True, tryfirst=True) +def pytest_runtest_teardown() -> Generator[None, None, None]: + yield from thread_exception_runtest_hook() diff --git a/src/_pytest/timing.py b/src/_pytest/timing.py new file mode 100644 index 00000000000..925163a5858 --- /dev/null +++ b/src/_pytest/timing.py @@ -0,0 +1,12 @@ +"""Indirection for time functions. + +We intentionally grab some "time" functions internally to avoid tests mocking "time" to affect +pytest runtime information (issue #185). + +Fixture "mock_timing" also interacts with this module for pytest's own tests. +""" +from time import perf_counter +from time import sleep +from time import time + +__all__ = ["perf_counter", "sleep", "time"] diff --git a/src/_pytest/tmpdir.py b/src/_pytest/tmpdir.py index c1e12da4f0d..08c445e2bf8 100644 --- a/src/_pytest/tmpdir.py +++ b/src/_pytest/tmpdir.py @@ -1,70 +1,90 @@ -""" support for providing temporary directories to test functions. """ +"""Support for providing temporary directories to test functions.""" import os import re import tempfile +from pathlib import Path from typing import Optional import attr import py -import pytest from .pathlib import ensure_reset_dir from .pathlib import LOCK_TIMEOUT from .pathlib import make_numbered_dir from .pathlib import make_numbered_dir_with_cleanup -from .pathlib import Path +from _pytest.compat import final +from _pytest.config import Config +from _pytest.deprecated import check_ispytest +from _pytest.fixtures import fixture from _pytest.fixtures import FixtureRequest +from _pytest.monkeypatch import MonkeyPatch -@attr.s +@final +@attr.s(init=False) 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=Path, - # using os.path.abspath() to get absolute path instead of resolve() as it - # does not work the same in all platforms (see #4427) - # Path.absolute() exists, but it is not public (see https://bugs.python.org/issue25012) - # Ignore type because of https://github.com/python/mypy/issues/6172. - converter=attr.converters.optional( - lambda p: Path(os.path.abspath(str(p))) # type: ignore - ), - ) + 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], default=None) + _basetemp = attr.ib(type=Optional[Path]) + + def __init__( + self, + given_basetemp: Optional[Path], + trace, + basetemp: Optional[Path] = None, + *, + _ispytest: bool = False, + ) -> None: + check_ispytest(_ispytest) + if given_basetemp is None: + self._given_basetemp = None + else: + # Use os.path.abspath() to get absolute path instead of resolve() as it + # does not work the same in all platforms (see #4427). + # 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._basetemp = basetemp @classmethod - def from_config(cls, config) -> "TempPathFactory": - """ - :param config: a pytest configuration + def from_config( + cls, config: Config, *, _ispytest: bool = False, + ) -> "TempPathFactory": + """Create a factory according to pytest configuration. + + :meta private: """ + check_ispytest(_ispytest) return cls( - given_basetemp=config.option.basetemp, trace=config.trace.get("tmpdir") + given_basetemp=config.option.basetemp, + trace=config.trace.get("tmpdir"), + _ispytest=True, ) - def _ensure_relative_to_basetemp(self, basename: str): + def _ensure_relative_to_basetemp(self, basename: str) -> str: basename = os.path.normpath(basename) if (self.getbasetemp() / basename).resolve().parent != self.getbasetemp(): - raise ValueError( - "{} is not a normalized and relative path".format(basename) - ) + raise ValueError(f"{basename} is not a normalized and relative path") return basename def mktemp(self, basename: str, numbered: bool = True) -> Path: - """Creates a new temporary directory managed by the factory. + """Create a new temporary directory managed by the factory. :param basename: Directory base name, must be a relative path. :param numbered: - If True, ensure the directory is unique by adding a number - prefix greater than any existing one: ``basename="foo"`` and ``numbered=True`` + If ``True``, ensure the directory is unique by adding a numbered + suffix greater than any existing one: ``basename="foo-"`` and ``numbered=True`` means that this function will create directories named ``"foo-0"``, ``"foo-1"``, ``"foo-2"`` and so on. - :return: + :returns: The path to the new directory. """ basename = self._ensure_relative_to_basetemp(basename) @@ -77,7 +97,7 @@ def mktemp(self, basename: str, numbered: bool = True) -> Path: return p def getbasetemp(self) -> Path: - """ return base temporary directory. """ + """Return base temporary directory.""" if self._basetemp is not None: return self._basetemp @@ -91,7 +111,7 @@ def getbasetemp(self) -> Path: user = get_user() or "unknown" # use a sub-directory in the temproot to speed-up # make_numbered_dir() call - rootdir = temproot.joinpath("pytest-of-{}".format(user)) + rootdir = temproot.joinpath(f"pytest-of-{user}") rootdir.mkdir(exist_ok=True) basetemp = make_numbered_dir_with_cleanup( prefix="pytest-", root=rootdir, keep=3, lock_timeout=LOCK_TIMEOUT @@ -102,30 +122,32 @@ def getbasetemp(self) -> Path: return t -@attr.s +@final +@attr.s(init=False) class TempdirFactory: - """ - backward comptibility wrapper that implements - :class:``py.path.local`` for :class:``TempPathFactory`` - """ + """Backward comptibility wrapper that implements :class:``py.path.local`` + for :class:``TempPathFactory``.""" _tmppath_factory = attr.ib(type=TempPathFactory) + def __init__( + self, tmppath_factory: TempPathFactory, *, _ispytest: bool = False + ) -> None: + check_ispytest(_ispytest) + self._tmppath_factory = tmppath_factory + def mktemp(self, basename: str, numbered: bool = True) -> py.path.local: - """ - Same as :meth:`TempPathFactory.mkdir`, but returns a ``py.path.local`` object. - """ + """Same as :meth:`TempPathFactory.mktemp`, but returns a ``py.path.local`` object.""" return py.path.local(self._tmppath_factory.mktemp(basename, numbered).resolve()) - def getbasetemp(self): - """backward compat wrapper for ``_tmppath_factory.getbasetemp``""" + def getbasetemp(self) -> py.path.local: + """Backward compat wrapper for ``_tmppath_factory.getbasetemp``.""" return py.path.local(self._tmppath_factory.getbasetemp().resolve()) def get_user() -> Optional[str]: """Return the current user name, or None if getuser() does not work - in the current environment (see #1010). - """ + in the current environment (see #1010).""" import getpass try: @@ -134,18 +156,33 @@ def get_user() -> Optional[str]: return None -@pytest.fixture(scope="session") -def tmpdir_factory(tmp_path_factory) -> TempdirFactory: - """Return a :class:`_pytest.tmpdir.TempdirFactory` instance for the test session. +def pytest_configure(config: Config) -> None: + """Create a TempdirFactory and attach it to the config object. + + This is to comply with existing plugins which expect the handler to be + available at pytest_configure time, but ideally should be moved entirely + to the tmpdir_factory session fixture. """ - return TempdirFactory(tmp_path_factory) + mp = MonkeyPatch() + tmppath_handler = TempPathFactory.from_config(config, _ispytest=True) + t = TempdirFactory(tmppath_handler, _ispytest=True) + config._cleanup.append(mp.undo) + mp.setattr(config, "_tmp_path_factory", tmppath_handler, raising=False) + mp.setattr(config, "_tmpdirhandler", t, raising=False) + +@fixture(scope="session") +def tmpdir_factory(request: FixtureRequest) -> TempdirFactory: + """Return a :class:`_pytest.tmpdir.TempdirFactory` instance for the test session.""" + # Set dynamically by pytest_configure() above. + return request.config._tmpdirhandler # type: ignore -@pytest.fixture(scope="session") + +@fixture(scope="session") def tmp_path_factory(request: FixtureRequest) -> TempPathFactory: - """Return a :class:`_pytest.tmpdir.TempPathFactory` instance for the test session. - """ - return TempPathFactory.from_config(request.config) + """Return a :class:`_pytest.tmpdir.TempPathFactory` instance for the test session.""" + # Set dynamically by pytest_configure() above. + return request.config._tmp_path_factory # type: ignore def _mk_tmp(request: FixtureRequest, factory: TempPathFactory) -> Path: @@ -156,30 +193,36 @@ def _mk_tmp(request: FixtureRequest, factory: TempPathFactory) -> Path: return factory.mktemp(name, numbered=True) -@pytest.fixture -def tmpdir(tmp_path): - """Return a temporary directory path object - which is unique to each test function invocation, - created as a sub directory of the base temporary - directory. The returned object is a `py.path.local`_ - path object. +@fixture +def tmpdir(tmp_path: Path) -> py.path.local: + """Return a temporary directory path object which is unique to each test + function invocation, created as a sub directory of the base temporary + directory. + + By default, a new base temporary directory is created each test session, + and old bases are removed after 3 sessions, to aid in debugging. If + ``--basetemp`` is used then it is cleared each session. See :ref:`base + temporary directory`. + + The returned object is a `py.path.local`_ path object. .. _`py.path.local`: https://py.readthedocs.io/en/latest/path.html """ return py.path.local(tmp_path) -@pytest.fixture +@fixture def tmp_path(request: FixtureRequest, tmp_path_factory: TempPathFactory) -> Path: - """Return a temporary directory path object - which is unique to each test function invocation, - created as a sub directory of the base temporary - directory. The returned object is a :class:`pathlib.Path` - object. + """Return a temporary directory path object which is unique to each test + function invocation, created as a sub directory of the base temporary + directory. - .. note:: + By default, a new base temporary directory is created each test session, + and old bases are removed after 3 sessions, to aid in debugging. If + ``--basetemp`` is used then it is cleared each session. See :ref:`base + temporary directory`. - in python < 3.6 this is a pathlib2.Path + The returned object is a :class:`pathlib.Path` object. """ return _mk_tmp(request, tmp_path_factory) diff --git a/src/_pytest/unittest.py b/src/_pytest/unittest.py index 2047876e5f1..55f15efe4b7 100644 --- a/src/_pytest/unittest.py +++ b/src/_pytest/unittest.py @@ -1,47 +1,77 @@ -""" discovery and running of std-library "unittest" style tests. """ -import functools +"""Discover and run std-library "unittest" style tests.""" import sys import traceback +import types +from typing import Any +from typing import Callable +from typing import Generator +from typing import Iterable +from typing import List +from typing import Optional +from typing import Tuple +from typing import Type +from typing import TYPE_CHECKING +from typing import Union import _pytest._code import pytest from _pytest.compat import getimfunc +from _pytest.compat import is_async_function from _pytest.config import hookimpl +from _pytest.fixtures import FixtureRequest +from _pytest.nodes import Collector +from _pytest.nodes import Item from _pytest.outcomes import exit from _pytest.outcomes import fail from _pytest.outcomes import skip from _pytest.outcomes import xfail from _pytest.python import Class from _pytest.python import Function +from _pytest.python import PyCollector from _pytest.runner import CallInfo from _pytest.skipping import skipped_by_mark_key from _pytest.skipping import unexpectedsuccess_key +if TYPE_CHECKING: + import unittest -def pytest_pycollect_makeitem(collector, name, obj): - # has unittest been imported and is obj a subclass of its TestCase? + from _pytest.fixtures import _Scope + + _SysExcInfoType = Union[ + Tuple[Type[BaseException], BaseException, types.TracebackType], + Tuple[None, None, None], + ] + + +def pytest_pycollect_makeitem( + collector: PyCollector, name: str, obj: object +) -> Optional["UnitTestCase"]: + # Has unittest been imported and is obj a subclass of its TestCase? try: - if not issubclass(obj, sys.modules["unittest"].TestCase): - return + ut = sys.modules["unittest"] + # Type ignored because `ut` is an opaque module. + if not issubclass(obj, ut.TestCase): # type: ignore + return None except Exception: - return - # yes, so let's collect it - return UnitTestCase.from_parent(collector, name=name, obj=obj) + return None + # Yes, so let's collect it. + item: UnitTestCase = UnitTestCase.from_parent(collector, name=name, obj=obj) + return item class UnitTestCase(Class): - # marker for fixturemanger.getfixtureinfo() - # to declare that our children do not support funcargs + # Marker for fixturemanger.getfixtureinfo() + # to declare that our children do not support funcargs. nofuncargs = True - def collect(self): + def collect(self) -> Iterable[Union[Item, Collector]]: from unittest import TestLoader cls = self.obj if not getattr(cls, "__test__", True): return - skipped = getattr(cls, "__unittest_skip__", False) + skipped = _is_skipped(cls) if not skipped: self._inject_setup_teardown_fixtures(cls) self._inject_setup_class_fixture() @@ -61,80 +91,128 @@ def collect(self): runtest = getattr(self.obj, "runTest", None) if runtest is not None: ut = sys.modules.get("twisted.trial.unittest", None) - if ut is None or runtest != ut.TestCase.runTest: - # TODO: callobj consistency + # Type ignored because `ut` is an opaque module. + if ut is None or runtest != ut.TestCase.runTest: # type: ignore yield TestCaseFunction.from_parent(self, name="runTest") - def _inject_setup_teardown_fixtures(self, cls): + def _inject_setup_teardown_fixtures(self, cls: type) -> None: """Injects a hidden auto-use fixture to invoke setUpClass/setup_method and corresponding - teardown functions (#517)""" + teardown functions (#517).""" class_fixture = _make_xunit_fixture( - cls, "setUpClass", "tearDownClass", scope="class", pass_self=False + cls, + "setUpClass", + "tearDownClass", + "doClassCleanups", + scope="class", + pass_self=False, ) if class_fixture: - cls.__pytest_class_setup = class_fixture + cls.__pytest_class_setup = class_fixture # type: ignore[attr-defined] method_fixture = _make_xunit_fixture( - cls, "setup_method", "teardown_method", scope="function", pass_self=True + cls, + "setup_method", + "teardown_method", + None, + scope="function", + pass_self=True, ) if method_fixture: - cls.__pytest_method_setup = method_fixture + cls.__pytest_method_setup = method_fixture # type: ignore[attr-defined] -def _make_xunit_fixture(obj, setup_name, teardown_name, scope, pass_self): +def _make_xunit_fixture( + obj: type, + setup_name: str, + teardown_name: str, + cleanup_name: Optional[str], + scope: "_Scope", + pass_self: bool, +): setup = getattr(obj, setup_name, None) teardown = getattr(obj, teardown_name, None) if setup is None and teardown is None: return None - @pytest.fixture(scope=scope, autouse=True) - def fixture(self, request): - if getattr(self, "__unittest_skip__", None): + if cleanup_name: + cleanup = getattr(obj, cleanup_name, lambda *args: None) + else: + + def cleanup(*args): + pass + + @pytest.fixture( + scope=scope, + autouse=True, + # Use a unique name to speed up lookup. + name=f"unittest_{setup_name}_fixture_{obj.__qualname__}", + ) + def fixture(self, request: FixtureRequest) -> Generator[None, None, None]: + if _is_skipped(self): reason = self.__unittest_skip_why__ pytest.skip(reason) if setup is not None: - if pass_self: - setup(self, request.function) - else: - setup() + try: + if pass_self: + setup(self, request.function) + else: + setup() + # unittest does not call the cleanup function for every BaseException, so we + # follow this here. + except Exception: + if pass_self: + cleanup(self) + else: + cleanup() + + raise yield - if teardown is not None: + try: + if teardown is not None: + if pass_self: + teardown(self, request.function) + else: + teardown() + finally: if pass_self: - teardown(self, request.function) + cleanup(self) else: - teardown() + cleanup() return fixture class TestCaseFunction(Function): nofuncargs = True - _excinfo = None - _testcase = None - - def setup(self): - self._needs_explicit_tearDown = False - self._testcase = self.parent.obj(self.name) + _excinfo: Optional[List[_pytest._code.ExceptionInfo[BaseException]]] = None + _testcase: Optional["unittest.TestCase"] = None + + def setup(self) -> None: + # A bound method to be called during teardown() if set (see 'runtest()'). + self._explicit_tearDown: Optional[Callable[[], None]] = None + assert self.parent is not None + self._testcase = self.parent.obj(self.name) # type: ignore[attr-defined] self._obj = getattr(self._testcase, self.name) if hasattr(self, "_request"): self._request._fillfixtures() - def teardown(self): - if self._needs_explicit_tearDown: - self._testcase.tearDown() + def teardown(self) -> None: + if self._explicit_tearDown is not None: + self._explicit_tearDown() + self._explicit_tearDown = None self._testcase = None self._obj = None - def startTest(self, testcase): + def startTest(self, testcase: "unittest.TestCase") -> None: pass - def _addexcinfo(self, rawexcinfo): - # unwrap potential exception info (see twisted trial support below) + def _addexcinfo(self, rawexcinfo: "_SysExcInfoType") -> None: + # Unwrap potential exception info (see twisted trial support below). rawexcinfo = getattr(rawexcinfo, "_rawexcinfo", rawexcinfo) try: - excinfo = _pytest._code.ExceptionInfo(rawexcinfo) - # invoke the attributes to trigger storing the traceback - # trial causes some issue there + excinfo = _pytest._code.ExceptionInfo(rawexcinfo) # type: ignore[arg-type] + # Invoke the attributes to trigger storing the traceback + # trial causes some issue there. excinfo.value excinfo.traceback except TypeError: @@ -149,7 +227,7 @@ def _addexcinfo(self, rawexcinfo): fail("".join(values), pytrace=False) except (fail.Exception, KeyboardInterrupt): raise - except: # noqa + except BaseException: fail( "ERROR: Unknown Incompatible Exception " "representation:\n%r" % (rawexcinfo,), @@ -161,7 +239,9 @@ def _addexcinfo(self, rawexcinfo): excinfo = _pytest._code.ExceptionInfo.from_current() self.__dict__.setdefault("_excinfo", []).append(excinfo) - def addError(self, testcase, rawexcinfo): + def addError( + self, testcase: "unittest.TestCase", rawexcinfo: "_SysExcInfoType" + ) -> None: try: if isinstance(rawexcinfo[1], exit.Exception): exit(rawexcinfo[1].msg) @@ -169,73 +249,82 @@ def addError(self, testcase, rawexcinfo): pass self._addexcinfo(rawexcinfo) - def addFailure(self, testcase, rawexcinfo): + def addFailure( + self, testcase: "unittest.TestCase", rawexcinfo: "_SysExcInfoType" + ) -> None: self._addexcinfo(rawexcinfo) - def addSkip(self, testcase, reason): + def addSkip(self, testcase: "unittest.TestCase", reason: str) -> None: try: skip(reason) except skip.Exception: self._store[skipped_by_mark_key] = True self._addexcinfo(sys.exc_info()) - def addExpectedFailure(self, testcase, rawexcinfo, reason=""): + def addExpectedFailure( + self, + testcase: "unittest.TestCase", + rawexcinfo: "_SysExcInfoType", + reason: str = "", + ) -> None: try: xfail(str(reason)) except xfail.Exception: self._addexcinfo(sys.exc_info()) - def addUnexpectedSuccess(self, testcase, reason=""): + def addUnexpectedSuccess( + self, testcase: "unittest.TestCase", reason: str = "" + ) -> None: self._store[unexpectedsuccess_key] = reason - def addSuccess(self, testcase): + def addSuccess(self, testcase: "unittest.TestCase") -> None: pass - def stopTest(self, testcase): + def stopTest(self, testcase: "unittest.TestCase") -> None: pass def _expecting_failure(self, test_method) -> bool: """Return True if the given unittest method (or the entire class) is marked - with @expectedFailure""" + with @expectedFailure.""" expecting_failure_method = getattr( test_method, "__unittest_expecting_failure__", False ) expecting_failure_class = getattr(self, "__unittest_expecting_failure__", False) return bool(expecting_failure_class or expecting_failure_method) - def runtest(self): - # TODO: move testcase reporter into separate class, this shouldnt be on item - import unittest - - testMethod = getattr(self._testcase, self._testcase._testMethodName) - - class _GetOutOf_testPartExecutor(KeyboardInterrupt): - """Helper exception to get out of unittests's testPartExecutor (see TestCase.run).""" - - @functools.wraps(testMethod) - def wrapped_testMethod(*args, **kwargs): - """Wrap the original method to call into pytest's machinery, so other pytest - features can have a chance to kick in (notably --pdb)""" + def runtest(self) -> None: + from _pytest.debugging import maybe_wrap_pytest_function_for_tracing + + assert self._testcase is not None + + maybe_wrap_pytest_function_for_tracing(self) + + # Let the unittest framework handle async functions. + if is_async_function(self.obj): + # Type ignored because self acts as the TestResult, but is not actually one. + self._testcase(result=self) # type: ignore[arg-type] + else: + # When --pdb is given, we want to postpone calling tearDown() otherwise + # when entering the pdb prompt, tearDown() would have probably cleaned up + # instance variables, which makes it difficult to debug. + # Arguably we could always postpone tearDown(), but this changes the moment where the + # TestCase instance interacts with the results object, so better to only do it + # when absolutely needed. + if self.config.getoption("usepdb") and not _is_skipped(self.obj): + self._explicit_tearDown = self._testcase.tearDown + setattr(self._testcase, "tearDown", lambda *args: None) + + # We need to update the actual bound method with self.obj, because + # wrap_pytest_function_for_tracing replaces self.obj by a wrapper. + setattr(self._testcase, self.name, self.obj) try: - self.ihook.pytest_pyfunc_call(pyfuncitem=self) - except unittest.SkipTest: - raise - except Exception as exc: - expecting_failure = self._expecting_failure(testMethod) - if expecting_failure: - raise - self._needs_explicit_tearDown = True - raise _GetOutOf_testPartExecutor(exc) + self._testcase(result=self) # type: ignore[arg-type] + finally: + delattr(self._testcase, self.name) - setattr(self._testcase, self._testcase._testMethodName, wrapped_testMethod) - try: - self._testcase(result=self) - except _GetOutOf_testPartExecutor as exc: - raise exc.args[0] from exc.args[0] - finally: - delattr(self._testcase, self._testcase._testMethodName) - - def _prunetraceback(self, excinfo): + def _prunetraceback( + self, excinfo: _pytest._code.ExceptionInfo[BaseException] + ) -> None: Function._prunetraceback(self, excinfo) traceback = excinfo.traceback.filter( lambda x: not x.frame.f_globals.get("__unittest") @@ -245,7 +334,7 @@ def _prunetraceback(self, excinfo): @hookimpl(tryfirst=True) -def pytest_runtest_makereport(item, call): +def pytest_runtest_makereport(item: Item, call: CallInfo[None]) -> None: if isinstance(item, TestCaseFunction): if item._excinfo: call.excinfo = item._excinfo.pop(0) @@ -255,21 +344,26 @@ def pytest_runtest_makereport(item, call): pass unittest = sys.modules.get("unittest") - if unittest and call.excinfo and call.excinfo.errisinstance(unittest.SkipTest): - # let's substitute the excinfo with a pytest.skip one - call2 = CallInfo.from_call( - lambda: pytest.skip(str(call.excinfo.value)), call.when + if ( + unittest + and call.excinfo + and isinstance(call.excinfo.value, unittest.SkipTest) # type: ignore[attr-defined] + ): + excinfo = call.excinfo + # Let's substitute the excinfo with a pytest.skip one. + call2 = CallInfo[None].from_call( + lambda: pytest.skip(str(excinfo.value)), call.when ) call.excinfo = call2.excinfo -# twisted trial support +# Twisted trial support. @hookimpl(hookwrapper=True) -def pytest_runtest_protocol(item): +def pytest_runtest_protocol(item: Item) -> Generator[None, None, None]: if isinstance(item, TestCaseFunction) and "twisted.trial.unittest" in sys.modules: - ut = sys.modules["twisted.python.failure"] + ut: Any = sys.modules["twisted.python.failure"] Failure__init__ = ut.Failure.__init__ check_testcase_implements_trial_reporter() @@ -296,7 +390,7 @@ def excstore( yield -def check_testcase_implements_trial_reporter(done=[]): +def check_testcase_implements_trial_reporter(done: List[int] = []) -> None: if done: return from zope.interface import classImplements @@ -304,3 +398,8 @@ def check_testcase_implements_trial_reporter(done=[]): classImplements(TestCaseFunction, IReporter) done.append(1) + + +def _is_skipped(obj) -> bool: + """Return True if the given object has been marked with @unittest.skip.""" + return bool(getattr(obj, "__unittest_skip__", False)) diff --git a/src/_pytest/unraisableexception.py b/src/_pytest/unraisableexception.py new file mode 100644 index 00000000000..fcb5d8237c1 --- /dev/null +++ b/src/_pytest/unraisableexception.py @@ -0,0 +1,93 @@ +import sys +import traceback +import warnings +from types import TracebackType +from typing import Any +from typing import Callable +from typing import Generator +from typing import Optional +from typing import Type + +import pytest + + +# Copied from cpython/Lib/test/support/__init__.py, with modifications. +class catch_unraisable_exception: + """Context manager catching unraisable exception using sys.unraisablehook. + + Storing the exception value (cm.unraisable.exc_value) creates a reference + cycle. The reference cycle is broken explicitly when the context manager + exits. + + Storing the object (cm.unraisable.object) can resurrect it if it is set to + an object which is being finalized. Exiting the context manager clears the + stored object. + + Usage: + with catch_unraisable_exception() as cm: + # code creating an "unraisable exception" + ... + # check the unraisable exception: use cm.unraisable + ... + # cm.unraisable attribute no longer exists at this point + # (to break a reference cycle) + """ + + def __init__(self) -> None: + self.unraisable: Optional["sys.UnraisableHookArgs"] = None + self._old_hook: Optional[Callable[["sys.UnraisableHookArgs"], Any]] = None + + def _hook(self, unraisable: "sys.UnraisableHookArgs") -> None: + # Storing unraisable.object can resurrect an object which is being + # finalized. Storing unraisable.exc_value creates a reference cycle. + self.unraisable = unraisable + + def __enter__(self) -> "catch_unraisable_exception": + self._old_hook = sys.unraisablehook + sys.unraisablehook = self._hook + return self + + def __exit__( + self, + exc_type: Optional[Type[BaseException]], + exc_val: Optional[BaseException], + exc_tb: Optional[TracebackType], + ) -> None: + assert self._old_hook is not None + sys.unraisablehook = self._old_hook + self._old_hook = None + del self.unraisable + + +def unraisable_exception_runtest_hook() -> Generator[None, None, None]: + with catch_unraisable_exception() as cm: + yield + if cm.unraisable: + if cm.unraisable.err_msg is not None: + err_msg = cm.unraisable.err_msg + else: + err_msg = "Exception ignored in" + msg = f"{err_msg}: {cm.unraisable.object!r}\n\n" + msg += "".join( + traceback.format_exception( + cm.unraisable.exc_type, + cm.unraisable.exc_value, + cm.unraisable.exc_traceback, + ) + ) + warnings.warn(pytest.PytestUnraisableExceptionWarning(msg)) + + +@pytest.hookimpl(hookwrapper=True, tryfirst=True) +def pytest_runtest_setup() -> Generator[None, None, None]: + yield from unraisable_exception_runtest_hook() + + +@pytest.hookimpl(hookwrapper=True, tryfirst=True) +def pytest_runtest_call() -> Generator[None, None, None]: + yield from unraisable_exception_runtest_hook() + + +@pytest.hookimpl(hookwrapper=True, tryfirst=True) +def pytest_runtest_teardown() -> Generator[None, None, None]: + yield from unraisable_exception_runtest_hook() diff --git a/src/_pytest/warning_types.py b/src/_pytest/warning_types.py index 2e03c578c02..2eadd9fe4db 100644 --- a/src/_pytest/warning_types.py +++ b/src/_pytest/warning_types.py @@ -1,81 +1,60 @@ from typing import Any from typing import Generic +from typing import Type from typing import TypeVar import attr -from _pytest.compat import TYPE_CHECKING - -if TYPE_CHECKING: - from typing import Type # noqa: F401 (used in type string) +from _pytest.compat import final class PytestWarning(UserWarning): - """ - Bases: :class:`UserWarning`. - - Base class for all warnings emitted by pytest. - """ + """Base class for all warnings emitted by pytest.""" __module__ = "pytest" +@final class PytestAssertRewriteWarning(PytestWarning): - """ - Bases: :class:`PytestWarning`. - - Warning emitted by the pytest assert rewrite module. - """ + """Warning emitted by the pytest assert rewrite module.""" __module__ = "pytest" +@final class PytestCacheWarning(PytestWarning): - """ - Bases: :class:`PytestWarning`. - - Warning emitted by the cache plugin in various situations. - """ + """Warning emitted by the cache plugin in various situations.""" __module__ = "pytest" +@final class PytestConfigWarning(PytestWarning): - """ - Bases: :class:`PytestWarning`. - - Warning emitted for configuration issues. - """ + """Warning emitted for configuration issues.""" __module__ = "pytest" +@final class PytestCollectionWarning(PytestWarning): - """ - Bases: :class:`PytestWarning`. - - Warning emitted when pytest is not able to collect a file or symbol in a module. - """ + """Warning emitted when pytest is not able to collect a file or symbol in a module.""" __module__ = "pytest" +@final class PytestDeprecationWarning(PytestWarning, DeprecationWarning): - """ - Bases: :class:`pytest.PytestWarning`, :class:`DeprecationWarning`. - - Warning class for features that will be removed in a future version. - """ + """Warning class for features that will be removed in a future version.""" __module__ = "pytest" +@final class PytestExperimentalApiWarning(PytestWarning, FutureWarning): - """ - Bases: :class:`pytest.PytestWarning`, :class:`FutureWarning`. + """Warning category used to denote experiments in pytest. - Warning category used to denote experiments in pytest. Use sparingly as the API might change or even be - removed completely in future version + Use sparingly as the API might change or even be removed completely in a + future version. """ __module__ = "pytest" @@ -89,24 +68,45 @@ def simple(cls, apiname: str) -> "PytestExperimentalApiWarning": ) +@final class PytestUnhandledCoroutineWarning(PytestWarning): - """ - Bases: :class:`PytestWarning`. + """Warning emitted for an unhandled coroutine. - Warning emitted when pytest encounters a test function which is a coroutine, - but it was not handled by any async-aware plugin. Coroutine test functions - are not natively supported. + A coroutine was encountered when collecting test functions, but was not + handled by any async-aware plugin. + Coroutine test functions are not natively supported. """ __module__ = "pytest" +@final class PytestUnknownMarkWarning(PytestWarning): + """Warning emitted on use of unknown markers. + + See :ref:`mark` for details. """ - Bases: :class:`PytestWarning`. - Warning emitted on use of unknown markers. - See https://docs.pytest.org/en/latest/mark.html for details. + __module__ = "pytest" + + +@final +class PytestUnraisableExceptionWarning(PytestWarning): + """An unraisable exception was reported. + + Unraisable exceptions are exceptions raised in :meth:`__del__ ` + implementations and similar situations when the exception cannot be raised + as normal. + """ + + __module__ = "pytest" + + +@final +class PytestUnhandledThreadExceptionWarning(PytestWarning): + """An unhandled exception occurred in a :class:`~threading.Thread`. + + Such exceptions don't propagate normally. """ __module__ = "pytest" @@ -115,19 +115,18 @@ class PytestUnknownMarkWarning(PytestWarning): _W = TypeVar("_W", bound=PytestWarning) +@final @attr.s class UnformattedWarning(Generic[_W]): - """Used to hold warnings that need to format their message at runtime, as opposed to a direct message. + """A warning meant to be formatted during runtime. - Using this class avoids to keep all the warning types and messages in this module, avoiding misuse. + This is used to hold warnings that need to format their message at runtime, + as opposed to a direct message. """ - category = attr.ib(type="Type[_W]") + category = attr.ib(type=Type["_W"]) template = attr.ib(type=str) def format(self, **kwargs: Any) -> _W: - """Returns an instance of the warning category, formatted with given kwargs""" + """Return an instance of the warning category, formatted with given kwargs.""" return self.category(self.template.format(**kwargs)) - - -PYTESTER_COPY_EXAMPLE = PytestExperimentalApiWarning.simple("testdir.copy_example") diff --git a/src/_pytest/warnings.py b/src/_pytest/warnings.py index 2a4d189d573..35eed96df58 100644 --- a/src/_pytest/warnings.py +++ b/src/_pytest/warnings.py @@ -2,106 +2,88 @@ import warnings from contextlib import contextmanager from typing import Generator +from typing import Optional +from typing import TYPE_CHECKING import pytest +from _pytest.config import apply_warning_filters +from _pytest.config import Config +from _pytest.config import parse_warning_filter from _pytest.main import Session +from _pytest.nodes import Item +from _pytest.terminal import TerminalReporter +if TYPE_CHECKING: + from typing_extensions import Literal -def _setoption(wmod, arg): - """ - Copy of the warning._setoption function but does not escape arguments. - """ - parts = arg.split(":") - if len(parts) > 5: - raise wmod._OptionError("too many fields (max 5): {!r}".format(arg)) - while len(parts) < 5: - parts.append("") - action, message, category, module, lineno = [s.strip() for s in parts] - action = wmod._getaction(action) - category = wmod._getcategory(category) - if lineno: - try: - lineno = int(lineno) - if lineno < 0: - raise ValueError - except (ValueError, OverflowError): - raise wmod._OptionError("invalid lineno {!r}".format(lineno)) - else: - lineno = 0 - wmod.filterwarnings(action, message, category, module, lineno) - - -def pytest_addoption(parser): - group = parser.getgroup("pytest-warnings") - group.addoption( - "-W", - "--pythonwarnings", - action="append", - help="set which warnings to report, see -W option of python itself.", - ) - parser.addini( - "filterwarnings", - type="linelist", - help="Each line specifies a pattern for " - "warnings.filterwarnings. " - "Processed after -W/--pythonwarnings.", - ) - -def pytest_configure(config): +def pytest_configure(config: Config) -> None: config.addinivalue_line( "markers", "filterwarnings(warning): add a warning filter to the given test. " - "see https://docs.pytest.org/en/latest/warnings.html#pytest-mark-filterwarnings ", + "see https://docs.pytest.org/en/stable/warnings.html#pytest-mark-filterwarnings ", ) @contextmanager -def catch_warnings_for_item(config, ihook, when, item): - """ - Context manager that catches warnings generated in the contained execution block. +def catch_warnings_for_item( + config: Config, + ihook, + when: "Literal['config', 'collect', 'runtest']", + item: Optional[Item], +) -> Generator[None, None, None]: + """Context manager that catches warnings generated in the contained execution block. ``item`` can be None if we are not in the context of an item execution. - Each warning captured triggers the ``pytest_warning_captured`` hook. + Each warning captured triggers the ``pytest_warning_recorded`` hook. """ - cmdline_filters = config.getoption("pythonwarnings") or [] - inifilters = config.getini("filterwarnings") + config_filters = config.getini("filterwarnings") + cmdline_filters = config.known_args_namespace.pythonwarnings or [] with warnings.catch_warnings(record=True) as log: # mypy can't infer that record=True means log is not None; help it. assert log is not None if not sys.warnoptions: - # if user is not explicitly configuring warning filters, show deprecation warnings by default (#2908) + # If user is not explicitly configuring warning filters, show deprecation warnings by default (#2908). warnings.filterwarnings("always", category=DeprecationWarning) warnings.filterwarnings("always", category=PendingDeprecationWarning) - # filters should have this precedence: mark, cmdline options, ini - # filters should be applied in the inverse order of precedence - for arg in inifilters: - _setoption(warnings, arg) - - for arg in cmdline_filters: - warnings._setoption(arg) + apply_warning_filters(config_filters, cmdline_filters) + # apply filters from "filterwarnings" marks + nodeid = "" if item is None else item.nodeid if item is not None: for mark in item.iter_markers(name="filterwarnings"): for arg in mark.args: - _setoption(warnings, arg) + warnings.filterwarnings(*parse_warning_filter(arg, escape=False)) yield for warning_message in log: ihook.pytest_warning_captured.call_historic( - kwargs=dict(warning_message=warning_message, when=when, item=item) + kwargs=dict( + warning_message=warning_message, + when=when, + item=item, + location=None, + ) + ) + ihook.pytest_warning_recorded.call_historic( + kwargs=dict( + warning_message=warning_message, + nodeid=nodeid, + when=when, + location=None, + ) ) -def warning_record_to_str(warning_message): +def warning_record_to_str(warning_message: warnings.WarningMessage) -> str: """Convert a warnings.WarningMessage to a string.""" warn_msg = warning_message.message msg = warnings.formatwarning( - warn_msg, + str(warn_msg), warning_message.category, warning_message.filename, warning_message.lineno, @@ -111,7 +93,7 @@ def warning_record_to_str(warning_message): @pytest.hookimpl(hookwrapper=True, tryfirst=True) -def pytest_runtest_protocol(item): +def pytest_runtest_protocol(item: Item) -> Generator[None, None, None]: with catch_warnings_for_item( config=item.config, ihook=item.ihook, when="runtest", item=item ): @@ -128,7 +110,9 @@ def pytest_collection(session: Session) -> Generator[None, None, None]: @pytest.hookimpl(hookwrapper=True) -def pytest_terminal_summary(terminalreporter): +def pytest_terminal_summary( + terminalreporter: TerminalReporter, +) -> Generator[None, None, None]: config = terminalreporter.config with catch_warnings_for_item( config=config, ihook=config.hook, when="config", item=None @@ -137,7 +121,7 @@ def pytest_terminal_summary(terminalreporter): @pytest.hookimpl(hookwrapper=True) -def pytest_sessionfinish(session): +def pytest_sessionfinish(session: Session) -> Generator[None, None, None]: config = session.config with catch_warnings_for_item( config=config, ihook=config.hook, when="config", item=None @@ -145,25 +129,11 @@ def pytest_sessionfinish(session): yield -def _issue_warning_captured(warning, hook, stacklevel): - """ - This function should be used instead of calling ``warnings.warn`` directly when we are in the "configure" stage: - at this point the actual options might not have been set, so we manually trigger the pytest_warning_captured - hook so we can display these warnings in the terminal. This is a hack until we can sort out #2891. - - :param warning: the warning instance. - :param hook: the hook caller - :param stacklevel: stacklevel forwarded to warnings.warn - """ - with warnings.catch_warnings(record=True) as records: - warnings.simplefilter("always", type(warning)) - warnings.warn(warning, stacklevel=stacklevel) - # Mypy can't infer that record=True means records is not None; help it. - assert records is not None - frame = sys._getframe(stacklevel - 1) - location = frame.f_code.co_filename, frame.f_lineno, frame.f_code.co_name - hook.pytest_warning_captured.call_historic( - kwargs=dict( - warning_message=records[0], when="config", item=None, location=location - ) - ) +@pytest.hookimpl(hookwrapper=True) +def pytest_load_initial_conftests( + early_config: "Config", +) -> Generator[None, None, None]: + with catch_warnings_for_item( + config=early_config, ihook=early_config.hook, when="config", item=None + ): + yield diff --git a/src/pytest/__init__.py b/src/pytest/__init__.py index 33bc3d0fbe5..70177f95040 100644 --- a/src/pytest/__init__.py +++ b/src/pytest/__init__.py @@ -1,24 +1,29 @@ # PYTHON_ARGCOMPLETE_OK -""" -pytest: unit and functional testing with Python. -""" +"""pytest: unit and functional testing with Python.""" +from . import collect from _pytest import __version__ from _pytest.assertion import register_assert_rewrite -from _pytest.compat import _setup_collect_fakemodule +from _pytest.cacheprovider import Cache +from _pytest.capture import CaptureFixture from _pytest.config import cmdline +from _pytest.config import console_main from _pytest.config import ExitCode from _pytest.config import hookimpl from _pytest.config import hookspec from _pytest.config import main from _pytest.config import UsageError from _pytest.debugging import pytestPDB as __pytestPDB -from _pytest.fixtures import fillfixtures as _fillfuncargs +from _pytest.fixtures import _fillfuncargs from _pytest.fixtures import fixture +from _pytest.fixtures import FixtureLookupError +from _pytest.fixtures import FixtureRequest from _pytest.fixtures import yield_fixture from _pytest.freeze_support import freeze_includes +from _pytest.logging import LogCaptureFixture from _pytest.main import Session from _pytest.mark import MARK_GEN as mark from _pytest.mark import param +from _pytest.monkeypatch import MonkeyPatch from _pytest.nodes import Collector from _pytest.nodes import File from _pytest.nodes import Item @@ -27,6 +32,8 @@ from _pytest.outcomes import importorskip from _pytest.outcomes import skip from _pytest.outcomes import xfail +from _pytest.pytester import Pytester +from _pytest.pytester import Testdir from _pytest.python import Class from _pytest.python import Function from _pytest.python import Instance @@ -35,7 +42,10 @@ from _pytest.python_api import approx from _pytest.python_api import raises from _pytest.recwarn import deprecated_call +from _pytest.recwarn import WarningsRecorder from _pytest.recwarn import warns +from _pytest.tmpdir import TempdirFactory +from _pytest.tmpdir import TempPathFactory from _pytest.warning_types import PytestAssertRewriteWarning from _pytest.warning_types import PytestCacheWarning from _pytest.warning_types import PytestCollectionWarning @@ -43,25 +53,32 @@ from _pytest.warning_types import PytestDeprecationWarning from _pytest.warning_types import PytestExperimentalApiWarning from _pytest.warning_types import PytestUnhandledCoroutineWarning +from _pytest.warning_types import PytestUnhandledThreadExceptionWarning from _pytest.warning_types import PytestUnknownMarkWarning +from _pytest.warning_types import PytestUnraisableExceptionWarning from _pytest.warning_types import PytestWarning - set_trace = __pytestPDB.set_trace __all__ = [ "__version__", "_fillfuncargs", "approx", + "Cache", + "CaptureFixture", "Class", "cmdline", + "collect", "Collector", + "console_main", "deprecated_call", "exit", "ExitCode", "fail", "File", "fixture", + "FixtureLookupError", + "FixtureRequest", "freeze_includes", "Function", "hookimpl", @@ -69,9 +86,11 @@ "importorskip", "Instance", "Item", + "LogCaptureFixture", "main", "mark", "Module", + "MonkeyPatch", "Package", "param", "PytestAssertRewriteWarning", @@ -80,20 +99,23 @@ "PytestConfigWarning", "PytestDeprecationWarning", "PytestExperimentalApiWarning", + "Pytester", "PytestUnhandledCoroutineWarning", + "PytestUnhandledThreadExceptionWarning", "PytestUnknownMarkWarning", + "PytestUnraisableExceptionWarning", "PytestWarning", "raises", "register_assert_rewrite", "Session", "set_trace", "skip", + "TempPathFactory", + "Testdir", + "TempdirFactory", "UsageError", + "WarningsRecorder", "warns", "xfail", "yield_fixture", ] - - -_setup_collect_fakemodule() -del _setup_collect_fakemodule diff --git a/src/pytest/__main__.py b/src/pytest/__main__.py index 01b2f6ccfe9..b170152937b 100644 --- a/src/pytest/__main__.py +++ b/src/pytest/__main__.py @@ -1,7 +1,5 @@ -""" -pytest entry point -""" +"""The pytest entry point.""" import pytest if __name__ == "__main__": - raise SystemExit(pytest.main()) + raise SystemExit(pytest.console_main()) diff --git a/src/pytest/collect.py b/src/pytest/collect.py new file mode 100644 index 00000000000..2edf4470f4d --- /dev/null +++ b/src/pytest/collect.py @@ -0,0 +1,39 @@ +import sys +import warnings +from types import ModuleType +from typing import Any +from typing import List + +import pytest +from _pytest.deprecated import PYTEST_COLLECT_MODULE + +COLLECT_FAKEMODULE_ATTRIBUTES = [ + "Collector", + "Module", + "Function", + "Instance", + "Session", + "Item", + "Class", + "File", + "_fillfuncargs", +] + + +class FakeCollectModule(ModuleType): + def __init__(self) -> None: + super().__init__("pytest.collect") + self.__all__ = list(COLLECT_FAKEMODULE_ATTRIBUTES) + self.__pytest = pytest + + def __dir__(self) -> List[str]: + return dir(super()) + self.__all__ + + def __getattr__(self, name: str) -> Any: + if name not in self.__all__: + raise AttributeError(name) + warnings.warn(PYTEST_COLLECT_MODULE.format(name=name), stacklevel=2) + return getattr(pytest, name) + + +sys.modules["pytest.collect"] = FakeCollectModule() diff --git a/src/pytest/py.typed b/src/pytest/py.typed new file mode 100644 index 00000000000..e69de29bb2d diff --git a/testing/acceptance_test.py b/testing/acceptance_test.py index 861938617e8..b7ec18a9cb6 100644 --- a/testing/acceptance_test.py +++ b/testing/acceptance_test.py @@ -1,6 +1,5 @@ import os import sys -import textwrap import types import attr @@ -9,9 +8,11 @@ import pytest from _pytest.compat import importlib_metadata from _pytest.config import ExitCode +from _pytest.pathlib import symlink_or_skip +from _pytest.pytester import Pytester -def prepend_pythonpath(*dirs): +def prepend_pythonpath(*dirs) -> str: cur = os.getenv("PYTHONPATH") if cur: dirs += (cur,) @@ -19,60 +20,60 @@ def prepend_pythonpath(*dirs): class TestGeneralUsage: - def test_config_error(self, testdir): - testdir.copy_example("conftest_usageerror/conftest.py") - result = testdir.runpytest(testdir.tmpdir) + def test_config_error(self, pytester: Pytester) -> None: + pytester.copy_example("conftest_usageerror/conftest.py") + result = pytester.runpytest(pytester.path) assert result.ret == ExitCode.USAGE_ERROR result.stderr.fnmatch_lines(["*ERROR: hello"]) result.stdout.fnmatch_lines(["*pytest_unconfigure_called"]) - def test_root_conftest_syntax_error(self, testdir): - testdir.makepyfile(conftest="raise SyntaxError\n") - result = testdir.runpytest() + def test_root_conftest_syntax_error(self, pytester: Pytester) -> None: + pytester.makepyfile(conftest="raise SyntaxError\n") + result = pytester.runpytest() result.stderr.fnmatch_lines(["*raise SyntaxError*"]) assert result.ret != 0 - def test_early_hook_error_issue38_1(self, testdir): - testdir.makeconftest( + def test_early_hook_error_issue38_1(self, pytester: Pytester) -> None: + pytester.makeconftest( """ def pytest_sessionstart(): 0 / 0 """ ) - result = testdir.runpytest(testdir.tmpdir) + result = pytester.runpytest(pytester.path) assert result.ret != 0 # tracestyle is native by default for hook failures result.stdout.fnmatch_lines( ["*INTERNALERROR*File*conftest.py*line 2*", "*0 / 0*"] ) - result = testdir.runpytest(testdir.tmpdir, "--fulltrace") + result = pytester.runpytest(pytester.path, "--fulltrace") assert result.ret != 0 # tracestyle is native by default for hook failures result.stdout.fnmatch_lines( ["*INTERNALERROR*def pytest_sessionstart():*", "*INTERNALERROR*0 / 0*"] ) - def test_early_hook_configure_error_issue38(self, testdir): - testdir.makeconftest( + def test_early_hook_configure_error_issue38(self, pytester: Pytester) -> None: + pytester.makeconftest( """ def pytest_configure(): 0 / 0 """ ) - result = testdir.runpytest(testdir.tmpdir) + result = pytester.runpytest(pytester.path) assert result.ret != 0 # here we get it on stderr result.stderr.fnmatch_lines( ["*INTERNALERROR*File*conftest.py*line 2*", "*0 / 0*"] ) - def test_file_not_found(self, testdir): - result = testdir.runpytest("asd") + def test_file_not_found(self, pytester: Pytester) -> None: + result = pytester.runpytest("asd") assert result.ret != 0 - result.stderr.fnmatch_lines(["ERROR: file not found*asd"]) + result.stderr.fnmatch_lines(["ERROR: file or directory not found: asd"]) - def test_file_not_found_unconfigure_issue143(self, testdir): - testdir.makeconftest( + def test_file_not_found_unconfigure_issue143(self, pytester: Pytester) -> None: + pytester.makeconftest( """ def pytest_configure(): print("---configure") @@ -80,36 +81,38 @@ def pytest_unconfigure(): print("---unconfigure") """ ) - result = testdir.runpytest("-s", "asd") + result = pytester.runpytest("-s", "asd") assert result.ret == ExitCode.USAGE_ERROR - result.stderr.fnmatch_lines(["ERROR: file not found*asd"]) + result.stderr.fnmatch_lines(["ERROR: file or directory not found: asd"]) result.stdout.fnmatch_lines(["*---configure", "*---unconfigure"]) - def test_config_preparse_plugin_option(self, testdir): - testdir.makepyfile( + def test_config_preparse_plugin_option(self, pytester: Pytester) -> None: + pytester.makepyfile( pytest_xyz=""" def pytest_addoption(parser): parser.addoption("--xyz", dest="xyz", action="store") """ ) - testdir.makepyfile( + pytester.makepyfile( test_one=""" def test_option(pytestconfig): assert pytestconfig.option.xyz == "123" """ ) - result = testdir.runpytest("-p", "pytest_xyz", "--xyz=123", syspathinsert=True) + result = pytester.runpytest("-p", "pytest_xyz", "--xyz=123", syspathinsert=True) assert result.ret == 0 result.stdout.fnmatch_lines(["*1 passed*"]) @pytest.mark.parametrize("load_cov_early", [True, False]) - def test_early_load_setuptools_name(self, testdir, monkeypatch, load_cov_early): + def test_early_load_setuptools_name( + self, pytester: Pytester, monkeypatch, load_cov_early + ) -> None: monkeypatch.delenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD") - testdir.makepyfile(mytestplugin1_module="") - testdir.makepyfile(mytestplugin2_module="") - testdir.makepyfile(mycov_module="") - testdir.syspathinsert() + pytester.makepyfile(mytestplugin1_module="") + pytester.makepyfile(mytestplugin2_module="") + pytester.makepyfile(mycov_module="") + pytester.syspathinsert() loaded = [] @@ -140,34 +143,35 @@ def my_dists(): monkeypatch.setattr(importlib_metadata, "distributions", my_dists) params = ("-p", "mycov") if load_cov_early else () - testdir.runpytest_inprocess(*params) + pytester.runpytest_inprocess(*params) if load_cov_early: assert loaded == ["mycov", "myplugin1", "myplugin2"] else: assert loaded == ["myplugin1", "myplugin2", "mycov"] - def test_assertion_magic(self, testdir): - p = testdir.makepyfile( + @pytest.mark.parametrize("import_mode", ["prepend", "append", "importlib"]) + def test_assertion_rewrite(self, pytester: Pytester, import_mode) -> None: + p = pytester.makepyfile( """ def test_this(): x = 0 assert x """ ) - result = testdir.runpytest(p) + result = pytester.runpytest(p, f"--import-mode={import_mode}") result.stdout.fnmatch_lines(["> assert x", "E assert 0"]) assert result.ret == 1 - def test_nested_import_error(self, testdir): - p = testdir.makepyfile( + def test_nested_import_error(self, pytester: Pytester) -> None: + p = pytester.makepyfile( """ import import_fails def test_this(): assert import_fails.a == 1 """ ) - testdir.makepyfile(import_fails="import does_not_work") - result = testdir.runpytest(p) + pytester.makepyfile(import_fails="import does_not_work") + result = pytester.runpytest(p) result.stdout.fnmatch_lines( [ "ImportError while importing test module*", @@ -176,146 +180,122 @@ def test_this(): ) assert result.ret == 2 - def test_not_collectable_arguments(self, testdir): - p1 = testdir.makepyfile("") - p2 = testdir.makefile(".pyc", "123") - result = testdir.runpytest(p1, p2) + def test_not_collectable_arguments(self, pytester: Pytester) -> None: + p1 = pytester.makepyfile("") + p2 = pytester.makefile(".pyc", "123") + result = pytester.runpytest(p1, p2) assert result.ret == ExitCode.USAGE_ERROR result.stderr.fnmatch_lines( [ - "ERROR: not found: {}".format(p2), + f"ERROR: not found: {p2}", "(no name {!r} in any of [[][]])".format(str(p2)), "", ] ) @pytest.mark.filterwarnings("default") - def test_better_reporting_on_conftest_load_failure(self, testdir): + def test_better_reporting_on_conftest_load_failure( + self, pytester: Pytester + ) -> None: """Show a user-friendly traceback on conftest import failures (#486, #3332)""" - testdir.makepyfile("") - conftest = testdir.makeconftest( + pytester.makepyfile("") + conftest = pytester.makeconftest( """ def foo(): import qwerty foo() """ ) - result = testdir.runpytest("--help") + result = pytester.runpytest("--help") result.stdout.fnmatch_lines( """ *--version* *warning*conftest.py* """ ) - result = testdir.runpytest() - exc_name = ( - "ModuleNotFoundError" if sys.version_info >= (3, 6) else "ImportError" - ) + result = pytester.runpytest() assert result.stdout.lines == [] assert result.stderr.lines == [ - "ImportError while loading conftest '{}'.".format(conftest), + f"ImportError while loading conftest '{conftest}'.", "conftest.py:3: in ", " foo()", "conftest.py:2: in foo", " import qwerty", - "E {}: No module named 'qwerty'".format(exc_name), + "E ModuleNotFoundError: No module named 'qwerty'", ] - @pytest.mark.filterwarnings("always::pytest.PytestDeprecationWarning") - def test_early_skip(self, testdir): - testdir.mkdir("xyz") - testdir.makeconftest( + def test_early_skip(self, pytester: Pytester) -> None: + pytester.mkdir("xyz") + pytester.makeconftest( """ import pytest - def pytest_collect_directory(): + def pytest_collect_file(): pytest.skip("early") """ ) - result = testdir.runpytest() + result = pytester.runpytest() assert result.ret == ExitCode.NO_TESTS_COLLECTED result.stdout.fnmatch_lines(["*1 skip*"]) - def test_issue88_initial_file_multinodes(self, testdir): - testdir.copy_example("issue88_initial_file_multinodes") - p = testdir.makepyfile("def test_hello(): pass") - result = testdir.runpytest(p, "--collect-only") + def test_issue88_initial_file_multinodes(self, pytester: Pytester) -> None: + pytester.copy_example("issue88_initial_file_multinodes") + p = pytester.makepyfile("def test_hello(): pass") + result = pytester.runpytest(p, "--collect-only") result.stdout.fnmatch_lines(["*MyFile*test_issue88*", "*Module*test_issue88*"]) - def test_issue93_initialnode_importing_capturing(self, testdir): - testdir.makeconftest( + def test_issue93_initialnode_importing_capturing(self, pytester: Pytester) -> None: + pytester.makeconftest( """ import sys print("should not be seen") sys.stderr.write("stder42\\n") """ ) - result = testdir.runpytest() + result = pytester.runpytest() assert result.ret == ExitCode.NO_TESTS_COLLECTED result.stdout.no_fnmatch_line("*should not be seen*") assert "stderr42" not in result.stderr.str() - def test_conftest_printing_shows_if_error(self, testdir): - testdir.makeconftest( + def test_conftest_printing_shows_if_error(self, pytester: Pytester) -> None: + pytester.makeconftest( """ print("should be seen") assert 0 """ ) - result = testdir.runpytest() + result = pytester.runpytest() assert result.ret != 0 assert "should be seen" in result.stdout.str() - @pytest.mark.skipif( - not hasattr(py.path.local, "mksymlinkto"), - reason="symlink not available on this platform", - ) - def test_chdir(self, testdir): - testdir.tmpdir.join("py").mksymlinkto(py._pydir) - p = testdir.tmpdir.join("main.py") - p.write( - textwrap.dedent( - """\ - import sys, os - sys.path.insert(0, '') - import py - print(py.__file__) - print(py.__path__) - os.chdir(os.path.dirname(os.getcwd())) - print(py.log) - """ - ) - ) - result = testdir.runpython(p) - assert not result.ret - - def test_issue109_sibling_conftests_not_loaded(self, testdir): - sub1 = testdir.mkdir("sub1") - sub2 = testdir.mkdir("sub2") - sub1.join("conftest.py").write("assert 0") - result = testdir.runpytest(sub2) + def test_issue109_sibling_conftests_not_loaded(self, pytester: Pytester) -> None: + sub1 = pytester.mkdir("sub1") + sub2 = pytester.mkdir("sub2") + sub1.joinpath("conftest.py").write_text("assert 0") + result = pytester.runpytest(sub2) assert result.ret == ExitCode.NO_TESTS_COLLECTED - sub2.ensure("__init__.py") - p = sub2.ensure("test_hello.py") - result = testdir.runpytest(p) + sub2.joinpath("__init__.py").touch() + p = sub2.joinpath("test_hello.py") + p.touch() + result = pytester.runpytest(p) assert result.ret == ExitCode.NO_TESTS_COLLECTED - result = testdir.runpytest(sub1) + result = pytester.runpytest(sub1) assert result.ret == ExitCode.USAGE_ERROR - def test_directory_skipped(self, testdir): - testdir.makeconftest( + def test_directory_skipped(self, pytester: Pytester) -> None: + pytester.makeconftest( """ import pytest def pytest_ignore_collect(): pytest.skip("intentional") """ ) - testdir.makepyfile("def test_hello(): pass") - result = testdir.runpytest() + pytester.makepyfile("def test_hello(): pass") + result = pytester.runpytest() assert result.ret == ExitCode.NO_TESTS_COLLECTED result.stdout.fnmatch_lines(["*1 skipped*"]) - def test_multiple_items_per_collector_byid(self, testdir): - c = testdir.makeconftest( + def test_multiple_items_per_collector_byid(self, pytester: Pytester) -> None: + c = pytester.makeconftest( """ import pytest class MyItem(pytest.Item): @@ -323,18 +303,18 @@ def runtest(self): pass class MyCollector(pytest.File): def collect(self): - return [MyItem(name="xyz", parent=self)] + return [MyItem.from_parent(name="xyz", parent=self)] def pytest_collect_file(path, parent): if path.basename.startswith("conftest"): - return MyCollector(path, parent) + return MyCollector.from_parent(fspath=path, parent=parent) """ ) - result = testdir.runpytest(c.basename + "::" + "xyz") + result = pytester.runpytest(c.name + "::" + "xyz") assert result.ret == 0 result.stdout.fnmatch_lines(["*1 pass*"]) - def test_skip_on_generated_funcarg_id(self, testdir): - testdir.makeconftest( + def test_skip_on_generated_funcarg_id(self, pytester: Pytester) -> None: + pytester.makeconftest( """ import pytest def pytest_generate_tests(metafunc): @@ -346,13 +326,13 @@ def pytest_runtest_setup(item): assert 0 """ ) - p = testdir.makepyfile("""def test_func(x): pass""") - res = testdir.runpytest(p) + p = pytester.makepyfile("""def test_func(x): pass""") + res = pytester.runpytest(p) assert res.ret == 0 res.stdout.fnmatch_lines(["*1 skipped*"]) - def test_direct_addressing_selects(self, testdir): - p = testdir.makepyfile( + def test_direct_addressing_selects(self, pytester: Pytester) -> None: + p = pytester.makepyfile( """ def pytest_generate_tests(metafunc): metafunc.parametrize('i', [1, 2], ids=["1", "2"]) @@ -360,56 +340,58 @@ def test_func(i): pass """ ) - res = testdir.runpytest(p.basename + "::" + "test_func[1]") + res = pytester.runpytest(p.name + "::" + "test_func[1]") assert res.ret == 0 res.stdout.fnmatch_lines(["*1 passed*"]) - def test_direct_addressing_notfound(self, testdir): - p = testdir.makepyfile( + def test_direct_addressing_notfound(self, pytester: Pytester) -> None: + p = pytester.makepyfile( """ def test_func(): pass """ ) - res = testdir.runpytest(p.basename + "::" + "test_notfound") + res = pytester.runpytest(p.name + "::" + "test_notfound") assert res.ret res.stderr.fnmatch_lines(["*ERROR*not found*"]) - def test_docstring_on_hookspec(self): + def test_docstring_on_hookspec(self) -> None: from _pytest import hookspec for name, value in vars(hookspec).items(): if name.startswith("pytest_"): assert value.__doc__, "no docstring for %s" % name - def test_initialization_error_issue49(self, testdir): - testdir.makeconftest( + def test_initialization_error_issue49(self, pytester: Pytester) -> None: + pytester.makeconftest( """ def pytest_configure(): x """ ) - result = testdir.runpytest() + result = pytester.runpytest() assert result.ret == 3 # internal error result.stderr.fnmatch_lines(["INTERNAL*pytest_configure*", "INTERNAL*x*"]) assert "sessionstarttime" not in result.stderr.str() @pytest.mark.parametrize("lookfor", ["test_fun.py::test_a"]) - def test_issue134_report_error_when_collecting_member(self, testdir, lookfor): - testdir.makepyfile( + def test_issue134_report_error_when_collecting_member( + self, pytester: Pytester, lookfor + ) -> None: + pytester.makepyfile( test_fun=""" def test_a(): pass def""" ) - result = testdir.runpytest(lookfor) + result = pytester.runpytest(lookfor) result.stdout.fnmatch_lines(["*SyntaxError*"]) if "::" in lookfor: result.stderr.fnmatch_lines(["*ERROR*"]) assert result.ret == 4 # usage error only if item not found - def test_report_all_failed_collections_initargs(self, testdir): - testdir.makeconftest( + def test_report_all_failed_collections_initargs(self, pytester: Pytester) -> None: + pytester.makeconftest( """ from _pytest.config import ExitCode @@ -418,24 +400,23 @@ def pytest_sessionfinish(exitstatus): print("pytest_sessionfinish_called") """ ) - testdir.makepyfile(test_a="def", test_b="def") - result = testdir.runpytest("test_a.py::a", "test_b.py::b") + pytester.makepyfile(test_a="def", test_b="def") + result = pytester.runpytest("test_a.py::a", "test_b.py::b") result.stderr.fnmatch_lines(["*ERROR*test_a.py::a*", "*ERROR*test_b.py::b*"]) result.stdout.fnmatch_lines(["pytest_sessionfinish_called"]) assert result.ret == ExitCode.USAGE_ERROR - @pytest.mark.usefixtures("recwarn") - def test_namespace_import_doesnt_confuse_import_hook(self, testdir): - """ - Ref #383. Python 3.3's namespace package messed with our import hooks + def test_namespace_import_doesnt_confuse_import_hook( + self, pytester: Pytester + ) -> None: + """Ref #383. + + Python 3.3's namespace package messed with our import hooks. Importing a module that didn't exist, even if the ImportError was gracefully handled, would make our test crash. - - Use recwarn here to silence this warning in Python 2.7: - ImportWarning: Not importing directory '...\not_a_package': missing __init__.py """ - testdir.mkdir("not_a_package") - p = testdir.makepyfile( + pytester.mkdir("not_a_package") + p = pytester.makepyfile( """ try: from not_a_package import doesnt_exist @@ -447,23 +428,25 @@ def test_whatever(): pass """ ) - res = testdir.runpytest(p.basename) + res = pytester.runpytest(p.name) assert res.ret == 0 - def test_unknown_option(self, testdir): - result = testdir.runpytest("--qwlkej") + def test_unknown_option(self, pytester: Pytester) -> None: + result = pytester.runpytest("--qwlkej") result.stderr.fnmatch_lines( """ *unrecognized* """ ) - def test_getsourcelines_error_issue553(self, testdir, monkeypatch): + def test_getsourcelines_error_issue553( + self, pytester: Pytester, monkeypatch + ) -> None: monkeypatch.setattr("inspect.getsourcelines", None) - p = testdir.makepyfile( + p = pytester.makepyfile( """ def raise_error(obj): - raise IOError('source code not available') + raise OSError('source code not available') import inspect inspect.getsourcelines = raise_error @@ -472,28 +455,28 @@ def test_foo(invalid_fixture): pass """ ) - res = testdir.runpytest(p) + res = pytester.runpytest(p) res.stdout.fnmatch_lines( ["*source code not available*", "E*fixture 'invalid_fixture' not found"] ) - def test_plugins_given_as_strings(self, tmpdir, monkeypatch, _sys_snapshot): - """test that str values passed to main() as `plugins` arg - are interpreted as module names to be imported and registered. - #855. - """ + def test_plugins_given_as_strings( + self, pytester: Pytester, monkeypatch, _sys_snapshot + ) -> None: + """Test that str values passed to main() as `plugins` arg are + interpreted as module names to be imported and registered (#855).""" with pytest.raises(ImportError) as excinfo: - pytest.main([str(tmpdir)], plugins=["invalid.module"]) + pytest.main([str(pytester.path)], plugins=["invalid.module"]) assert "invalid" in str(excinfo.value) - p = tmpdir.join("test_test_plugins_given_as_strings.py") - p.write("def test_foo(): pass") + p = pytester.path.joinpath("test_test_plugins_given_as_strings.py") + p.write_text("def test_foo(): pass") mod = types.ModuleType("myplugin") monkeypatch.setitem(sys.modules, "myplugin", mod) - assert pytest.main(args=[str(tmpdir)], plugins=["myplugin"]) == 0 + assert pytest.main(args=[str(pytester.path)], plugins=["myplugin"]) == 0 - def test_parametrized_with_bytes_regex(self, testdir): - p = testdir.makepyfile( + def test_parametrized_with_bytes_regex(self, pytester: Pytester) -> None: + p = pytester.makepyfile( """ import re import pytest @@ -502,12 +485,12 @@ def test_stuff(r): pass """ ) - res = testdir.runpytest(p) + res = pytester.runpytest(p) res.stdout.fnmatch_lines(["*1 passed*"]) - def test_parametrized_with_null_bytes(self, testdir): + def test_parametrized_with_null_bytes(self, pytester: Pytester) -> None: """Test parametrization with values that contain null bytes and unicode characters (#2644, #2957)""" - p = testdir.makepyfile( + p = pytester.makepyfile( """\ import pytest @@ -516,30 +499,30 @@ def test_foo(data): assert data """ ) - res = testdir.runpytest(p) + res = pytester.runpytest(p) res.assert_outcomes(passed=3) class TestInvocationVariants: - def test_earlyinit(self, testdir): - p = testdir.makepyfile( + def test_earlyinit(self, pytester: Pytester) -> None: + p = pytester.makepyfile( """ import pytest assert hasattr(pytest, 'mark') """ ) - result = testdir.runpython(p) + result = pytester.runpython(p) assert result.ret == 0 - def test_pydoc(self, testdir): + def test_pydoc(self, pytester: Pytester) -> None: for name in ("py.test", "pytest"): - result = testdir.runpython_c("import {};help({})".format(name, name)) + result = pytester.runpython_c(f"import {name};help({name})") assert result.ret == 0 s = result.stdout.str() assert "MarkGenerator" in s - def test_import_star_py_dot_test(self, testdir): - p = testdir.makepyfile( + def test_import_star_py_dot_test(self, pytester: Pytester) -> None: + p = pytester.makepyfile( """ from py.test import * #collect @@ -552,11 +535,11 @@ def test_import_star_py_dot_test(self, testdir): xfail """ ) - result = testdir.runpython(p) + result = pytester.runpython(p) assert result.ret == 0 - def test_import_star_pytest(self, testdir): - p = testdir.makepyfile( + def test_import_star_pytest(self, pytester: Pytester) -> None: + p = pytester.makepyfile( """ from pytest import * #Item @@ -566,57 +549,58 @@ def test_import_star_pytest(self, testdir): xfail """ ) - result = testdir.runpython(p) + result = pytester.runpython(p) assert result.ret == 0 - def test_double_pytestcmdline(self, testdir): - p = testdir.makepyfile( + def test_double_pytestcmdline(self, pytester: Pytester) -> None: + p = pytester.makepyfile( run=""" import pytest pytest.main() pytest.main() """ ) - testdir.makepyfile( + pytester.makepyfile( """ def test_hello(): pass """ ) - result = testdir.runpython(p) + result = pytester.runpython(p) result.stdout.fnmatch_lines(["*1 passed*", "*1 passed*"]) - def test_python_minus_m_invocation_ok(self, testdir): - p1 = testdir.makepyfile("def test_hello(): pass") - res = testdir.run(sys.executable, "-m", "pytest", str(p1)) + def test_python_minus_m_invocation_ok(self, pytester: Pytester) -> None: + p1 = pytester.makepyfile("def test_hello(): pass") + res = pytester.run(sys.executable, "-m", "pytest", str(p1)) assert res.ret == 0 - def test_python_minus_m_invocation_fail(self, testdir): - p1 = testdir.makepyfile("def test_fail(): 0/0") - res = testdir.run(sys.executable, "-m", "pytest", str(p1)) + def test_python_minus_m_invocation_fail(self, pytester: Pytester) -> None: + p1 = pytester.makepyfile("def test_fail(): 0/0") + res = pytester.run(sys.executable, "-m", "pytest", str(p1)) assert res.ret == 1 - def test_python_pytest_package(self, testdir): - p1 = testdir.makepyfile("def test_pass(): pass") - res = testdir.run(sys.executable, "-m", "pytest", str(p1)) + def test_python_pytest_package(self, pytester: Pytester) -> None: + p1 = pytester.makepyfile("def test_pass(): pass") + res = pytester.run(sys.executable, "-m", "pytest", str(p1)) assert res.ret == 0 res.stdout.fnmatch_lines(["*1 passed*"]) - def test_equivalence_pytest_pytest(self): - assert pytest.main == py.test.cmdline.main + def test_equivalence_pytest_pydottest(self) -> None: + # Type ignored because `py.test` is not and will not be typed. + assert pytest.main == py.test.cmdline.main # type: ignore[attr-defined] - def test_invoke_with_invalid_type(self): + def test_invoke_with_invalid_type(self) -> None: with pytest.raises( TypeError, match="expected to be a list of strings, got: '-h'" ): - pytest.main("-h") + pytest.main("-h") # type: ignore[arg-type] - def test_invoke_with_path(self, tmpdir, capsys): - retcode = pytest.main(tmpdir) + def test_invoke_with_path(self, pytester: Pytester, capsys) -> None: + retcode = pytest.main([str(pytester.path)]) assert retcode == ExitCode.NO_TESTS_COLLECTED out, err = capsys.readouterr() - def test_invoke_plugin_api(self, capsys): + def test_invoke_plugin_api(self, capsys) -> None: class MyPlugin: def pytest_addoption(self, parser): parser.addoption("--myopt") @@ -625,67 +609,72 @@ def pytest_addoption(self, parser): out, err = capsys.readouterr() assert "--myopt" in out - def test_pyargs_importerror(self, testdir, monkeypatch): + def test_pyargs_importerror(self, pytester: Pytester, monkeypatch) -> None: monkeypatch.delenv("PYTHONDONTWRITEBYTECODE", False) - path = testdir.mkpydir("tpkg") - path.join("test_hello.py").write("raise ImportError") + path = pytester.mkpydir("tpkg") + path.joinpath("test_hello.py").write_text("raise ImportError") - result = testdir.runpytest("--pyargs", "tpkg.test_hello", syspathinsert=True) + result = pytester.runpytest("--pyargs", "tpkg.test_hello", syspathinsert=True) assert result.ret != 0 result.stdout.fnmatch_lines(["collected*0*items*/*1*error"]) - def test_pyargs_only_imported_once(self, testdir): - pkg = testdir.mkpydir("foo") - pkg.join("test_foo.py").write("print('hello from test_foo')\ndef test(): pass") - pkg.join("conftest.py").write( + def test_pyargs_only_imported_once(self, pytester: Pytester) -> None: + pkg = pytester.mkpydir("foo") + pkg.joinpath("test_foo.py").write_text( + "print('hello from test_foo')\ndef test(): pass" + ) + pkg.joinpath("conftest.py").write_text( "def pytest_configure(config): print('configuring')" ) - result = testdir.runpytest("--pyargs", "foo.test_foo", "-s", syspathinsert=True) + result = pytester.runpytest( + "--pyargs", "foo.test_foo", "-s", syspathinsert=True + ) # should only import once assert result.outlines.count("hello from test_foo") == 1 # should only configure once assert result.outlines.count("configuring") == 1 - def test_pyargs_filename_looks_like_module(self, testdir): - testdir.tmpdir.join("conftest.py").ensure() - testdir.tmpdir.join("t.py").write("def test(): pass") - result = testdir.runpytest("--pyargs", "t.py") + def test_pyargs_filename_looks_like_module(self, pytester: Pytester) -> None: + pytester.path.joinpath("conftest.py").touch() + pytester.path.joinpath("t.py").write_text("def test(): pass") + result = pytester.runpytest("--pyargs", "t.py") assert result.ret == ExitCode.OK - def test_cmdline_python_package(self, testdir, monkeypatch): + def test_cmdline_python_package(self, pytester: Pytester, monkeypatch) -> None: import warnings monkeypatch.delenv("PYTHONDONTWRITEBYTECODE", False) - path = testdir.mkpydir("tpkg") - path.join("test_hello.py").write("def test_hello(): pass") - path.join("test_world.py").write("def test_world(): pass") - result = testdir.runpytest("--pyargs", "tpkg") + path = pytester.mkpydir("tpkg") + path.joinpath("test_hello.py").write_text("def test_hello(): pass") + path.joinpath("test_world.py").write_text("def test_world(): pass") + result = pytester.runpytest("--pyargs", "tpkg") assert result.ret == 0 result.stdout.fnmatch_lines(["*2 passed*"]) - result = testdir.runpytest("--pyargs", "tpkg.test_hello", syspathinsert=True) + result = pytester.runpytest("--pyargs", "tpkg.test_hello", syspathinsert=True) assert result.ret == 0 result.stdout.fnmatch_lines(["*1 passed*"]) - empty_package = testdir.mkpydir("empty_package") + empty_package = pytester.mkpydir("empty_package") monkeypatch.setenv("PYTHONPATH", str(empty_package), prepend=os.pathsep) # the path which is not a package raises a warning on pypy; # no idea why only pypy and not normal python warn about it here with warnings.catch_warnings(): warnings.simplefilter("ignore", ImportWarning) - result = testdir.runpytest("--pyargs", ".") + result = pytester.runpytest("--pyargs", ".") assert result.ret == 0 result.stdout.fnmatch_lines(["*2 passed*"]) - monkeypatch.setenv("PYTHONPATH", str(testdir), prepend=os.pathsep) - result = testdir.runpytest("--pyargs", "tpkg.test_missing", syspathinsert=True) + monkeypatch.setenv("PYTHONPATH", str(pytester), prepend=os.pathsep) + result = pytester.runpytest("--pyargs", "tpkg.test_missing", syspathinsert=True) assert result.ret != 0 result.stderr.fnmatch_lines(["*not*found*test_missing*"]) - def test_cmdline_python_namespace_package(self, testdir, monkeypatch): - """ - test --pyargs option with namespace packages (#1567) + def test_cmdline_python_namespace_package( + self, pytester: Pytester, monkeypatch + ) -> None: + """Test --pyargs option with namespace packages (#1567). Ref: https://packaging.python.org/guides/packaging-namespace-packages/ """ @@ -693,16 +682,18 @@ def test_cmdline_python_namespace_package(self, testdir, monkeypatch): search_path = [] for dirname in "hello", "world": - d = testdir.mkdir(dirname) + d = pytester.mkdir(dirname) search_path.append(d) - ns = d.mkdir("ns_pkg") - ns.join("__init__.py").write( + ns = d.joinpath("ns_pkg") + ns.mkdir() + ns.joinpath("__init__.py").write_text( "__import__('pkg_resources').declare_namespace(__name__)" ) - lib = ns.mkdir(dirname) - lib.ensure("__init__.py") - lib.join("test_{}.py".format(dirname)).write( - "def test_{}(): pass\ndef test_other():pass".format(dirname) + lib = ns.joinpath(dirname) + lib.mkdir() + lib.joinpath("__init__.py").touch() + lib.joinpath(f"test_{dirname}.py").write_text( + f"def test_{dirname}(): pass\ndef test_other():pass" ) # The structure of the test directory is now: @@ -727,7 +718,7 @@ def test_cmdline_python_namespace_package(self, testdir, monkeypatch): # mixed module and filenames: monkeypatch.chdir("world") - result = testdir.runpytest("--pyargs", "-v", "ns_pkg.hello", "ns_pkg/world") + result = pytester.runpytest("--pyargs", "-v", "ns_pkg.hello", "ns_pkg/world") assert result.ret == 0 result.stdout.fnmatch_lines( [ @@ -740,8 +731,8 @@ def test_cmdline_python_namespace_package(self, testdir, monkeypatch): ) # specify tests within a module - testdir.chdir() - result = testdir.runpytest( + pytester.chdir() + result = pytester.runpytest( "--pyargs", "-v", "ns_pkg.world.test_world::test_other" ) assert result.ret == 0 @@ -749,53 +740,47 @@ def test_cmdline_python_namespace_package(self, testdir, monkeypatch): ["*test_world.py::test_other*PASSED*", "*1 passed*"] ) - def test_invoke_test_and_doctestmodules(self, testdir): - p = testdir.makepyfile( + def test_invoke_test_and_doctestmodules(self, pytester: Pytester) -> None: + p = pytester.makepyfile( """ def test(): pass """ ) - result = testdir.runpytest(str(p) + "::test", "--doctest-modules") + result = pytester.runpytest(str(p) + "::test", "--doctest-modules") result.stdout.fnmatch_lines(["*1 passed*"]) - def test_cmdline_python_package_symlink(self, testdir, monkeypatch): + def test_cmdline_python_package_symlink( + self, pytester: Pytester, monkeypatch + ) -> None: """ - test --pyargs option with packages with path containing symlink can - have conftest.py in their package (#2985) + --pyargs with packages with path containing symlink can have conftest.py in + their package (#2985) """ - # dummy check that we can actually create symlinks: on Windows `os.symlink` is available, - # but normal users require special admin privileges to create symlinks. - if sys.platform == "win32": - try: - os.symlink( - str(testdir.tmpdir.ensure("tmpfile")), - str(testdir.tmpdir.join("tmpfile2")), - ) - except OSError as e: - pytest.skip(str(e.args[0])) monkeypatch.delenv("PYTHONDONTWRITEBYTECODE", raising=False) dirname = "lib" - d = testdir.mkdir(dirname) - foo = d.mkdir("foo") - foo.ensure("__init__.py") - lib = foo.mkdir("bar") - lib.ensure("__init__.py") - lib.join("test_bar.py").write( + d = pytester.mkdir(dirname) + foo = d.joinpath("foo") + foo.mkdir() + foo.joinpath("__init__.py").touch() + lib = foo.joinpath("bar") + lib.mkdir() + lib.joinpath("__init__.py").touch() + lib.joinpath("test_bar.py").write_text( "def test_bar(): pass\ndef test_other(a_fixture):pass" ) - lib.join("conftest.py").write( + lib.joinpath("conftest.py").write_text( "import pytest\n@pytest.fixture\ndef a_fixture():pass" ) - d_local = testdir.mkdir("local") - symlink_location = os.path.join(str(d_local), "lib") - os.symlink(str(d), symlink_location, target_is_directory=True) + d_local = pytester.mkdir("symlink_root") + symlink_location = d_local / "lib" + symlink_or_skip(d, symlink_location, target_is_directory=True) # The structure of the test directory is now: # . - # ├── local + # ├── symlink_root # │ └── lib -> ../lib # └── lib # └── foo @@ -806,41 +791,32 @@ def test_cmdline_python_package_symlink(self, testdir, monkeypatch): # └── test_bar.py # NOTE: the different/reversed ordering is intentional here. - search_path = ["lib", os.path.join("local", "lib")] + search_path = ["lib", os.path.join("symlink_root", "lib")] monkeypatch.setenv("PYTHONPATH", prepend_pythonpath(*search_path)) for p in search_path: monkeypatch.syspath_prepend(p) # module picked up in symlink-ed directory: - # It picks up local/lib/foo/bar (symlink) via sys.path. - result = testdir.runpytest("--pyargs", "-v", "foo.bar") - testdir.chdir() + # It picks up symlink_root/lib/foo/bar (symlink) via sys.path. + result = pytester.runpytest("--pyargs", "-v", "foo.bar") + pytester.chdir() assert result.ret == 0 - if hasattr(py.path.local, "mksymlinkto"): - result.stdout.fnmatch_lines( - [ - "lib/foo/bar/test_bar.py::test_bar PASSED*", - "lib/foo/bar/test_bar.py::test_other PASSED*", - "*2 passed*", - ] - ) - else: - result.stdout.fnmatch_lines( - [ - "*lib/foo/bar/test_bar.py::test_bar PASSED*", - "*lib/foo/bar/test_bar.py::test_other PASSED*", - "*2 passed*", - ] - ) + result.stdout.fnmatch_lines( + [ + "symlink_root/lib/foo/bar/test_bar.py::test_bar PASSED*", + "symlink_root/lib/foo/bar/test_bar.py::test_other PASSED*", + "*2 passed*", + ] + ) - def test_cmdline_python_package_not_exists(self, testdir): - result = testdir.runpytest("--pyargs", "tpkgwhatv") + def test_cmdline_python_package_not_exists(self, pytester: Pytester) -> None: + result = pytester.runpytest("--pyargs", "tpkgwhatv") assert result.ret - result.stderr.fnmatch_lines(["ERROR*file*or*package*not*found*"]) + result.stderr.fnmatch_lines(["ERROR*module*or*package*not*found*"]) @pytest.mark.xfail(reason="decide: feature or bug") - def test_noclass_discovery_if_not_testcase(self, testdir): - testpath = testdir.makepyfile( + def test_noclass_discovery_if_not_testcase(self, pytester: Pytester) -> None: + testpath = pytester.makepyfile( """ import unittest class TestHello(object): @@ -851,11 +827,11 @@ class RealTest(unittest.TestCase, TestHello): attr = 42 """ ) - reprec = testdir.inline_run(testpath) + reprec = pytester.inline_run(testpath) reprec.assertoutcome(passed=1) - def test_doctest_id(self, testdir): - testdir.makefile( + def test_doctest_id(self, pytester: Pytester) -> None: + pytester.makefile( ".txt", """ >>> x=3 @@ -870,16 +846,16 @@ def test_doctest_id(self, testdir): "FAILED test_doctest_id.txt::test_doctest_id.txt", "*= 1 failed in*", ] - result = testdir.runpytest(testid, "-rf", "--tb=short") + result = pytester.runpytest(testid, "-rf", "--tb=short") result.stdout.fnmatch_lines(expected_lines) # Ensure that re-running it will still handle it as # doctest.DocTestFailure, which was not the case before when # re-importing doctest, but not creating a new RUNNER_CLASS. - result = testdir.runpytest(testid, "-rf", "--tb=short") + result = pytester.runpytest(testid, "-rf", "--tb=short") result.stdout.fnmatch_lines(expected_lines) - def test_core_backward_compatibility(self): + def test_core_backward_compatibility(self) -> None: """Test backward compatibility for get_plugin_manager function. See #787.""" import _pytest.config @@ -888,122 +864,129 @@ def test_core_backward_compatibility(self): is _pytest.config.PytestPluginManager ) - def test_has_plugin(self, request): + def test_has_plugin(self, request) -> None: """Test hasplugin function of the plugin manager (#932).""" assert request.config.pluginmanager.hasplugin("python") class TestDurations: source = """ - import time - frag = 0.002 + from _pytest import timing def test_something(): pass def test_2(): - time.sleep(frag*5) + timing.sleep(0.010) def test_1(): - time.sleep(frag) + timing.sleep(0.002) def test_3(): - time.sleep(frag*10) + timing.sleep(0.020) """ - def test_calls(self, testdir): - testdir.makepyfile(self.source) - result = testdir.runpytest("--durations=10") + def test_calls(self, pytester: Pytester, mock_timing) -> None: + pytester.makepyfile(self.source) + result = pytester.runpytest_inprocess("--durations=10") assert result.ret == 0 + result.stdout.fnmatch_lines_random( ["*durations*", "*call*test_3*", "*call*test_2*"] ) + result.stdout.fnmatch_lines( - ["(0.00 durations hidden. Use -vv to show these durations.)"] + ["(8 durations < 0.005s hidden. Use -vv to show these durations.)"] ) - def test_calls_show_2(self, testdir): - testdir.makepyfile(self.source) - result = testdir.runpytest("--durations=2") + def test_calls_show_2(self, pytester: Pytester, mock_timing) -> None: + + pytester.makepyfile(self.source) + result = pytester.runpytest_inprocess("--durations=2") assert result.ret == 0 + lines = result.stdout.get_lines_after("*slowest*durations*") assert "4 passed" in lines[2] - def test_calls_showall(self, testdir): - testdir.makepyfile(self.source) - result = testdir.runpytest("--durations=0") + def test_calls_showall(self, pytester: Pytester, mock_timing) -> None: + pytester.makepyfile(self.source) + result = pytester.runpytest_inprocess("--durations=0") assert result.ret == 0 - for x in "23": + + tested = "3" + for x in tested: for y in ("call",): # 'setup', 'call', 'teardown': for line in result.stdout.lines: if ("test_%s" % x) in line and y in line: break else: - raise AssertionError("not found {} {}".format(x, y)) + raise AssertionError(f"not found {x} {y}") - def test_calls_showall_verbose(self, testdir): - testdir.makepyfile(self.source) - result = testdir.runpytest("--durations=0", "-vv") + def test_calls_showall_verbose(self, pytester: Pytester, mock_timing) -> None: + pytester.makepyfile(self.source) + result = pytester.runpytest_inprocess("--durations=0", "-vv") assert result.ret == 0 + for x in "123": for y in ("call",): # 'setup', 'call', 'teardown': for line in result.stdout.lines: if ("test_%s" % x) in line and y in line: break else: - raise AssertionError("not found {} {}".format(x, y)) + raise AssertionError(f"not found {x} {y}") - def test_with_deselected(self, testdir): - testdir.makepyfile(self.source) - result = testdir.runpytest("--durations=2", "-k test_2") + def test_with_deselected(self, pytester: Pytester, mock_timing) -> None: + pytester.makepyfile(self.source) + result = pytester.runpytest_inprocess("--durations=2", "-k test_3") assert result.ret == 0 - result.stdout.fnmatch_lines(["*durations*", "*call*test_2*"]) - def test_with_failing_collection(self, testdir): - testdir.makepyfile(self.source) - testdir.makepyfile(test_collecterror="""xyz""") - result = testdir.runpytest("--durations=2", "-k test_1") + result.stdout.fnmatch_lines(["*durations*", "*call*test_3*"]) + + def test_with_failing_collection(self, pytester: Pytester, mock_timing) -> None: + pytester.makepyfile(self.source) + pytester.makepyfile(test_collecterror="""xyz""") + result = pytester.runpytest_inprocess("--durations=2", "-k test_1") assert result.ret == 2 + result.stdout.fnmatch_lines(["*Interrupted: 1 error during collection*"]) # Collection errors abort test execution, therefore no duration is # output result.stdout.no_fnmatch_line("*duration*") - def test_with_not(self, testdir): - testdir.makepyfile(self.source) - result = testdir.runpytest("-k not 1") + def test_with_not(self, pytester: Pytester, mock_timing) -> None: + pytester.makepyfile(self.source) + result = pytester.runpytest_inprocess("-k not 1") assert result.ret == 0 -class TestDurationWithFixture: +class TestDurationsWithFixture: source = """ import pytest - import time - frag = 0.01 + from _pytest import timing @pytest.fixture def setup_fixt(): - time.sleep(frag) + timing.sleep(2) def test_1(setup_fixt): - time.sleep(frag) + timing.sleep(5) """ - def test_setup_function(self, testdir): - testdir.makepyfile(self.source) - result = testdir.runpytest("--durations=10") + def test_setup_function(self, pytester: Pytester, mock_timing) -> None: + pytester.makepyfile(self.source) + result = pytester.runpytest_inprocess("--durations=10") assert result.ret == 0 result.stdout.fnmatch_lines_random( """ *durations* - * setup *test_1* - * call *test_1* + 5.00s call *test_1* + 2.00s setup *test_1* """ ) -def test_zipimport_hook(testdir, tmpdir): +def test_zipimport_hook(pytester: Pytester) -> None: """Test package loader is being used correctly (see #1837).""" zipapp = pytest.importorskip("zipapp") - testdir.tmpdir.join("app").ensure(dir=1) - testdir.makepyfile( + pytester.path.joinpath("app").mkdir() + pytester.makepyfile( **{ "app/foo.py": """ import pytest @@ -1012,25 +995,27 @@ def main(): """ } ) - target = tmpdir.join("foo.zip") - zipapp.create_archive(str(testdir.tmpdir.join("app")), str(target), main="foo:main") - result = testdir.runpython(target) + target = pytester.path.joinpath("foo.zip") + zipapp.create_archive( + str(pytester.path.joinpath("app")), str(target), main="foo:main" + ) + result = pytester.runpython(target) assert result.ret == 0 result.stderr.fnmatch_lines(["*not found*foo*"]) result.stdout.no_fnmatch_line("*INTERNALERROR>*") -def test_import_plugin_unicode_name(testdir): - testdir.makepyfile(myplugin="") - testdir.makepyfile("def test(): pass") - testdir.makeconftest("pytest_plugins = ['myplugin']") - r = testdir.runpytest() +def test_import_plugin_unicode_name(pytester: Pytester) -> None: + pytester.makepyfile(myplugin="") + pytester.makepyfile("def test(): pass") + pytester.makeconftest("pytest_plugins = ['myplugin']") + r = pytester.runpytest() assert r.ret == 0 -def test_pytest_plugins_as_module(testdir): +def test_pytest_plugins_as_module(pytester: Pytester) -> None: """Do not raise an error if pytest_plugins attribute is a module (#3899)""" - testdir.makepyfile( + pytester.makepyfile( **{ "__init__.py": "", "pytest_plugins.py": "", @@ -1038,16 +1023,14 @@ def test_pytest_plugins_as_module(testdir): "test_foo.py": "def test(): pass", } ) - result = testdir.runpytest() + result = pytester.runpytest() result.stdout.fnmatch_lines(["* 1 passed in *"]) -def test_deferred_hook_checking(testdir): - """ - Check hooks as late as possible (#1821). - """ - testdir.syspathinsert() - testdir.makepyfile( +def test_deferred_hook_checking(pytester: Pytester) -> None: + """Check hooks as late as possible (#1821).""" + pytester.syspathinsert() + pytester.makepyfile( **{ "plugin.py": """ class Hooks(object): @@ -1068,15 +1051,15 @@ def test(request): """, } ) - result = testdir.runpytest() + result = pytester.runpytest() result.stdout.fnmatch_lines(["* 1 passed *"]) -def test_fixture_values_leak(testdir): +def test_fixture_values_leak(pytester: Pytester) -> None: """Ensure that fixture objects are properly destroyed by the garbage collector at the end of their expected life-times (#2981). """ - testdir.makepyfile( + pytester.makepyfile( """ import attr import gc @@ -1116,14 +1099,13 @@ def test2(): # Running on subprocess does not activate the HookRecorder # which holds itself a reference to objects in case of the # pytest_assert_reprcompare hook - result = testdir.runpytest_subprocess() + result = pytester.runpytest_subprocess() result.stdout.fnmatch_lines(["* 2 passed *"]) -def test_fixture_order_respects_scope(testdir): - """Ensure that fixtures are created according to scope order, regression test for #2405 - """ - testdir.makepyfile( +def test_fixture_order_respects_scope(pytester: Pytester) -> None: + """Ensure that fixtures are created according to scope order (#2405).""" + pytester.makepyfile( """ import pytest @@ -1142,18 +1124,19 @@ def test_value(): assert data.get('value') """ ) - result = testdir.runpytest() + result = pytester.runpytest() assert result.ret == 0 -def test_frame_leak_on_failing_test(testdir): - """pytest would leak garbage referencing the frames of tests that failed that could never be reclaimed (#2798) +def test_frame_leak_on_failing_test(pytester: Pytester) -> None: + """Pytest would leak garbage referencing the frames of tests that failed + that could never be reclaimed (#2798). Unfortunately it was not possible to remove the actual circles because most of them are made of traceback objects which cannot be weakly referenced. Those objects at least can be eventually claimed by the garbage collector. """ - testdir.makepyfile( + pytester.makepyfile( """ import gc import weakref @@ -1174,35 +1157,40 @@ def test2(): assert ref() is None """ ) - result = testdir.runpytest_subprocess() + result = pytester.runpytest_subprocess() result.stdout.fnmatch_lines(["*1 failed, 1 passed in*"]) -def test_fixture_mock_integration(testdir): +def test_fixture_mock_integration(pytester: Pytester) -> None: """Test that decorators applied to fixture are left working (#3774)""" - p = testdir.copy_example("acceptance/fixture_mock_integration.py") - result = testdir.runpytest(p) + p = pytester.copy_example("acceptance/fixture_mock_integration.py") + result = pytester.runpytest(p) result.stdout.fnmatch_lines(["*1 passed*"]) -def test_usage_error_code(testdir): - result = testdir.runpytest("-unknown-option-") +def test_usage_error_code(pytester: Pytester) -> None: + result = pytester.runpytest("-unknown-option-") assert result.ret == ExitCode.USAGE_ERROR @pytest.mark.filterwarnings("default") -def test_warn_on_async_function(testdir): - testdir.makepyfile( +def test_warn_on_async_function(pytester: Pytester) -> None: + # In the below we .close() the coroutine only to avoid + # "RuntimeWarning: coroutine 'test_2' was never awaited" + # which messes with other tests. + pytester.makepyfile( test_async=""" async def test_1(): pass async def test_2(): pass def test_3(): - return test_2() + coro = test_2() + coro.close() + return coro """ ) - result = testdir.runpytest() + result = pytester.runpytest() result.stdout.fnmatch_lines( [ "test_async.py::test_1", @@ -1219,11 +1207,8 @@ def test_3(): @pytest.mark.filterwarnings("default") -@pytest.mark.skipif( - sys.version_info < (3, 6), reason="async gen syntax available in Python 3.6+" -) -def test_warn_on_async_gen_function(testdir): - testdir.makepyfile( +def test_warn_on_async_gen_function(pytester: Pytester) -> None: + pytester.makepyfile( test_async=""" async def test_1(): yield @@ -1233,7 +1218,7 @@ def test_3(): return test_2() """ ) - result = testdir.runpytest() + result = pytester.runpytest() result.stdout.fnmatch_lines( [ "test_async.py::test_1", @@ -1249,8 +1234,8 @@ def test_3(): ) -def test_pdb_can_be_rewritten(testdir): - testdir.makepyfile( +def test_pdb_can_be_rewritten(pytester: Pytester) -> None: + pytester.makepyfile( **{ "conftest.py": """ import pytest @@ -1270,7 +1255,7 @@ def test(): ) # Disable debugging plugin itself to avoid: # > INTERNALERROR> AttributeError: module 'pdb' has no attribute 'set_trace' - result = testdir.runpytest_subprocess("-p", "no:debugging", "-vv") + result = pytester.runpytest_subprocess("-p", "no:debugging", "-vv") result.stdout.fnmatch_lines( [ " def check():", @@ -1286,8 +1271,8 @@ def test(): assert result.ret == 1 -def test_tee_stdio_captures_and_live_prints(testdir): - testpath = testdir.makepyfile( +def test_tee_stdio_captures_and_live_prints(pytester: Pytester) -> None: + testpath = pytester.makepyfile( """ import sys def test_simple(): @@ -1295,7 +1280,7 @@ def test_simple(): print ("@this is stderr@", file=sys.stderr) """ ) - result = testdir.runpytest_subprocess( + result = pytester.runpytest_subprocess( testpath, "--capture=tee-sys", "--junitxml=output.xml", @@ -1308,7 +1293,28 @@ def test_simple(): result.stderr.fnmatch_lines(["*@this is stderr@*"]) # now ensure the output is in the junitxml - with open(os.path.join(testdir.tmpdir.strpath, "output.xml"), "r") as f: + with open(pytester.path.joinpath("output.xml")) as f: fullXml = f.read() assert "@this is stdout@\n" in fullXml assert "@this is stderr@\n" in fullXml + + +@pytest.mark.skipif( + sys.platform == "win32", + reason="Windows raises `OSError: [Errno 22] Invalid argument` instead", +) +def test_no_brokenpipeerror_message(pytester: Pytester) -> None: + """Ensure that the broken pipe error message is supressed. + + In some Python versions, it reaches sys.unraisablehook, in others + a BrokenPipeError exception is propagated, but either way it prints + to stderr on shutdown, so checking nothing is printed is enough. + """ + popen = pytester.popen((*pytester._getpytestargs(), "--help")) + popen.stdout.close() + ret = popen.wait() + assert popen.stderr.read() == b"" + assert ret == 1 + + # Cleanup. + popen.stderr.close() diff --git a/testing/code/test_code.py b/testing/code/test_code.py index 826a377089b..33809528a06 100644 --- a/testing/code/test_code.py +++ b/testing/code/test_code.py @@ -1,3 +1,4 @@ +import re import sys from types import FrameType from unittest import mock @@ -6,6 +7,8 @@ from _pytest._code import Code from _pytest._code import ExceptionInfo from _pytest._code import Frame +from _pytest._code import Source +from _pytest._code.code import ExceptionChainRepr from _pytest._code.code import ReprFuncArgs @@ -25,11 +28,12 @@ def test_code_gives_back_name_for_not_existing_file() -> None: assert code.fullsource is None -def test_code_with_class() -> None: +def test_code_from_function_with_class() -> None: class A: pass - pytest.raises(TypeError, Code, A) + with pytest.raises(TypeError): + Code.from_function(A) def x() -> None: @@ -37,13 +41,13 @@ def x() -> None: def test_code_fullsource() -> None: - code = Code(x) + code = Code.from_function(x) full = code.fullsource assert "test_code_fullsource()" in str(full) def test_code_source() -> None: - code = Code(x) + code = Code.from_function(x) src = code.source() expected = """def x() -> None: raise NotImplementedError()""" @@ -66,11 +70,11 @@ def func() -> FrameType: f = Frame(func()) with mock.patch.object(f.code.__class__, "fullsource", None): - assert f.statement == "" + assert f.statement == Source("") def test_code_from_func() -> None: - co = Code(test_frame_getsourcelineno_myself) + co = Code.from_function(test_frame_getsourcelineno_myself) assert co.firstlineno assert co.path @@ -89,25 +93,25 @@ def test_code_getargs() -> None: def f1(x): raise NotImplementedError() - c1 = Code(f1) + c1 = Code.from_function(f1) assert c1.getargs(var=True) == ("x",) def f2(x, *y): raise NotImplementedError() - c2 = Code(f2) + c2 = Code.from_function(f2) assert c2.getargs(var=True) == ("x", "y") def f3(x, **z): raise NotImplementedError() - c3 = Code(f3) + c3 = Code.from_function(f3) assert c3.getargs(var=True) == ("x", "z") def f4(x, *y, **z): raise NotImplementedError() - c4 = Code(f4) + c4 = Code.from_function(f4) assert c4.getargs(var=True) == ("x", "y", "z") @@ -168,6 +172,15 @@ def test_getsource(self) -> None: assert len(source) == 6 assert "assert False" in source[5] + def test_tb_entry_str(self): + try: + assert False + except AssertionError: + exci = ExceptionInfo.from_current() + pattern = r" File '.*test_code.py':\d+ in test_tb_entry_str\n assert False" + entry = str(exci.traceback[0]) + assert re.match(pattern, entry) + class TestReprFuncArgs: def test_not_raise_exception_with_mixed_encoding(self, tw_mock) -> None: @@ -180,3 +193,20 @@ def test_not_raise_exception_with_mixed_encoding(self, tw_mock) -> None: tw_mock.lines[0] == r"unicode_string = São Paulo, utf8_string = b'S\xc3\xa3o Paulo'" ) + + +def test_ExceptionChainRepr(): + """Test ExceptionChainRepr, especially with regard to being hashable.""" + try: + raise ValueError() + except ValueError: + excinfo1 = ExceptionInfo.from_current() + excinfo2 = ExceptionInfo.from_current() + + repr1 = excinfo1.getrepr() + repr2 = excinfo2.getrepr() + assert repr1 != repr2 + + assert isinstance(repr1, ExceptionChainRepr) + assert hash(repr1) != hash(repr2) + assert repr1 is not excinfo1.getrepr() diff --git a/testing/code/test_excinfo.py b/testing/code/test_excinfo.py index 412f11edc05..5b9e3eda529 100644 --- a/testing/code/test_excinfo.py +++ b/testing/code/test_excinfo.py @@ -1,8 +1,14 @@ +import importlib +import io import operator import os import queue import sys import textwrap +from typing import Any +from typing import Dict +from typing import Tuple +from typing import TYPE_CHECKING from typing import Union import py @@ -15,12 +21,8 @@ from _pytest._io import TerminalWriter from _pytest.pytester import LineMatcher -try: - import importlib -except ImportError: - invalidate_import_caches = None -else: - invalidate_import_caches = getattr(importlib, "invalidate_caches", None) +if TYPE_CHECKING: + from _pytest._code.code import _TracebackStyle @pytest.fixture @@ -39,10 +41,11 @@ def test_excinfo_simple() -> None: assert info.type == ValueError -def test_excinfo_from_exc_info_simple(): +def test_excinfo_from_exc_info_simple() -> None: try: raise ValueError except ValueError as e: + assert e.__traceback__ is not None info = _pytest._code.ExceptionInfo.from_exc_info((type(e), e, e.__traceback__)) assert info.type == ValueError @@ -120,27 +123,31 @@ def test_traceback_entry_getsource(self): assert s.endswith("raise ValueError") def test_traceback_entry_getsource_in_construct(self): - source = _pytest._code.Source( - """\ - def xyz(): - try: - raise ValueError - except somenoname: - pass - xyz() - """ - ) + def xyz(): + try: + raise ValueError + except somenoname: # type: ignore[name-defined] # noqa: F821 + pass # pragma: no cover + try: - exec(source.compile()) + xyz() except NameError: - tb = _pytest._code.ExceptionInfo.from_current().traceback - print(tb[-1].getsource()) - s = str(tb[-1].getsource()) - assert s.startswith("def xyz():\n try:") - assert s.strip().endswith("except somenoname:") + excinfo = _pytest._code.ExceptionInfo.from_current() + else: + assert False, "did not raise NameError" + + tb = excinfo.traceback + source = tb[-1].getsource() + assert source is not None + assert source.deindent().lines == [ + "def xyz():", + " try:", + " raise ValueError", + " except somenoname: # type: ignore[name-defined] # noqa: F821", + ] def test_traceback_cut(self): - co = _pytest._code.Code(f) + co = _pytest._code.Code.from_function(f) path, firstlineno = co.path, co.firstlineno traceback = self.excinfo.traceback newtraceback = traceback.cut(path=path, firstlineno=firstlineno) @@ -193,8 +200,8 @@ def h(): excinfo = pytest.raises(ValueError, h) traceback = excinfo.traceback ntraceback = traceback.filter() - print("old: {!r}".format(traceback)) - print("new: {!r}".format(ntraceback)) + print(f"old: {traceback!r}") + print(f"new: {ntraceback!r}") if matching: assert len(ntraceback) == len(traceback) - 2 @@ -238,7 +245,7 @@ def reraise_me() -> None: def f(n: int) -> None: try: do_stuff() - except: # noqa + except BaseException: reraise_me() excinfo = pytest.raises(RuntimeError, f, 8) @@ -252,7 +259,7 @@ def test_traceback_messy_recursion(self): decorator = pytest.importorskip("decorator").decorator def log(f, *k, **kw): - print("{} {}".format(k, kw)) + print(f"{k} {kw}") f(*k, **kw) log = decorator(log) @@ -283,7 +290,7 @@ def f(): excinfo = pytest.raises(ValueError, f) tb = excinfo.traceback entry = tb.getcrashentry() - co = _pytest._code.Code(h) + co = _pytest._code.Code.from_function(h) assert entry.frame.code.path == co.path assert entry.lineno == co.firstlineno + 1 assert entry.frame.code.name == "h" @@ -300,7 +307,7 @@ def f(): excinfo = pytest.raises(ValueError, f) tb = excinfo.traceback entry = tb.getcrashentry() - co = _pytest._code.Code(g) + co = _pytest._code.Code.from_function(g) assert entry.frame.code.path == co.path assert entry.lineno == co.firstlineno + 2 assert entry.frame.code.name == "g" @@ -316,25 +323,25 @@ def test_excinfo_exconly(): assert msg.endswith("world") -def test_excinfo_repr_str(): - excinfo = pytest.raises(ValueError, h) - assert repr(excinfo) == "" - assert str(excinfo) == "" +def test_excinfo_repr_str() -> None: + excinfo1 = pytest.raises(ValueError, h) + assert repr(excinfo1) == "" + assert str(excinfo1) == "" class CustomException(Exception): def __repr__(self): return "custom_repr" - def raises(): + def raises() -> None: raise CustomException() - excinfo = pytest.raises(CustomException, raises) - assert repr(excinfo) == "" - assert str(excinfo) == "" + excinfo2 = pytest.raises(CustomException, raises) + assert repr(excinfo2) == "" + assert str(excinfo2) == "" -def test_excinfo_for_later(): - e = ExceptionInfo.for_later() +def test_excinfo_for_later() -> None: + e = ExceptionInfo[BaseException].for_later() assert "for raises" in repr(e) assert "for raises" in str(e) @@ -365,7 +372,7 @@ def test_excinfo_no_python_sourcecode(tmpdir): for item in excinfo.traceback: print(item) # XXX: for some reason jinja.Template.render is printed in full item.source # shouldn't fail - if item.path.basename == "test.txt": + if isinstance(item.path, py.path.local) and item.path.basename == "test.txt": assert str(item.source) == "{{ h()}}:" @@ -412,14 +419,14 @@ def test_division_zero(): result = testdir.runpytest() assert result.ret != 0 - exc_msg = "Pattern '[[]123[]]+' does not match 'division by zero'" - result.stdout.fnmatch_lines(["E * AssertionError: {}".format(exc_msg)]) + exc_msg = "Regex pattern '[[]123[]]+' does not match 'division by zero'." + result.stdout.fnmatch_lines([f"E * AssertionError: {exc_msg}"]) result.stdout.no_fnmatch_line("*__tracebackhide__ = True*") result = testdir.runpytest("--fulltrace") assert result.ret != 0 result.stdout.fnmatch_lines( - ["*__tracebackhide__ = True*", "E * AssertionError: {}".format(exc_msg)] + ["*__tracebackhide__ = True*", f"E * AssertionError: {exc_msg}"] ) @@ -432,22 +439,11 @@ def importasmod(source): modpath = tmpdir.join("mod.py") tmpdir.ensure("__init__.py") modpath.write(source) - if invalidate_import_caches is not None: - invalidate_import_caches() + importlib.invalidate_caches() return modpath.pyimport() return importasmod - def excinfo_from_exec(self, source): - source = _pytest._code.Source(source).strip() - try: - exec(source.compile()) - except KeyboardInterrupt: - raise - except: # noqa - return _pytest._code.ExceptionInfo.from_current() - assert 0, "did not raise" - def test_repr_source(self): pr = FormattedExcinfo() source = _pytest._code.Source( @@ -462,20 +458,31 @@ def f(x): assert lines[0] == "| def f(x):" assert lines[1] == " pass" - def test_repr_source_excinfo(self): - """ check if indentation is right """ - pr = FormattedExcinfo() - excinfo = self.excinfo_from_exec( - """ - def f(): - assert 0 - f() - """ - ) + def test_repr_source_excinfo(self) -> None: + """Check if indentation is right.""" + try: + + def f(): + 1 / 0 + + f() + + except BaseException: + excinfo = _pytest._code.ExceptionInfo.from_current() + else: + assert False, "did not raise" + pr = FormattedExcinfo() source = pr._getentrysource(excinfo.traceback[-1]) + assert source is not None lines = pr.get_source(source, 1, excinfo) - assert lines == [" def f():", "> assert 0", "E AssertionError"] + for line in lines: + print(line) + assert lines == [ + " def f():", + "> 1 / 0", + "E ZeroDivisionError: division by zero", + ] def test_repr_source_not_existing(self): pr = FormattedExcinfo() @@ -521,17 +528,18 @@ def test_repr_source_failing_fullsource(self, monkeypatch) -> None: assert repr.reprtraceback.reprentries[0].lines[0] == "> ???" assert repr.chain[0][0].reprentries[0].lines[0] == "> ???" - def test_repr_local(self): + def test_repr_local(self) -> None: p = FormattedExcinfo(showlocals=True) loc = {"y": 5, "z": 7, "x": 3, "@x": 2, "__builtins__": {}} reprlocals = p.repr_locals(loc) + assert reprlocals is not None assert reprlocals.lines assert reprlocals.lines[0] == "__builtins__ = " assert reprlocals.lines[1] == "x = 3" assert reprlocals.lines[2] == "y = 5" assert reprlocals.lines[3] == "z = 7" - def test_repr_local_with_error(self): + def test_repr_local_with_error(self) -> None: class ObjWithErrorInRepr: def __repr__(self): raise NotImplementedError @@ -539,11 +547,12 @@ def __repr__(self): p = FormattedExcinfo(showlocals=True, truncate_locals=False) loc = {"x": ObjWithErrorInRepr(), "__builtins__": {}} reprlocals = p.repr_locals(loc) + assert reprlocals is not None assert reprlocals.lines assert reprlocals.lines[0] == "__builtins__ = " assert "[NotImplementedError() raised in repr()]" in reprlocals.lines[1] - def test_repr_local_with_exception_in_class_property(self): + def test_repr_local_with_exception_in_class_property(self) -> None: class ExceptionWithBrokenClass(Exception): # Type ignored because it's bypassed intentionally. @property # type: ignore @@ -557,23 +566,26 @@ def __repr__(self): p = FormattedExcinfo(showlocals=True, truncate_locals=False) loc = {"x": ObjWithErrorInRepr(), "__builtins__": {}} reprlocals = p.repr_locals(loc) + assert reprlocals is not None assert reprlocals.lines assert reprlocals.lines[0] == "__builtins__ = " assert "[ExceptionWithBrokenClass() raised in repr()]" in reprlocals.lines[1] - def test_repr_local_truncated(self): + def test_repr_local_truncated(self) -> None: loc = {"l": [i for i in range(10)]} p = FormattedExcinfo(showlocals=True) truncated_reprlocals = p.repr_locals(loc) + assert truncated_reprlocals is not None assert truncated_reprlocals.lines assert truncated_reprlocals.lines[0] == "l = [0, 1, 2, 3, 4, 5, ...]" q = FormattedExcinfo(showlocals=True, truncate_locals=False) full_reprlocals = q.repr_locals(loc) + assert full_reprlocals is not None assert full_reprlocals.lines assert full_reprlocals.lines[0] == "l = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]" - def test_repr_tracebackentry_lines(self, importasmod): + def test_repr_tracebackentry_lines(self, importasmod) -> None: mod = importasmod( """ def func1(): @@ -601,11 +613,12 @@ def func1(): assert not lines[4:] loc = repr_entry.reprfileloc + assert loc is not None assert loc.path == mod.__file__ assert loc.lineno == 3 # assert loc.message == "ValueError: hello" - def test_repr_tracebackentry_lines2(self, importasmod, tw_mock): + def test_repr_tracebackentry_lines2(self, importasmod, tw_mock) -> None: mod = importasmod( """ def func1(m, x, y, z): @@ -617,6 +630,7 @@ def func1(m, x, y, z): entry = excinfo.traceback[-1] p = FormattedExcinfo(funcargs=True) reprfuncargs = p.repr_args(entry) + assert reprfuncargs is not None assert reprfuncargs.args[0] == ("m", repr("m" * 90)) assert reprfuncargs.args[1] == ("x", "5") assert reprfuncargs.args[2] == ("y", "13") @@ -624,13 +638,14 @@ def func1(m, x, y, z): p = FormattedExcinfo(funcargs=True) repr_entry = p.repr_traceback_entry(entry) + assert repr_entry.reprfuncargs is not None assert repr_entry.reprfuncargs.args == reprfuncargs.args repr_entry.toterminal(tw_mock) assert tw_mock.lines[0] == "m = " + repr("m" * 90) assert tw_mock.lines[1] == "x = 5, y = 13" assert tw_mock.lines[2] == "z = " + repr("z" * 120) - def test_repr_tracebackentry_lines_var_kw_args(self, importasmod, tw_mock): + def test_repr_tracebackentry_lines_var_kw_args(self, importasmod, tw_mock) -> None: mod = importasmod( """ def func1(x, *y, **z): @@ -642,17 +657,19 @@ def func1(x, *y, **z): entry = excinfo.traceback[-1] p = FormattedExcinfo(funcargs=True) reprfuncargs = p.repr_args(entry) + assert reprfuncargs is not None assert reprfuncargs.args[0] == ("x", repr("a")) assert reprfuncargs.args[1] == ("y", repr(("b",))) assert reprfuncargs.args[2] == ("z", repr({"c": "d"})) p = FormattedExcinfo(funcargs=True) repr_entry = p.repr_traceback_entry(entry) + assert repr_entry.reprfuncargs assert repr_entry.reprfuncargs.args == reprfuncargs.args repr_entry.toterminal(tw_mock) assert tw_mock.lines[0] == "x = 'a', y = ('b',), z = {'c': 'd'}" - def test_repr_tracebackentry_short(self, importasmod): + def test_repr_tracebackentry_short(self, importasmod) -> None: mod = importasmod( """ def func1(): @@ -667,6 +684,7 @@ def entry(): lines = reprtb.lines basename = py.path.local(mod.__file__).basename assert lines[0] == " func1()" + assert reprtb.reprfileloc is not None assert basename in str(reprtb.reprfileloc.path) assert reprtb.reprfileloc.lineno == 5 @@ -676,6 +694,7 @@ def entry(): lines = reprtb.lines assert lines[0] == ' raise ValueError("hello")' assert lines[1] == "E ValueError: hello" + assert reprtb.reprfileloc is not None assert basename in str(reprtb.reprfileloc.path) assert reprtb.reprfileloc.lineno == 3 @@ -715,7 +734,7 @@ def entry(): reprtb = p.repr_traceback(excinfo) assert len(reprtb.reprentries) == 3 - def test_traceback_short_no_source(self, importasmod, monkeypatch): + def test_traceback_short_no_source(self, importasmod, monkeypatch) -> None: mod = importasmod( """ def func1(): @@ -728,7 +747,6 @@ def entry(): from _pytest._code.code import Code monkeypatch.setattr(Code, "path", "bogus") - excinfo.traceback[0].frame.code.path = "bogus" p = FormattedExcinfo(style="short") reprtb = p.repr_traceback_entry(excinfo.traceback[-2]) lines = reprtb.lines @@ -741,7 +759,7 @@ def entry(): assert last_lines[0] == ' raise ValueError("hello")' assert last_lines[1] == "E ValueError: hello" - def test_repr_traceback_and_excinfo(self, importasmod): + def test_repr_traceback_and_excinfo(self, importasmod) -> None: mod = importasmod( """ def f(x): @@ -752,7 +770,8 @@ def entry(): ) excinfo = pytest.raises(ValueError, mod.entry) - for style in ("long", "short"): + styles: Tuple[_TracebackStyle, ...] = ("long", "short") + for style in styles: p = FormattedExcinfo(style=style) reprtb = p.repr_traceback(excinfo) assert len(reprtb.reprentries) == 2 @@ -764,10 +783,11 @@ def entry(): assert repr.chain[0][0] assert len(repr.chain[0][0].reprentries) == len(reprtb.reprentries) + assert repr.reprcrash is not None assert repr.reprcrash.path.endswith("mod.py") assert repr.reprcrash.message == "ValueError: 0" - def test_repr_traceback_with_invalid_cwd(self, importasmod, monkeypatch): + def test_repr_traceback_with_invalid_cwd(self, importasmod, monkeypatch) -> None: mod = importasmod( """ def f(x): @@ -786,7 +806,9 @@ def entry(): def raiseos(): nonlocal raised - if sys._getframe().f_back.f_code.co_name == "checked_call": + upframe = sys._getframe().f_back + assert upframe is not None + if upframe.f_code.co_name == "checked_call": # Only raise with expected calls, but not via e.g. inspect for # py38-windows. raised += 1 @@ -804,14 +826,14 @@ def raiseos(): "def entry():", "> f(0)", "", - "{}:5: ".format(mod.__file__), + f"{mod.__file__}:5: ", "_ _ *", "", " def f(x):", "> raise ValueError(x)", "E ValueError: 0", "", - "{}:3: ValueError".format(mod.__file__), + f"{mod.__file__}:3: ValueError", ] ) assert raised == 3 @@ -830,7 +852,7 @@ def entry(): assert tw_mock.lines[-1] == "content" assert tw_mock.lines[-2] == ("-", "title") - def test_repr_excinfo_reprcrash(self, importasmod): + def test_repr_excinfo_reprcrash(self, importasmod) -> None: mod = importasmod( """ def entry(): @@ -839,6 +861,7 @@ def entry(): ) excinfo = pytest.raises(ValueError, mod.entry) repr = excinfo.getrepr() + assert repr.reprcrash is not None assert repr.reprcrash.path.endswith("mod.py") assert repr.reprcrash.lineno == 3 assert repr.reprcrash.message == "ValueError" @@ -863,7 +886,7 @@ def entry(): assert reprtb.extraline == "!!! Recursion detected (same locals & position)" assert str(reprtb) - def test_reprexcinfo_getrepr(self, importasmod): + def test_reprexcinfo_getrepr(self, importasmod) -> None: mod = importasmod( """ def f(x): @@ -874,14 +897,15 @@ def entry(): ) excinfo = pytest.raises(ValueError, mod.entry) - for style in ("short", "long", "no"): + styles: Tuple[_TracebackStyle, ...] = ("short", "long", "no") + for style in styles: for showlocals in (True, False): repr = excinfo.getrepr(style=style, showlocals=showlocals) assert repr.reprtraceback.style == style assert isinstance(repr, ExceptionChainRepr) - for repr in repr.chain: - assert repr[0].style == style + for r in repr.chain: + assert r[0].style == style def test_reprexcinfo_unicode(self): from _pytest._code.code import TerminalRepr @@ -1015,32 +1039,39 @@ def f(): @pytest.mark.parametrize( "reproptions", [ - { - "style": style, - "showlocals": showlocals, - "funcargs": funcargs, - "tbfilter": tbfilter, - } - for style in ("long", "short", "no") + pytest.param( + { + "style": style, + "showlocals": showlocals, + "funcargs": funcargs, + "tbfilter": tbfilter, + }, + id="style={},showlocals={},funcargs={},tbfilter={}".format( + style, showlocals, funcargs, tbfilter + ), + ) + for style in ["long", "short", "line", "no", "native", "value", "auto"] for showlocals in (True, False) for tbfilter in (True, False) for funcargs in (True, False) ], ) - def test_format_excinfo(self, importasmod, reproptions): - mod = importasmod( - """ - def g(x): - raise ValueError(x) - def f(): - g(3) - """ - ) - excinfo = pytest.raises(ValueError, mod.f) - tw = TerminalWriter(stringio=True) + def test_format_excinfo(self, reproptions: Dict[str, Any]) -> None: + def bar(): + assert False, "some error" + + def foo(): + bar() + + # using inline functions as opposed to importasmod so we get source code lines + # in the tracebacks (otherwise getinspect doesn't find the source code). + with pytest.raises(AssertionError) as excinfo: + foo() + file = io.StringIO() + tw = TerminalWriter(file=file) repr = excinfo.getrepr(**reproptions) repr.toterminal(tw) - assert tw.stringio.getvalue() + assert file.getvalue() def test_traceback_repr_style(self, importasmod, tw_mock): mod = importasmod( @@ -1255,11 +1286,12 @@ def g(): getattr(excinfo.value, attr).__traceback__ = None r = excinfo.getrepr() - tw = TerminalWriter(stringio=True) + file = io.StringIO() + tw = TerminalWriter(file=file) tw.hasmarkup = False r.toterminal(tw) - matcher = LineMatcher(tw.stringio.getvalue().splitlines()) + matcher = LineMatcher(file.getvalue().splitlines()) matcher.fnmatch_lines( [ "ValueError: invalid value", @@ -1312,12 +1344,25 @@ def unreraise(): ) assert out == expected_out + def test_exec_type_error_filter(self, importasmod): + """See #7742""" + mod = importasmod( + """\ + def f(): + exec("a = 1", {}, []) + """ + ) + with pytest.raises(TypeError) as excinfo: + mod.f() + # previously crashed with `AttributeError: list has no attribute get` + excinfo.traceback.filter() + @pytest.mark.parametrize("style", ["short", "long"]) @pytest.mark.parametrize("encoding", [None, "utf8", "utf16"]) def test_repr_traceback_with_unicode(style, encoding): if encoding is None: - msg = "☹" # type: Union[str, bytes] + msg: Union[str, bytes] = "☹" else: msg = "☹".encode(encoding) try: diff --git a/testing/code/test_source.py b/testing/code/test_source.py index cf09309744a..04d0ea9323d 100644 --- a/testing/code/test_source.py +++ b/testing/code/test_source.py @@ -3,7 +3,9 @@ # or redundant on purpose and can't be disable on a line-by-line basis import ast import inspect +import linecache import sys +import textwrap from types import CodeType from typing import Any from typing import Dict @@ -11,8 +13,9 @@ import py.path -import _pytest._code import pytest +from _pytest._code import Code +from _pytest._code import Frame from _pytest._code import getfslineno from _pytest._code import Source @@ -32,16 +35,8 @@ def test_source_str_function() -> None: assert str(x) == "\n3" -def test_unicode() -> None: - x = Source("4") - assert str(x) == "4" - co = _pytest._code.compile('"å"', mode="eval") - val = eval(co) - assert isinstance(val, str) - - def test_source_from_function() -> None: - source = _pytest._code.Source(test_source_str_function) + source = Source(test_source_str_function) assert str(source).startswith("def test_source_str_function() -> None:") @@ -50,59 +45,24 @@ class TestClass: def test_method(self): pass - source = _pytest._code.Source(TestClass().test_method) + source = Source(TestClass().test_method) assert source.lines == ["def test_method(self):", " pass"] def test_source_from_lines() -> None: lines = ["a \n", "b\n", "c"] - source = _pytest._code.Source(lines) + source = Source(lines) assert source.lines == ["a ", "b", "c"] def test_source_from_inner_function() -> None: def f(): - pass + raise NotImplementedError() - source = _pytest._code.Source(f, deindent=False) - assert str(source).startswith(" def f():") - source = _pytest._code.Source(f) + source = Source(f) assert str(source).startswith("def f():") -def test_source_putaround_simple() -> None: - source = Source("raise ValueError") - source = source.putaround( - "try:", - """\ - except ValueError: - x = 42 - else: - x = 23""", - ) - assert ( - str(source) - == """\ -try: - raise ValueError -except ValueError: - x = 42 -else: - x = 23""" - ) - - -def test_source_putaround() -> None: - source = Source() - source = source.putaround( - """ - if 1: - x=1 - """ - ) - assert str(source).strip() == "if 1:\n x=1" - - def test_source_strips() -> None: source = Source("") assert source == Source() @@ -117,23 +77,6 @@ def test_source_strip_multiline() -> None: assert source2.lines == [" hello"] -def test_syntaxerror_rerepresentation() -> None: - ex = pytest.raises(SyntaxError, _pytest._code.compile, "xyz xyz") - assert ex is not None - assert ex.value.lineno == 1 - assert ex.value.offset in {5, 7} # cpython: 7, pypy3.6 7.1.1: 5 - assert ex.value.text == "xyz xyz\n" - - -def test_isparseable() -> None: - assert Source("hello").isparseable() - assert Source("if 1:\n pass").isparseable() - assert Source(" \nif 1:\n pass").isparseable() - assert not Source("if 1:\n").isparseable() - assert not Source(" \nif 1:\npass").isparseable() - assert not Source(chr(0)).isparseable() - - class TestAccesses: def setup_class(self) -> None: self.source = Source( @@ -147,7 +90,6 @@ def g(x): def test_getrange(self) -> None: x = self.source[0:2] - assert x.isparseable() assert len(x.lines) == 2 assert str(x) == "def f(x):\n pass" @@ -167,7 +109,7 @@ def test_iter(self) -> None: assert len(values) == 4 -class TestSourceParsingAndCompiling: +class TestSourceParsing: def setup_class(self) -> None: self.source = Source( """\ @@ -178,39 +120,6 @@ def f(x): """ ).strip() - def test_compile(self) -> None: - co = _pytest._code.compile("x=3") - d = {} # type: Dict[str, Any] - exec(co, d) - assert d["x"] == 3 - - def test_compile_and_getsource_simple(self) -> None: - co = _pytest._code.compile("x=3") - exec(co) - source = _pytest._code.Source(co) - assert str(source) == "x=3" - - def test_compile_and_getsource_through_same_function(self) -> None: - def gensource(source): - return _pytest._code.compile(source) - - co1 = gensource( - """ - def f(): - raise KeyError() - """ - ) - co2 = gensource( - """ - def f(): - raise ValueError() - """ - ) - source1 = inspect.getsource(co1) - assert "KeyError" in source1 - source2 = inspect.getsource(co2) - assert "ValueError" in source2 - def test_getstatement(self) -> None: # print str(self.source) ass = str(self.source[1:]) @@ -227,9 +136,9 @@ def test_getstatementrange_triple_quoted(self) -> None: ''')""" ) s = source.getstatement(0) - assert s == str(source) + assert s == source s = source.getstatement(1) - assert s == str(source) + assert s == source def test_getstatementrange_within_constructs(self) -> None: source = Source( @@ -307,50 +216,12 @@ def test_getstatementrange_with_syntaxerror_issue7(self) -> None: source = Source(":") pytest.raises(SyntaxError, lambda: source.getstatementrange(0)) - def test_compile_to_ast(self) -> None: - source = Source("x = 4") - mod = source.compile(flag=ast.PyCF_ONLY_AST) - assert isinstance(mod, ast.Module) - compile(mod, "", "exec") - - def test_compile_and_getsource(self) -> None: - co = self.source.compile() - exec(co, globals()) - f(7) # type: ignore - excinfo = pytest.raises(AssertionError, f, 6) # type: ignore - assert excinfo is not None - frame = excinfo.traceback[-1].frame - assert isinstance(frame.code.fullsource, Source) - stmt = frame.code.fullsource.getstatement(frame.lineno) - assert str(stmt).strip().startswith("assert") - - @pytest.mark.parametrize("name", ["", None, "my"]) - def test_compilefuncs_and_path_sanity(self, name: Optional[str]) -> None: - def check(comp, name) -> None: - co = comp(self.source, name) - if not name: - expected = "codegen %s:%d>" % (mypath, mylineno + 2 + 2) # type: ignore - else: - expected = "codegen %r %s:%d>" % (name, mypath, mylineno + 2 + 2) # type: ignore - fn = co.co_filename - assert fn.endswith(expected) - - mycode = _pytest._code.Code(self.test_compilefuncs_and_path_sanity) - mylineno = mycode.firstlineno - mypath = mycode.path - - for comp in _pytest._code.compile, _pytest._code.Source.compile: - check(comp, name) - - def test_offsetless_synerr(self): - pytest.raises(SyntaxError, _pytest._code.compile, "lambda a,a: 0", mode="eval") - def test_getstartingblock_singleline() -> None: class A: def __init__(self, *args) -> None: frame = sys._getframe(1) - self.source = _pytest._code.Frame(frame).statement + self.source = Frame(frame).statement x = A("x", "y") @@ -368,24 +239,22 @@ def c() -> None: c(1) # type: ignore finally: if teardown: - teardown() + teardown() # type: ignore[unreachable] source = excinfo.traceback[-1].statement assert str(source).strip() == "c(1) # type: ignore" def test_getfuncsource_dynamic() -> None: - source = """ - def f(): - raise ValueError + def f(): + raise NotImplementedError() - def g(): pass - """ - co = _pytest._code.compile(source) - exec(co, globals()) - f_source = _pytest._code.Source(f) # type: ignore - g_source = _pytest._code.Source(g) # type: ignore - assert str(f_source).strip() == "def f():\n raise ValueError" - assert str(g_source).strip() == "def g(): pass" + def g(): + pass # pragma: no cover + + f_source = Source(f) + g_source = Source(g) + assert str(f_source).strip() == "def f():\n raise NotImplementedError()" + assert str(g_source).strip() == "def g():\n pass # pragma: no cover" def test_getfuncsource_with_multine_string() -> None: @@ -400,7 +269,7 @@ def f(): pass """ ''' - assert str(_pytest._code.Source(f)) == expected.rstrip() + assert str(Source(f)) == expected.rstrip() def test_deindent() -> None: @@ -420,16 +289,16 @@ def g(): def test_source_of_class_at_eof_without_newline(tmpdir, _sys_snapshot) -> None: # this test fails because the implicit inspect.getsource(A) below # does not return the "x = 1" last line. - source = _pytest._code.Source( + source = Source( """ - class A(object): + class A: def method(self): x = 1 """ ) path = tmpdir.join("a.py") path.write(source) - s2 = _pytest._code.Source(tmpdir.join("a.py").pyimport().A) + s2 = Source(tmpdir.join("a.py").pyimport().A) assert str(source).strip() == str(s2).strip() @@ -439,30 +308,11 @@ def x(): pass -def test_getsource_fallback() -> None: - from _pytest._code.source import getsource - +def test_source_fallback() -> None: + src = Source(x) expected = """def x(): pass""" - src = getsource(x) - assert src == expected - - -def test_idem_compile_and_getsource() -> None: - from _pytest._code.source import getsource - - expected = "def x(): pass" - co = _pytest._code.compile(expected) - src = getsource(co) - assert src == expected - - -def test_compile_ast() -> None: - # We don't necessarily want to support this. - # This test was added just for coverage. - stmt = ast.parse("def x(): pass") - co = _pytest._code.compile(stmt, filename="foo.py") - assert isinstance(co, CodeType) + assert str(src) == expected def test_findsource_fallback() -> None: @@ -474,21 +324,21 @@ def test_findsource_fallback() -> None: assert src[lineno] == " def x():" -def test_findsource() -> None: +def test_findsource(monkeypatch) -> None: from _pytest._code.source import findsource - co = _pytest._code.compile( - """if 1: - def x(): - pass -""" - ) + filename = "" + lines = ["if 1:\n", " def x():\n", " pass\n"] + co = compile("".join(lines), filename, "exec") + + # Type ignored because linecache.cache is private. + monkeypatch.setitem(linecache.cache, filename, (1, None, lines, filename)) # type: ignore[attr-defined] src, lineno = findsource(co) assert src is not None assert "if 1:" in str(src) - d = {} # type: Dict[str, Any] + d: Dict[str, Any] = {} eval(co, d) src, lineno = findsource(d["x"]) assert src is not None @@ -521,42 +371,34 @@ class A: class B: pass - B.__name__ = "B2" + B.__name__ = B.__qualname__ = "B2" assert getfslineno(B)[1] == -1 - co = compile("...", "", "eval") - assert co.co_filename == "" - - if hasattr(sys, "pypy_version_info"): - assert getfslineno(co) == ("", -1) - else: - assert getfslineno(co) == ("", 0) - def test_code_of_object_instance_with_call() -> None: class A: pass - pytest.raises(TypeError, lambda: _pytest._code.Source(A())) + pytest.raises(TypeError, lambda: Source(A())) class WithCall: def __call__(self) -> None: pass - code = _pytest._code.Code(WithCall()) + code = Code.from_function(WithCall()) assert "pass" in str(code.source()) class Hello: def __call__(self) -> None: pass - pytest.raises(TypeError, lambda: _pytest._code.Code(Hello)) + pytest.raises(TypeError, lambda: Code.from_function(Hello)) def getstatement(lineno: int, source) -> Source: from _pytest._code.source import getstatementrange_ast - src = _pytest._code.Source(source, deindent=False) + src = Source(source) ast, start, end = getstatementrange_ast(lineno, src) return src[start:end] @@ -623,6 +465,33 @@ def test_comment_in_statement() -> None: ) +def test_source_with_decorator() -> None: + """Test behavior with Source / Code().source with regard to decorators.""" + from _pytest.compat import get_real_func + + @pytest.mark.foo + def deco_mark(): + assert False + + src = inspect.getsource(deco_mark) + assert textwrap.indent(str(Source(deco_mark)), " ") + "\n" == src + assert src.startswith(" @pytest.mark.foo") + + @pytest.fixture + def deco_fixture(): + assert False + + src = inspect.getsource(deco_fixture) + assert src == " @pytest.fixture\n def deco_fixture():\n assert False\n" + # currenly Source does not unwrap decorators, testing the + # existing behavior here for explicitness, but perhaps we should revisit/change this + # in the future + assert str(Source(deco_fixture)).startswith("@functools.wraps(function)") + assert ( + textwrap.indent(str(Source(get_real_func(deco_fixture))), " ") + "\n" == src + ) + + def test_single_line_else() -> None: source = getstatement(1, "if False: 2\nelse: 3") assert str(source) == "else: 3" @@ -761,7 +630,7 @@ def test_getstartingblock_multiline() -> None: class A: def __init__(self, *args): frame = sys._getframe(1) - self.source = _pytest._code.Frame(frame).statement + self.source = Frame(frame).statement # fmt: off x = A('x', diff --git a/testing/code/test_terminal_writer.py b/testing/code/test_terminal_writer.py deleted file mode 100644 index 01da3c23500..00000000000 --- a/testing/code/test_terminal_writer.py +++ /dev/null @@ -1,28 +0,0 @@ -import re -from io import StringIO - -import pytest -from _pytest._io import TerminalWriter - - -@pytest.mark.parametrize( - "has_markup, expected", - [ - pytest.param( - True, "{kw}assert{hl-reset} {number}0{hl-reset}\n", id="with markup" - ), - pytest.param(False, "assert 0\n", id="no markup"), - ], -) -def test_code_highlight(has_markup, expected, color_mapping): - f = StringIO() - tw = TerminalWriter(f) - tw.hasmarkup = has_markup - tw._write_source(["assert 0"]) - assert f.getvalue().splitlines(keepends=True) == color_mapping.format([expected]) - - with pytest.raises( - ValueError, - match=re.escape("indents size (2) should have same size as lines (1)"), - ): - tw._write_source(["assert 0"], [" ", " "]) diff --git a/testing/conftest.py b/testing/conftest.py index 90cdcb869fd..2dc20bcb2fd 100644 --- a/testing/conftest.py +++ b/testing/conftest.py @@ -3,8 +3,8 @@ from typing import List import pytest -from _pytest.pytester import RunResult -from _pytest.pytester import Testdir +from _pytest.monkeypatch import MonkeyPatch +from _pytest.pytester import Pytester if sys.gettrace(): @@ -42,7 +42,7 @@ def pytest_collection_modifyitems(items): # (https://github.com/pytest-dev/pytest/issues/5070) neutral_items.append(item) else: - if "testdir" in fixtures: + if "pytester" in fixtures: co_names = item.function.__code__.co_names if spawn_names.intersection(co_names): item.add_marker(pytest.mark.uses_pexpect) @@ -104,36 +104,36 @@ def get_write_msg(self, idx): @pytest.fixture -def dummy_yaml_custom_test(testdir): +def dummy_yaml_custom_test(pytester: Pytester): """Writes a conftest file that collects and executes a dummy yaml test. Taken from the docs, but stripped down to the bare minimum, useful for tests which needs custom items collected. """ - testdir.makeconftest( + pytester.makeconftest( """ import pytest def pytest_collect_file(parent, path): if path.ext == ".yaml" and path.basename.startswith("test"): - return YamlFile(path, parent) + return YamlFile.from_parent(fspath=path, parent=parent) class YamlFile(pytest.File): def collect(self): - yield YamlItem(self.fspath.basename, self) + yield YamlItem.from_parent(name=self.fspath.basename, parent=self) class YamlItem(pytest.Item): def runtest(self): pass """ ) - testdir.makefile(".yaml", test1="") + pytester.makefile(".yaml", test1="") @pytest.fixture -def testdir(testdir: Testdir) -> Testdir: - testdir.monkeypatch.setenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD", "1") - return testdir +def pytester(pytester: Pytester, monkeypatch: MonkeyPatch) -> Pytester: + monkeypatch.setenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD", "1") + return pytester @pytest.fixture(scope="session") @@ -175,23 +175,42 @@ def format_for_rematch(cls, lines: List[str]) -> List[str]: """Replace color names for use with LineMatcher.re_match_lines""" return [line.format(**cls.RE_COLORS) for line in lines] - @classmethod - def requires_ordered_markup(cls, result: RunResult): - """Should be called if a test expects markup to appear in the output - in the order they were passed, for example: - - tw.write(line, bold=True, red=True) - - In Python 3.5 there's no guarantee that the generated markup will appear - in the order called, so we do some limited color testing and skip the rest of - the test. - """ - if sys.version_info < (3, 6): - # terminal writer.write accepts keyword arguments, so - # py36+ is required so the markup appears in the expected order - output = result.stdout.str() - assert "test session starts" in output - assert "\x1b[1m" in output - pytest.skip("doing limited testing because lacking ordered markup") - return ColorMapping + + +@pytest.fixture +def mock_timing(monkeypatch: MonkeyPatch): + """Mocks _pytest.timing with a known object that can be used to control timing in tests + deterministically. + + pytest itself should always use functions from `_pytest.timing` instead of `time` directly. + + This then allows us more control over time during testing, if testing code also + uses `_pytest.timing` functions. + + Time is static, and only advances through `sleep` calls, thus tests might sleep over large + numbers and obtain accurate time() calls at the end, making tests reliable and instant. + """ + import attr + + @attr.s + class MockTiming: + + _current_time = attr.ib(default=1590150050.0) + + def sleep(self, seconds): + self._current_time += seconds + + def time(self): + return self._current_time + + def patch(self): + from _pytest import timing + + monkeypatch.setattr(timing, "sleep", self.sleep) + monkeypatch.setattr(timing, "time", self.time) + monkeypatch.setattr(timing, "perf_counter", self.time) + + result = MockTiming() + result.patch() + return result diff --git a/testing/deprecated_test.py b/testing/deprecated_test.py index b5c66d9f5f1..d213414ee45 100644 --- a/testing/deprecated_test.py +++ b/testing/deprecated_test.py @@ -1,43 +1,18 @@ -import inspect +import re +import warnings +from unittest import mock import pytest from _pytest import deprecated -from _pytest import nodes +from _pytest.pytester import Pytester +from _pytest.pytester import Testdir -@pytest.mark.filterwarnings("default") -def test_resultlog_is_deprecated(testdir): - result = testdir.runpytest("--help") - result.stdout.fnmatch_lines(["*DEPRECATED path for machine-readable result log*"]) - - testdir.makepyfile( - """ - def test(): - pass - """ - ) - result = testdir.runpytest("--result-log=%s" % testdir.tmpdir.join("result.log")) - result.stdout.fnmatch_lines( - [ - "*--result-log is deprecated, please try the new pytest-reportlog plugin.", - "*See https://docs.pytest.org/en/latest/deprecations.html#result-log-result-log for more information*", - ] - ) - - -def test_terminal_reporter_writer_attr(pytestconfig): - """Check that TerminalReporter._tw is also available as 'writer' (#2984) - This attribute has been deprecated in 5.4. - """ - try: - import xdist # noqa - - pytest.skip("xdist workers disable the terminal reporter plugin") - except ImportError: - pass - terminal_reporter = pytestconfig.pluginmanager.get_plugin("terminalreporter") - with pytest.warns(pytest.PytestDeprecationWarning): - assert terminal_reporter.writer is terminal_reporter._tw +@pytest.mark.parametrize("attribute", pytest.collect.__all__) # type: ignore +# false positive due to dynamic attribute +def test_pytest_collect_module_deprecated(attribute): + with pytest.warns(DeprecationWarning, match=attribute): + getattr(pytest.collect, attribute) @pytest.mark.parametrize("plugin", sorted(deprecated.DEPRECATED_EXTERNAL_PLUGINS)) @@ -50,85 +25,115 @@ def test_external_plugins_integrated(testdir, plugin): testdir.parseconfig("-p", plugin) -@pytest.mark.parametrize("junit_family", [None, "legacy", "xunit2"]) -def test_warn_about_imminent_junit_family_default_change(testdir, junit_family): - """Show a warning if junit_family is not defined and --junitxml is used (#6179)""" - testdir.makepyfile( - """ - def test_foo(): - pass - """ - ) - if junit_family: - testdir.makeini( - """ - [pytest] - junit_family={junit_family} - """.format( - junit_family=junit_family - ) - ) - - result = testdir.runpytest("--junit-xml=foo.xml") - warning_msg = ( - "*PytestDeprecationWarning: The 'junit_family' default value will change*" - ) - if junit_family: - result.stdout.no_fnmatch_line(warning_msg) - else: - result.stdout.fnmatch_lines([warning_msg]) +def test_fillfuncargs_is_deprecated() -> None: + with pytest.warns( + pytest.PytestDeprecationWarning, + match=re.escape( + "pytest._fillfuncargs() is deprecated, use " + "function._request._fillfixtures() instead if you cannot avoid reaching into internals." + ), + ): + pytest._fillfuncargs(mock.Mock()) -def test_node_direct_ctor_warning(): - class MockConfig: - pass +def test_fillfixtures_is_deprecated() -> None: + import _pytest.fixtures - ms = MockConfig() with pytest.warns( - DeprecationWarning, - match="direct construction of .* has been deprecated, please use .*.from_parent", - ) as w: - nodes.Node(name="test", config=ms, session=ms, nodeid="None") - assert w[0].lineno == inspect.currentframe().f_lineno - 1 - assert w[0].filename == __file__ + pytest.PytestDeprecationWarning, + match=re.escape( + "_pytest.fixtures.fillfixtures() is deprecated, use " + "function._request._fillfixtures() instead if you cannot avoid reaching into internals." + ), + ): + _pytest.fixtures.fillfixtures(mock.Mock()) + + +def test_minus_k_dash_is_deprecated(testdir) -> None: + threepass = testdir.makepyfile( + test_threepass=""" + def test_one(): assert 1 + def test_two(): assert 1 + def test_three(): assert 1 + """ + ) + result = testdir.runpytest("-k=-test_two", threepass) + result.stdout.fnmatch_lines(["*The `-k '-expr'` syntax*deprecated*"]) -def assert_no_print_logs(testdir, args): - result = testdir.runpytest(*args) - result.stdout.fnmatch_lines( - [ - "*--no-print-logs is deprecated and scheduled for removal in pytest 6.0*", - "*Please use --show-capture instead.*", - ] +def test_minus_k_colon_is_deprecated(testdir) -> None: + threepass = testdir.makepyfile( + test_threepass=""" + def test_one(): assert 1 + def test_two(): assert 1 + def test_three(): assert 1 + """ ) + result = testdir.runpytest("-k", "test_two:", threepass) + result.stdout.fnmatch_lines(["*The `-k 'expr:'` syntax*deprecated*"]) -@pytest.mark.filterwarnings("default") -def test_noprintlogs_is_deprecated_cmdline(testdir): - testdir.makepyfile( - """ - def test_foo(): - pass +def test_fscollector_gethookproxy_isinitpath(testdir: Testdir) -> None: + module = testdir.getmodulecol( """ + def test_foo(): pass + """, + withinit=True, ) + assert isinstance(module, pytest.Module) + package = module.parent + assert isinstance(package, pytest.Package) - assert_no_print_logs(testdir, ("--no-print-logs",)) + with pytest.warns(pytest.PytestDeprecationWarning, match="gethookproxy"): + package.gethookproxy(testdir.tmpdir) + with pytest.warns(pytest.PytestDeprecationWarning, match="isinitpath"): + package.isinitpath(testdir.tmpdir) + + # The methods on Session are *not* deprecated. + session = module.session + with warnings.catch_warnings(record=True) as rec: + session.gethookproxy(testdir.tmpdir) + session.isinitpath(testdir.tmpdir) + assert len(rec) == 0 -@pytest.mark.filterwarnings("default") -def test_noprintlogs_is_deprecated_ini(testdir): - testdir.makeini( - """ - [pytest] - log_print=False - """ - ) - testdir.makepyfile( +def test_strict_option_is_deprecated(pytester: Pytester) -> None: + """--strict is a deprecated alias to --strict-markers (#7530).""" + pytester.makepyfile( """ - def test_foo(): - pass + import pytest + + @pytest.mark.unknown + def test_foo(): pass """ ) + result = pytester.runpytest("--strict") + result.stdout.fnmatch_lines( + [ + "'unknown' not found in `markers` configuration option", + "*PytestDeprecationWarning: The --strict option is deprecated, use --strict-markers instead.", + ] + ) + + +def test_yield_fixture_is_deprecated() -> None: + with pytest.warns(DeprecationWarning, match=r"yield_fixture is deprecated"): + + @pytest.yield_fixture + def fix(): + assert False + + +def test_private_is_deprecated() -> None: + class PrivateInit: + def __init__(self, foo: int, *, _ispytest: bool = False) -> None: + deprecated.check_ispytest(_ispytest) + + with pytest.warns( + pytest.PytestDeprecationWarning, match="private pytest class or function" + ): + PrivateInit(10) - assert_no_print_logs(testdir, ()) + # Doesn't warn. + PrivateInit(10, _ispytest=True) diff --git a/testing/example_scripts/dataclasses/test_compare_dataclasses.py b/testing/example_scripts/dataclasses/test_compare_dataclasses.py index 82a685c6314..d96c90a91bd 100644 --- a/testing/example_scripts/dataclasses/test_compare_dataclasses.py +++ b/testing/example_scripts/dataclasses/test_compare_dataclasses.py @@ -2,11 +2,11 @@ from dataclasses import field -def test_dataclasses(): +def test_dataclasses() -> None: @dataclass class SimpleDataObject: field_a: int = field() - field_b: int = field() + field_b: str = field() left = SimpleDataObject(1, "b") right = SimpleDataObject(1, "c") diff --git a/testing/example_scripts/dataclasses/test_compare_dataclasses_field_comparison_off.py b/testing/example_scripts/dataclasses/test_compare_dataclasses_field_comparison_off.py index fa89e4a2044..7479c66c1be 100644 --- a/testing/example_scripts/dataclasses/test_compare_dataclasses_field_comparison_off.py +++ b/testing/example_scripts/dataclasses/test_compare_dataclasses_field_comparison_off.py @@ -2,11 +2,11 @@ from dataclasses import field -def test_dataclasses_with_attribute_comparison_off(): +def test_dataclasses_with_attribute_comparison_off() -> None: @dataclass class SimpleDataObject: field_a: int = field() - field_b: int = field(compare=False) + field_b: str = field(compare=False) left = SimpleDataObject(1, "b") right = SimpleDataObject(1, "c") diff --git a/testing/example_scripts/dataclasses/test_compare_dataclasses_verbose.py b/testing/example_scripts/dataclasses/test_compare_dataclasses_verbose.py index 06634565b16..4737ef904e0 100644 --- a/testing/example_scripts/dataclasses/test_compare_dataclasses_verbose.py +++ b/testing/example_scripts/dataclasses/test_compare_dataclasses_verbose.py @@ -2,11 +2,11 @@ from dataclasses import field -def test_dataclasses_verbose(): +def test_dataclasses_verbose() -> None: @dataclass class SimpleDataObject: field_a: int = field() - field_b: int = field() + field_b: str = field() left = SimpleDataObject(1, "b") right = SimpleDataObject(1, "c") diff --git a/testing/example_scripts/dataclasses/test_compare_recursive_dataclasses.py b/testing/example_scripts/dataclasses/test_compare_recursive_dataclasses.py new file mode 100644 index 00000000000..167140e16a6 --- /dev/null +++ b/testing/example_scripts/dataclasses/test_compare_recursive_dataclasses.py @@ -0,0 +1,38 @@ +from dataclasses import dataclass + + +@dataclass +class S: + a: int + b: str + + +@dataclass +class C: + c: S + d: S + + +@dataclass +class C2: + e: C + f: S + + +@dataclass +class C3: + g: S + h: C2 + i: str + j: str + + +def test_recursive_dataclasses(): + left = C3( + S(10, "ten"), C2(C(S(1, "one"), S(2, "two")), S(2, "three")), "equal", "left", + ) + right = C3( + S(20, "xxx"), C2(C(S(1, "one"), S(2, "yyy")), S(3, "three")), "equal", "right", + ) + + assert left == right diff --git a/testing/example_scripts/dataclasses/test_compare_two_different_dataclasses.py b/testing/example_scripts/dataclasses/test_compare_two_different_dataclasses.py index 4c638e1fcd6..0a4820c69ba 100644 --- a/testing/example_scripts/dataclasses/test_compare_two_different_dataclasses.py +++ b/testing/example_scripts/dataclasses/test_compare_two_different_dataclasses.py @@ -2,18 +2,18 @@ from dataclasses import field -def test_comparing_two_different_data_classes(): +def test_comparing_two_different_data_classes() -> None: @dataclass class SimpleDataObjectOne: field_a: int = field() - field_b: int = field() + field_b: str = field() @dataclass class SimpleDataObjectTwo: field_a: int = field() - field_b: int = field() + field_b: str = field() left = SimpleDataObjectOne(1, "b") right = SimpleDataObjectTwo(1, "c") - assert left != right + assert left != right # type: ignore[comparison-overlap] diff --git a/testing/example_scripts/fixtures/custom_item/conftest.py b/testing/example_scripts/fixtures/custom_item/conftest.py index 25299d72690..161934b58f7 100644 --- a/testing/example_scripts/fixtures/custom_item/conftest.py +++ b/testing/example_scripts/fixtures/custom_item/conftest.py @@ -1,10 +1,15 @@ import pytest -class CustomItem(pytest.Item, pytest.File): +class CustomItem(pytest.Item): def runtest(self): pass +class CustomFile(pytest.File): + def collect(self): + yield CustomItem.from_parent(name="foo", parent=self) + + def pytest_collect_file(path, parent): - return CustomItem(path, parent) + return CustomFile.from_parent(fspath=path, parent=parent) diff --git a/testing/example_scripts/fixtures/fill_fixtures/test_conftest_funcargs_only_available_in_subdir/sub1/conftest.py b/testing/example_scripts/fixtures/fill_fixtures/test_conftest_funcargs_only_available_in_subdir/sub1/conftest.py index 79af4bc4790..be5adbeb6e5 100644 --- a/testing/example_scripts/fixtures/fill_fixtures/test_conftest_funcargs_only_available_in_subdir/sub1/conftest.py +++ b/testing/example_scripts/fixtures/fill_fixtures/test_conftest_funcargs_only_available_in_subdir/sub1/conftest.py @@ -3,5 +3,5 @@ @pytest.fixture def arg1(request): - with pytest.raises(Exception): + with pytest.raises(pytest.FixtureLookupError): request.getfixturevalue("arg2") diff --git a/testing/example_scripts/issue88_initial_file_multinodes/conftest.py b/testing/example_scripts/issue88_initial_file_multinodes/conftest.py index aa5d878313c..a053a638a9f 100644 --- a/testing/example_scripts/issue88_initial_file_multinodes/conftest.py +++ b/testing/example_scripts/issue88_initial_file_multinodes/conftest.py @@ -3,11 +3,11 @@ class MyFile(pytest.File): def collect(self): - return [MyItem("hello", parent=self)] + return [MyItem.from_parent(name="hello", parent=self)] def pytest_collect_file(path, parent): - return MyFile(path, parent) + return MyFile.from_parent(fspath=path, parent=parent) class MyItem(pytest.Item): diff --git a/testing/example_scripts/issue_519.py b/testing/example_scripts/issue_519.py index 7199df820fb..3928294886f 100644 --- a/testing/example_scripts/issue_519.py +++ b/testing/example_scripts/issue_519.py @@ -1,4 +1,6 @@ import pprint +from typing import List +from typing import Tuple import pytest @@ -13,7 +15,7 @@ def pytest_generate_tests(metafunc): @pytest.fixture(scope="session") def checked_order(): - order = [] + order: List[Tuple[str, str, str]] = [] yield order pprint.pprint(order) @@ -31,13 +33,13 @@ def checked_order(): ] -@pytest.yield_fixture(scope="module") +@pytest.fixture(scope="module") def fix1(request, arg1, checked_order): checked_order.append((request.node.name, "fix1", arg1)) yield "fix1-" + arg1 -@pytest.yield_fixture(scope="function") +@pytest.fixture(scope="function") def fix2(request, fix1, arg2, checked_order): checked_order.append((request.node.name, "fix2", arg2)) yield "fix2-" + arg2 + fix1 diff --git a/testing/example_scripts/pytest.ini b/testing/example_scripts/pytest.ini new file mode 100644 index 00000000000..ec5fe0e83a7 --- /dev/null +++ b/testing/example_scripts/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +# dummy pytest.ini to ease direct running of example scripts diff --git a/testing/example_scripts/unittest/test_unittest_asyncio.py b/testing/example_scripts/unittest/test_unittest_asyncio.py new file mode 100644 index 00000000000..bbc752de5c1 --- /dev/null +++ b/testing/example_scripts/unittest/test_unittest_asyncio.py @@ -0,0 +1,25 @@ +from typing import List +from unittest import IsolatedAsyncioTestCase # type: ignore + + +teardowns: List[None] = [] + + +class AsyncArguments(IsolatedAsyncioTestCase): + async def asyncTearDown(self): + teardowns.append(None) + + async def test_something_async(self): + async def addition(x, y): + return x + y + + self.assertEqual(await addition(2, 2), 4) + + async def test_something_async_fails(self): + async def addition(x, y): + return x + y + + self.assertEqual(await addition(2, 2), 3) + + def test_teardowns(self): + assert len(teardowns) == 2 diff --git a/testing/example_scripts/unittest/test_unittest_asynctest.py b/testing/example_scripts/unittest/test_unittest_asynctest.py new file mode 100644 index 00000000000..fb26617067c --- /dev/null +++ b/testing/example_scripts/unittest/test_unittest_asynctest.py @@ -0,0 +1,23 @@ +"""Issue #7110""" +import asyncio +from typing import List + +import asynctest + + +teardowns: List[None] = [] + + +class Test(asynctest.TestCase): + async def tearDown(self): + teardowns.append(None) + + async def test_error(self): + await asyncio.sleep(0) + self.fail("failing on purpose") + + async def test_ok(self): + await asyncio.sleep(0) + + def test_teardowns(self): + assert len(teardowns) == 2 diff --git a/testing/example_scripts/unittest/test_unittest_plain_async.py b/testing/example_scripts/unittest/test_unittest_plain_async.py new file mode 100644 index 00000000000..78dfece684e --- /dev/null +++ b/testing/example_scripts/unittest/test_unittest_plain_async.py @@ -0,0 +1,6 @@ +import unittest + + +class Test(unittest.TestCase): + async def test_foo(self): + assert False diff --git a/testing/example_scripts/warnings/test_group_warnings_by_message.py b/testing/example_scripts/warnings/test_group_warnings_by_message.py index c736135b7b9..6985caa4407 100644 --- a/testing/example_scripts/warnings/test_group_warnings_by_message.py +++ b/testing/example_scripts/warnings/test_group_warnings_by_message.py @@ -3,14 +3,19 @@ import pytest -def func(): - warnings.warn(UserWarning("foo")) +def func(msg): + warnings.warn(UserWarning(msg)) @pytest.mark.parametrize("i", range(5)) def test_foo(i): - func() + func("foo") -def test_bar(): - func() +def test_foo_1(): + func("foo") + + +@pytest.mark.parametrize("i", range(5)) +def test_bar(i): + func("bar") diff --git a/testing/example_scripts/warnings/test_group_warnings_by_message_summary.py b/testing/example_scripts/warnings/test_group_warnings_by_message_summary.py deleted file mode 100644 index 4f7df3d6d33..00000000000 --- a/testing/example_scripts/warnings/test_group_warnings_by_message_summary.py +++ /dev/null @@ -1,21 +0,0 @@ -import warnings - -import pytest - - -def func(): - warnings.warn(UserWarning("foo")) - - -@pytest.fixture(params=range(20), autouse=True) -def repeat_hack(request): - return request.param - - -@pytest.mark.parametrize("i", range(5)) -def test_foo(i): - func() - - -def test_bar(): - func() diff --git a/testing/example_scripts/warnings/test_group_warnings_by_message_summary/test_1.py b/testing/example_scripts/warnings/test_group_warnings_by_message_summary/test_1.py new file mode 100644 index 00000000000..b8c11cb71c9 --- /dev/null +++ b/testing/example_scripts/warnings/test_group_warnings_by_message_summary/test_1.py @@ -0,0 +1,21 @@ +import warnings + +import pytest + + +def func(msg): + warnings.warn(UserWarning(msg)) + + +@pytest.mark.parametrize("i", range(20)) +def test_foo(i): + func("foo") + + +def test_foo_1(): + func("foo") + + +@pytest.mark.parametrize("i", range(20)) +def test_bar(i): + func("bar") diff --git a/testing/example_scripts/warnings/test_group_warnings_by_message_summary/test_2.py b/testing/example_scripts/warnings/test_group_warnings_by_message_summary/test_2.py new file mode 100644 index 00000000000..636d04a5505 --- /dev/null +++ b/testing/example_scripts/warnings/test_group_warnings_by_message_summary/test_2.py @@ -0,0 +1,5 @@ +from test_1 import func + + +def test_2(): + func("foo") diff --git a/testing/freeze/create_executable.py b/testing/freeze/create_executable.py index b53eb09f53b..998df7b1ca7 100644 --- a/testing/freeze/create_executable.py +++ b/testing/freeze/create_executable.py @@ -1,6 +1,4 @@ -""" -Generates an executable with pytest runner embedded using PyInstaller. -""" +"""Generate an executable with pytest runner embedded using PyInstaller.""" if __name__ == "__main__": import pytest import subprocess diff --git a/testing/io/test_saferepr.py b/testing/io/test_saferepr.py index 06084202eb7..7a97cf424c5 100644 --- a/testing/io/test_saferepr.py +++ b/testing/io/test_saferepr.py @@ -25,7 +25,7 @@ def __repr__(self): assert s[0] == "(" and s[-1] == ")" -def test_exceptions(): +def test_exceptions() -> None: class BrokenRepr: def __init__(self, ex): self.ex = ex @@ -34,8 +34,8 @@ def __repr__(self): raise self.ex class BrokenReprException(Exception): - __str__ = None - __repr__ = None + __str__ = None # type: ignore[assignment] + __repr__ = None # type: ignore[assignment] assert "Exception" in saferepr(BrokenRepr(Exception("broken"))) s = saferepr(BrokenReprException("really broken")) @@ -44,7 +44,7 @@ class BrokenReprException(Exception): none = None try: - none() + none() # type: ignore[misc] except BaseException as exc: exp_exc = repr(exc) obj = BrokenRepr(BrokenReprException("omg even worse")) @@ -136,10 +136,10 @@ def test_big_repr(): assert len(saferepr(range(1000))) <= len("[" + SafeRepr(0).maxlist * "1000" + "]") -def test_repr_on_newstyle(): +def test_repr_on_newstyle() -> None: class Function: def __repr__(self): - return "<%s>" % (self.name) + return "<%s>" % (self.name) # type: ignore[attr-defined] assert saferepr(Function()) @@ -154,3 +154,20 @@ def test_pformat_dispatch(): assert _pformat_dispatch("a") == "'a'" assert _pformat_dispatch("a" * 10, width=5) == "'aaaaaaaaaa'" assert _pformat_dispatch("foo bar", width=5) == "('foo '\n 'bar')" + + +def test_broken_getattribute(): + """saferepr() can create proper representations of classes with + broken __getattribute__ (#7145) + """ + + class SomeClass: + def __getattribute__(self, attr): + raise RuntimeError + + def __repr__(self): + raise RuntimeError + + assert saferepr(SomeClass()).startswith( + "<[RuntimeError() raised in repr()] SomeClass object at 0x" + ) diff --git a/testing/io/test_terminalwriter.py b/testing/io/test_terminalwriter.py new file mode 100644 index 00000000000..db0ccf06a40 --- /dev/null +++ b/testing/io/test_terminalwriter.py @@ -0,0 +1,283 @@ +import io +import os +import re +import shutil +import sys +from typing import Generator +from unittest import mock + +import pytest +from _pytest._io import terminalwriter +from _pytest.monkeypatch import MonkeyPatch + + +# These tests were initially copied from py 1.8.1. + + +def test_terminal_width_COLUMNS(monkeypatch: MonkeyPatch) -> None: + monkeypatch.setenv("COLUMNS", "42") + assert terminalwriter.get_terminal_width() == 42 + monkeypatch.delenv("COLUMNS", raising=False) + + +def test_terminalwriter_width_bogus(monkeypatch: MonkeyPatch) -> None: + monkeypatch.setattr(shutil, "get_terminal_size", mock.Mock(return_value=(10, 10))) + monkeypatch.delenv("COLUMNS", raising=False) + tw = terminalwriter.TerminalWriter() + assert tw.fullwidth == 80 + + +def test_terminalwriter_computes_width(monkeypatch: MonkeyPatch) -> None: + monkeypatch.setattr(terminalwriter, "get_terminal_width", lambda: 42) + tw = terminalwriter.TerminalWriter() + assert tw.fullwidth == 42 + + +def test_terminalwriter_dumb_term_no_markup(monkeypatch: MonkeyPatch) -> None: + monkeypatch.setattr(os, "environ", {"TERM": "dumb", "PATH": ""}) + + class MyFile: + closed = False + + def isatty(self): + return True + + with monkeypatch.context() as m: + m.setattr(sys, "stdout", MyFile()) + assert sys.stdout.isatty() + tw = terminalwriter.TerminalWriter() + assert not tw.hasmarkup + + +def test_terminalwriter_not_unicode() -> None: + """If the file doesn't support Unicode, the string is unicode-escaped (#7475).""" + buffer = io.BytesIO() + file = io.TextIOWrapper(buffer, encoding="cp1252") + tw = terminalwriter.TerminalWriter(file) + tw.write("hello 🌀 wôrld אבג", flush=True) + assert buffer.getvalue() == br"hello \U0001f300 w\xf4rld \u05d0\u05d1\u05d2" + + +win32 = int(sys.platform == "win32") + + +class TestTerminalWriter: + @pytest.fixture(params=["path", "stringio"]) + def tw( + self, request, tmpdir + ) -> Generator[terminalwriter.TerminalWriter, None, None]: + if request.param == "path": + p = tmpdir.join("tmpfile") + f = open(str(p), "w+", encoding="utf8") + tw = terminalwriter.TerminalWriter(f) + + def getlines(): + f.flush() + with open(str(p), encoding="utf8") as fp: + return fp.readlines() + + elif request.param == "stringio": + f = io.StringIO() + tw = terminalwriter.TerminalWriter(f) + + def getlines(): + f.seek(0) + return f.readlines() + + tw.getlines = getlines # type: ignore + tw.getvalue = lambda: "".join(getlines()) # type: ignore + + with f: + yield tw + + def test_line(self, tw) -> None: + tw.line("hello") + lines = tw.getlines() + assert len(lines) == 1 + assert lines[0] == "hello\n" + + def test_line_unicode(self, tw) -> None: + msg = "b\u00f6y" + tw.line(msg) + lines = tw.getlines() + assert lines[0] == msg + "\n" + + def test_sep_no_title(self, tw) -> None: + tw.sep("-", fullwidth=60) + lines = tw.getlines() + assert len(lines) == 1 + assert lines[0] == "-" * (60 - win32) + "\n" + + def test_sep_with_title(self, tw) -> None: + tw.sep("-", "hello", fullwidth=60) + lines = tw.getlines() + assert len(lines) == 1 + assert lines[0] == "-" * 26 + " hello " + "-" * (27 - win32) + "\n" + + def test_sep_longer_than_width(self, tw) -> None: + tw.sep("-", "a" * 10, fullwidth=5) + (line,) = tw.getlines() + # even though the string is wider than the line, still have a separator + assert line == "- aaaaaaaaaa -\n" + + @pytest.mark.skipif(sys.platform == "win32", reason="win32 has no native ansi") + @pytest.mark.parametrize("bold", (True, False)) + @pytest.mark.parametrize("color", ("red", "green")) + def test_markup(self, tw, bold: bool, color: str) -> None: + text = tw.markup("hello", **{color: True, "bold": bold}) + assert "hello" in text + + def test_markup_bad(self, tw) -> None: + with pytest.raises(ValueError): + tw.markup("x", wronkw=3) + with pytest.raises(ValueError): + tw.markup("x", wronkw=0) + + def test_line_write_markup(self, tw) -> None: + tw.hasmarkup = True + tw.line("x", bold=True) + tw.write("x\n", red=True) + lines = tw.getlines() + if sys.platform != "win32": + assert len(lines[0]) >= 2, lines + assert len(lines[1]) >= 2, lines + + def test_attr_fullwidth(self, tw) -> None: + tw.sep("-", "hello", fullwidth=70) + tw.fullwidth = 70 + tw.sep("-", "hello") + lines = tw.getlines() + assert len(lines[0]) == len(lines[1]) + + +@pytest.mark.skipif(sys.platform == "win32", reason="win32 has no native ansi") +def test_attr_hasmarkup() -> None: + file = io.StringIO() + tw = terminalwriter.TerminalWriter(file) + assert not tw.hasmarkup + tw.hasmarkup = True + tw.line("hello", bold=True) + s = file.getvalue() + assert len(s) > len("hello\n") + assert "\x1b[1m" in s + assert "\x1b[0m" in s + + +def assert_color_set(): + file = io.StringIO() + tw = terminalwriter.TerminalWriter(file) + assert tw.hasmarkup + tw.line("hello", bold=True) + s = file.getvalue() + assert len(s) > len("hello\n") + assert "\x1b[1m" in s + assert "\x1b[0m" in s + + +def assert_color_not_set(): + f = io.StringIO() + f.isatty = lambda: True # type: ignore + tw = terminalwriter.TerminalWriter(file=f) + assert not tw.hasmarkup + tw.line("hello", bold=True) + s = f.getvalue() + assert s == "hello\n" + + +def test_should_do_markup_PY_COLORS_eq_1(monkeypatch: MonkeyPatch) -> None: + monkeypatch.setitem(os.environ, "PY_COLORS", "1") + assert_color_set() + + +def test_should_not_do_markup_PY_COLORS_eq_0(monkeypatch: MonkeyPatch) -> None: + monkeypatch.setitem(os.environ, "PY_COLORS", "0") + assert_color_not_set() + + +def test_should_not_do_markup_NO_COLOR(monkeypatch: MonkeyPatch) -> None: + monkeypatch.setitem(os.environ, "NO_COLOR", "1") + assert_color_not_set() + + +def test_should_do_markup_FORCE_COLOR(monkeypatch: MonkeyPatch) -> None: + monkeypatch.setitem(os.environ, "FORCE_COLOR", "1") + assert_color_set() + + +def test_should_not_do_markup_NO_COLOR_and_FORCE_COLOR( + monkeypatch: MonkeyPatch, +) -> None: + monkeypatch.setitem(os.environ, "NO_COLOR", "1") + monkeypatch.setitem(os.environ, "FORCE_COLOR", "1") + assert_color_not_set() + + +class TestTerminalWriterLineWidth: + def test_init(self) -> None: + tw = terminalwriter.TerminalWriter() + assert tw.width_of_current_line == 0 + + def test_update(self) -> None: + tw = terminalwriter.TerminalWriter() + tw.write("hello world") + assert tw.width_of_current_line == 11 + + def test_update_with_newline(self) -> None: + tw = terminalwriter.TerminalWriter() + tw.write("hello\nworld") + assert tw.width_of_current_line == 5 + + def test_update_with_wide_text(self) -> None: + tw = terminalwriter.TerminalWriter() + tw.write("乇乂ㄒ尺卂 ㄒ卄丨匚匚") + assert tw.width_of_current_line == 21 # 5*2 + 1 + 5*2 + + def test_composed(self) -> None: + tw = terminalwriter.TerminalWriter() + text = "café food" + assert len(text) == 9 + tw.write(text) + assert tw.width_of_current_line == 9 + + def test_combining(self) -> None: + tw = terminalwriter.TerminalWriter() + text = "café food" + assert len(text) == 10 + tw.write(text) + assert tw.width_of_current_line == 9 + + +@pytest.mark.parametrize( + ("has_markup", "code_highlight", "expected"), + [ + pytest.param( + True, + True, + "{kw}assert{hl-reset} {number}0{hl-reset}\n", + id="with markup and code_highlight", + ), + pytest.param( + True, False, "assert 0\n", id="with markup but no code_highlight", + ), + pytest.param( + False, True, "assert 0\n", id="without markup but with code_highlight", + ), + pytest.param( + False, False, "assert 0\n", id="neither markup nor code_highlight", + ), + ], +) +def test_code_highlight(has_markup, code_highlight, expected, color_mapping): + f = io.StringIO() + tw = terminalwriter.TerminalWriter(f) + tw.hasmarkup = has_markup + tw.code_highlight = code_highlight + tw._write_source(["assert 0"]) + + assert f.getvalue().splitlines(keepends=True) == color_mapping.format([expected]) + + with pytest.raises( + ValueError, + match=re.escape("indents size (2) should have same size as lines (1)"), + ): + tw._write_source(["assert 0"], [" ", " "]) diff --git a/testing/io/test_wcwidth.py b/testing/io/test_wcwidth.py new file mode 100644 index 00000000000..7cc74df5d07 --- /dev/null +++ b/testing/io/test_wcwidth.py @@ -0,0 +1,38 @@ +import pytest +from _pytest._io.wcwidth import wcswidth +from _pytest._io.wcwidth import wcwidth + + +@pytest.mark.parametrize( + ("c", "expected"), + [ + ("\0", 0), + ("\n", -1), + ("a", 1), + ("1", 1), + ("א", 1), + ("\u200B", 0), + ("\u1ABE", 0), + ("\u0591", 0), + ("🉐", 2), + ("$", 2), + ], +) +def test_wcwidth(c: str, expected: int) -> None: + assert wcwidth(c) == expected + + +@pytest.mark.parametrize( + ("s", "expected"), + [ + ("", 0), + ("hello, world!", 13), + ("hello, world!\n", -1), + ("0123456789", 10), + ("שלום, עולם!", 11), + ("שְבֻעָיים", 6), + ("🉐🉐🉐", 6), + ], +) +def test_wcswidth(s: str, expected: int) -> None: + assert wcswidth(s) == expected diff --git a/testing/logging/test_fixture.py b/testing/logging/test_fixture.py index c68866beff9..ffd51bcad7a 100644 --- a/testing/logging/test_fixture.py +++ b/testing/logging/test_fixture.py @@ -1,6 +1,8 @@ import logging import pytest +from _pytest.logging import caplog_records_key +from _pytest.pytester import Testdir logger = logging.getLogger(__name__) sublogger = logging.getLogger(__name__ + ".baz") @@ -26,8 +28,11 @@ def test_change_level(caplog): assert "CRITICAL" in caplog.text -def test_change_level_undo(testdir): - """Ensure that 'set_level' is undone after the end of the test""" +def test_change_level_undo(testdir: Testdir) -> None: + """Ensure that 'set_level' is undone after the end of the test. + + Tests the logging output themselves (affacted both by logger and handler levels). + """ testdir.makepyfile( """ import logging @@ -49,6 +54,34 @@ def test2(caplog): result.stdout.no_fnmatch_line("*log from test2*") +def test_change_level_undos_handler_level(testdir: Testdir) -> None: + """Ensure that 'set_level' is undone after the end of the test (handler). + + Issue #7569. Tests the handler level specifically. + """ + testdir.makepyfile( + """ + import logging + + def test1(caplog): + assert caplog.handler.level == 0 + caplog.set_level(9999) + caplog.set_level(41) + assert caplog.handler.level == 41 + + def test2(caplog): + assert caplog.handler.level == 0 + + def test3(caplog): + assert caplog.handler.level == 0 + caplog.set_level(43) + assert caplog.handler.level == 43 + """ + ) + result = testdir.runpytest() + result.assert_outcomes(passed=3) + + def test_with_statement(caplog): with caplog.at_level(logging.INFO): logger.debug("handler DEBUG level") @@ -136,4 +169,140 @@ def test_caplog_captures_for_all_stages(caplog, logging_during_setup_and_teardow assert [x.message for x in caplog.get_records("setup")] == ["a_setup_log"] # This reaches into private API, don't use this type of thing in real tests! - assert set(caplog._item.catch_log_handlers.keys()) == {"setup", "call"} + assert set(caplog._item._store[caplog_records_key]) == {"setup", "call"} + + +def test_ini_controls_global_log_level(testdir): + testdir.makepyfile( + """ + import pytest + import logging + def test_log_level_override(request, caplog): + plugin = request.config.pluginmanager.getplugin('logging-plugin') + assert plugin.log_level == logging.ERROR + logger = logging.getLogger('catchlog') + logger.warning("WARNING message won't be shown") + logger.error("ERROR message will be shown") + assert 'WARNING' not in caplog.text + assert 'ERROR' in caplog.text + """ + ) + testdir.makeini( + """ + [pytest] + log_level=ERROR + """ + ) + + result = testdir.runpytest() + # make sure that that we get a '0' exit code for the testsuite + assert result.ret == 0 + + +def test_caplog_can_override_global_log_level(testdir): + testdir.makepyfile( + """ + import pytest + import logging + def test_log_level_override(request, caplog): + logger = logging.getLogger('catchlog') + plugin = request.config.pluginmanager.getplugin('logging-plugin') + assert plugin.log_level == logging.WARNING + + logger.info("INFO message won't be shown") + + caplog.set_level(logging.INFO, logger.name) + + with caplog.at_level(logging.DEBUG, logger.name): + logger.debug("DEBUG message will be shown") + + logger.debug("DEBUG message won't be shown") + + with caplog.at_level(logging.CRITICAL, logger.name): + logger.warning("WARNING message won't be shown") + + logger.debug("DEBUG message won't be shown") + logger.info("INFO message will be shown") + + assert "message won't be shown" not in caplog.text + """ + ) + testdir.makeini( + """ + [pytest] + log_level=WARNING + """ + ) + + result = testdir.runpytest() + assert result.ret == 0 + + +def test_caplog_captures_despite_exception(testdir): + testdir.makepyfile( + """ + import pytest + import logging + def test_log_level_override(request, caplog): + logger = logging.getLogger('catchlog') + plugin = request.config.pluginmanager.getplugin('logging-plugin') + assert plugin.log_level == logging.WARNING + + logger.error("ERROR message " + "will be shown") + + with caplog.at_level(logging.DEBUG, logger.name): + logger.debug("DEBUG message " + "won't be shown") + raise Exception() + """ + ) + testdir.makeini( + """ + [pytest] + log_level=WARNING + """ + ) + + result = testdir.runpytest() + result.stdout.fnmatch_lines(["*ERROR message will be shown*"]) + result.stdout.no_fnmatch_line("*DEBUG message won't be shown*") + assert result.ret == 1 + + +def test_log_report_captures_according_to_config_option_upon_failure(testdir): + """Test that upon failure: + (1) `caplog` succeeded to capture the DEBUG message and assert on it => No `Exception` is raised. + (2) The `DEBUG` message does NOT appear in the `Captured log call` report. + (3) The stdout, `INFO`, and `WARNING` messages DO appear in the test reports due to `--log-level=INFO`. + """ + testdir.makepyfile( + """ + import pytest + import logging + + def function_that_logs(): + logging.debug('DEBUG log ' + 'message') + logging.info('INFO log ' + 'message') + logging.warning('WARNING log ' + 'message') + print('Print ' + 'message') + + def test_that_fails(request, caplog): + plugin = request.config.pluginmanager.getplugin('logging-plugin') + assert plugin.log_level == logging.INFO + + with caplog.at_level(logging.DEBUG): + function_that_logs() + + if 'DEBUG log ' + 'message' not in caplog.text: + raise Exception('caplog failed to ' + 'capture DEBUG') + + assert False + """ + ) + + result = testdir.runpytest("--log-level=INFO") + result.stdout.no_fnmatch_line("*Exception: caplog failed to capture DEBUG*") + result.stdout.no_fnmatch_line("*DEBUG log message*") + result.stdout.fnmatch_lines( + ["*Print message*", "*INFO log message*", "*WARNING log message*"] + ) + assert result.ret == 1 diff --git a/testing/logging/test_formatter.py b/testing/logging/test_formatter.py index 85e949d7a78..335166caa2f 100644 --- a/testing/logging/test_formatter.py +++ b/testing/logging/test_formatter.py @@ -1,10 +1,11 @@ import logging +from typing import Any from _pytest._io import TerminalWriter from _pytest.logging import ColoredLevelFormatter -def test_coloredlogformatter(): +def test_coloredlogformatter() -> None: logfmt = "%(filename)-25s %(lineno)4d %(levelname)-8s %(message)s" record = logging.LogRecord( @@ -14,7 +15,7 @@ def test_coloredlogformatter(): lineno=10, msg="Test Message", args=(), - exc_info=False, + exc_info=None, ) class ColorConfig: @@ -35,19 +36,19 @@ class option: assert output == ("dummypath 10 INFO Test Message") -def test_multiline_message(): +def test_multiline_message() -> None: from _pytest.logging import PercentStyleMultiline logfmt = "%(filename)-25s %(lineno)4d %(levelname)-8s %(message)s" - record = logging.LogRecord( + record: Any = logging.LogRecord( name="dummy", level=logging.INFO, pathname="dummypath", lineno=10, msg="Test Message line1\nline2", args=(), - exc_info=False, + exc_info=None, ) # this is called by logging.Formatter.format record.message = record.getMessage() @@ -124,7 +125,7 @@ def test_multiline_message(): ) -def test_colored_short_level(): +def test_colored_short_level() -> None: logfmt = "%(levelname).1s %(message)s" record = logging.LogRecord( @@ -134,7 +135,7 @@ def test_colored_short_level(): lineno=10, msg="Test Message", args=(), - exc_info=False, + exc_info=None, ) class ColorConfig: diff --git a/testing/logging/test_reporting.py b/testing/logging/test_reporting.py index 4333bbb0028..fc9f1082346 100644 --- a/testing/logging/test_reporting.py +++ b/testing/logging/test_reporting.py @@ -1,8 +1,13 @@ import io import os import re +from typing import cast import pytest +from _pytest.capture import CaptureManager +from _pytest.config import ExitCode +from _pytest.pytester import Testdir +from _pytest.terminal import TerminalReporter def test_nothing_logged(testdir): @@ -166,60 +171,6 @@ def teardown_function(function): ) -def test_disable_log_capturing(testdir): - testdir.makepyfile( - """ - import sys - import logging - - logger = logging.getLogger(__name__) - - def test_foo(): - sys.stdout.write('text going to stdout') - logger.warning('catch me if you can!') - sys.stderr.write('text going to stderr') - assert False - """ - ) - result = testdir.runpytest("--no-print-logs") - print(result.stdout) - assert result.ret == 1 - result.stdout.fnmatch_lines(["*- Captured stdout call -*", "text going to stdout"]) - result.stdout.fnmatch_lines(["*- Captured stderr call -*", "text going to stderr"]) - with pytest.raises(pytest.fail.Exception): - result.stdout.fnmatch_lines(["*- Captured *log call -*"]) - - -def test_disable_log_capturing_ini(testdir): - testdir.makeini( - """ - [pytest] - log_print=False - """ - ) - testdir.makepyfile( - """ - import sys - import logging - - logger = logging.getLogger(__name__) - - def test_foo(): - sys.stdout.write('text going to stdout') - logger.warning('catch me if you can!') - sys.stderr.write('text going to stderr') - assert False - """ - ) - result = testdir.runpytest() - print(result.stdout) - assert result.ret == 1 - result.stdout.fnmatch_lines(["*- Captured stdout call -*", "text going to stdout"]) - result.stdout.fnmatch_lines(["*- Captured stderr call -*", "text going to stderr"]) - with pytest.raises(pytest.fail.Exception): - result.stdout.fnmatch_lines(["*- Captured *log call -*"]) - - @pytest.mark.parametrize("enabled", [True, False]) def test_log_cli_enabled_disabled(testdir, enabled): msg = "critical message logged by test" @@ -311,10 +262,10 @@ def test_log_2(): result = testdir.runpytest() result.stdout.fnmatch_lines( [ - "{}::test_log_1 ".format(filename), + f"{filename}::test_log_1 ", "*WARNING*log message from test_log_1*", "PASSED *50%*", - "{}::test_log_2 ".format(filename), + f"{filename}::test_log_2 ", "*WARNING*log message from test_log_2*", "PASSED *100%*", "=* 2 passed in *=", @@ -367,7 +318,7 @@ def test_log_2(fix): result = testdir.runpytest() result.stdout.fnmatch_lines( [ - "{}::test_log_1 ".format(filename), + f"{filename}::test_log_1 ", "*-- live log start --*", "*WARNING* >>>>> START >>>>>*", "*-- live log setup --*", @@ -379,7 +330,7 @@ def test_log_2(fix): "*WARNING*log message from teardown of test_log_1*", "*-- live log finish --*", "*WARNING* <<<<< END <<<<<<<*", - "{}::test_log_2 ".format(filename), + f"{filename}::test_log_2 ", "*-- live log start --*", "*WARNING* >>>>> START >>>>>*", "*-- live log setup --*", @@ -443,7 +394,7 @@ def test_log_1(fix): result.stdout.fnmatch_lines( [ "*WARNING*Unknown Section*", - "{}::test_log_1 ".format(filename), + f"{filename}::test_log_1 ", "*WARNING* >>>>> START >>>>>*", "*-- live log setup --*", "*WARNING*log message from setup of test_log_1*", @@ -502,7 +453,7 @@ def test_log_1(fix): result = testdir.runpytest() result.stdout.fnmatch_lines( [ - "{}::test_log_1 ".format(filename), + f"{filename}::test_log_1 ", "*-- live log start --*", "*WARNING* >>>>> START >>>>>*", "*-- live log setup --*", @@ -687,7 +638,7 @@ def test_log_file(request): log_file = testdir.tmpdir.join("pytest.log").strpath result = testdir.runpytest( - "-s", "--log-file={}".format(log_file), "--log-file-level=WARNING" + "-s", f"--log-file={log_file}", "--log-file-level=WARNING" ) # fnmatch_lines does an assertion internally @@ -719,9 +670,7 @@ def test_log_file(request): log_file = testdir.tmpdir.join("pytest.log").strpath - result = testdir.runpytest( - "-s", "--log-file={}".format(log_file), "--log-file-level=INFO" - ) + result = testdir.runpytest("-s", f"--log-file={log_file}", "--log-file-level=INFO") # fnmatch_lines does an assertion internally result.stdout.fnmatch_lines(["test_log_file_cli_level.py PASSED"]) @@ -861,7 +810,7 @@ def test_log_file(): @pytest.mark.parametrize("has_capture_manager", [True, False]) -def test_live_logging_suspends_capture(has_capture_manager, request): +def test_live_logging_suspends_capture(has_capture_manager: bool, request) -> None: """Test that capture manager is suspended when we emitting messages for live logging. This tests the implementation calls instead of behavior because it is difficult/impossible to do it using @@ -888,8 +837,10 @@ class DummyTerminal(io.StringIO): def section(self, *args, **kwargs): pass - out_file = DummyTerminal() - capture_manager = MockCaptureManager() if has_capture_manager else None + out_file = cast(TerminalReporter, DummyTerminal()) + capture_manager = ( + cast(CaptureManager, MockCaptureManager()) if has_capture_manager else None + ) handler = _LiveLoggingStreamHandler(out_file, capture_manager) handler.set_when("call") @@ -902,7 +853,7 @@ def section(self, *args, **kwargs): assert MockCaptureManager.calls == ["enter disabled", "exit disabled"] else: assert MockCaptureManager.calls == [] - assert out_file.getvalue() == "\nsome message\n" + assert cast(io.StringIO, out_file).getvalue() == "\nsome message\n" def test_collection_live_logging(testdir): @@ -938,7 +889,7 @@ def test_simple(): [ "*collected 1 item*", "**", - "*no tests ran*", + "*1 test collected*", ] ) elif verbose == "-q": @@ -946,7 +897,7 @@ def test_simple(): expected_lines.extend( [ "*test_collection_collect_only_live_logging.py::test_simple*", - "no tests ran in [0-1].[0-9][0-9]s", + "1 test collected in [0-9].[0-9][0-9]s", ] ) elif verbose == "-qq": @@ -1103,20 +1054,18 @@ def test_second(): """ ) testdir.runpytest() - with open(os.path.join(report_dir_base, "test_first"), "r") as rfh: + with open(os.path.join(report_dir_base, "test_first")) as rfh: content = rfh.read() assert "message from test 1" in content - with open(os.path.join(report_dir_base, "test_second"), "r") as rfh: + with open(os.path.join(report_dir_base, "test_second")) as rfh: content = rfh.read() assert "message from test 2" in content def test_colored_captured_log(testdir): - """ - Test that the level names of captured log messages of a failing test are - colored. - """ + """Test that the level names of captured log messages of a failing test + are colored.""" testdir.makepyfile( """ import logging @@ -1139,9 +1088,7 @@ def test_foo(): def test_colored_ansi_esc_caplogtext(testdir): - """ - Make sure that caplog.text does not contain ANSI escape sequences. - """ + """Make sure that caplog.text does not contain ANSI escape sequences.""" testdir.makepyfile( """ import logging @@ -1155,3 +1102,53 @@ def test_foo(caplog): ) result = testdir.runpytest("--log-level=INFO", "--color=yes") assert result.ret == 0 + + +def test_logging_emit_error(testdir: Testdir) -> None: + """An exception raised during emit() should fail the test. + + The default behavior of logging is to print "Logging error" + to stderr with the call stack and some extra details. + + pytest overrides this behavior to propagate the exception. + """ + testdir.makepyfile( + """ + import logging + + def test_bad_log(): + logging.warning('oops', 'first', 2) + """ + ) + result = testdir.runpytest() + result.assert_outcomes(failed=1) + result.stdout.fnmatch_lines( + [ + "====* FAILURES *====", + "*not all arguments converted during string formatting*", + ] + ) + + +def test_logging_emit_error_supressed(testdir: Testdir) -> None: + """If logging is configured to silently ignore errors, pytest + doesn't propagate errors either.""" + testdir.makepyfile( + """ + import logging + + def test_bad_log(monkeypatch): + monkeypatch.setattr(logging, 'raiseExceptions', False) + logging.warning('oops', 'first', 2) + """ + ) + result = testdir.runpytest() + result.assert_outcomes(passed=1) + + +def test_log_file_cli_subdirectories_are_successfully_created(testdir): + path = testdir.makepyfile(""" def test_logger(): pass """) + expected = os.path.join(os.path.dirname(str(path)), "foo", "bar") + result = testdir.runpytest("--log-file=foo/bar/logf.log") + assert "logf.log" in os.listdir(expected) + assert result.ret == ExitCode.OK diff --git a/testing/plugins_integration/.gitignore b/testing/plugins_integration/.gitignore new file mode 100644 index 00000000000..d934447a03b --- /dev/null +++ b/testing/plugins_integration/.gitignore @@ -0,0 +1,2 @@ +*.html +assets/ diff --git a/testing/plugins_integration/README.rst b/testing/plugins_integration/README.rst new file mode 100644 index 00000000000..8f027c3bd35 --- /dev/null +++ b/testing/plugins_integration/README.rst @@ -0,0 +1,13 @@ +This folder contains tests and support files for smoke testing popular plugins against the current pytest version. + +The objective is to gauge if any intentional or unintentional changes in pytest break plugins. + +As a rule of thumb, we should add plugins here: + +1. That are used at large. This might be subjective in some cases, but if answer is yes to + the question: *if a new release of pytest causes pytest-X to break, will this break a ton of test suites out there?*. +2. That don't have large external dependencies: such as external services. + +Besides adding the plugin as dependency, we should also add a quick test which uses some +minimal part of the plugin, a smoke test. Also consider reusing one of the existing tests if that's +possible. diff --git a/testing/plugins_integration/bdd_wallet.feature b/testing/plugins_integration/bdd_wallet.feature new file mode 100644 index 00000000000..e404c4948e9 --- /dev/null +++ b/testing/plugins_integration/bdd_wallet.feature @@ -0,0 +1,9 @@ +Feature: Buy things with apple + + Scenario: Buy fruits + Given A wallet with 50 + + When I buy some apples for 1 + And I buy some bananas for 2 + + Then I have 47 left diff --git a/testing/plugins_integration/bdd_wallet.py b/testing/plugins_integration/bdd_wallet.py new file mode 100644 index 00000000000..35927ea5875 --- /dev/null +++ b/testing/plugins_integration/bdd_wallet.py @@ -0,0 +1,39 @@ +from pytest_bdd import given +from pytest_bdd import scenario +from pytest_bdd import then +from pytest_bdd import when + +import pytest + + +@scenario("bdd_wallet.feature", "Buy fruits") +def test_publish(): + pass + + +@pytest.fixture +def wallet(): + class Wallet: + amount = 0 + + return Wallet() + + +@given("A wallet with 50") +def fill_wallet(wallet): + wallet.amount = 50 + + +@when("I buy some apples for 1") +def buy_apples(wallet): + wallet.amount -= 1 + + +@when("I buy some bananas for 2") +def buy_bananas(wallet): + wallet.amount -= 2 + + +@then("I have 47 left") +def check(wallet): + assert wallet.amount == 47 diff --git a/testing/plugins_integration/django_settings.py b/testing/plugins_integration/django_settings.py new file mode 100644 index 00000000000..0715f476531 --- /dev/null +++ b/testing/plugins_integration/django_settings.py @@ -0,0 +1 @@ +SECRET_KEY = "mysecret" diff --git a/testing/plugins_integration/pytest.ini b/testing/plugins_integration/pytest.ini new file mode 100644 index 00000000000..f6c77b0dee5 --- /dev/null +++ b/testing/plugins_integration/pytest.ini @@ -0,0 +1,4 @@ +[pytest] +addopts = --strict-markers +filterwarnings = + error::pytest.PytestWarning diff --git a/testing/plugins_integration/pytest_anyio_integration.py b/testing/plugins_integration/pytest_anyio_integration.py new file mode 100644 index 00000000000..65c2f593663 --- /dev/null +++ b/testing/plugins_integration/pytest_anyio_integration.py @@ -0,0 +1,8 @@ +import anyio + +import pytest + + +@pytest.mark.anyio +async def test_sleep(): + await anyio.sleep(0) diff --git a/testing/plugins_integration/pytest_asyncio_integration.py b/testing/plugins_integration/pytest_asyncio_integration.py new file mode 100644 index 00000000000..5d2a3faccfc --- /dev/null +++ b/testing/plugins_integration/pytest_asyncio_integration.py @@ -0,0 +1,8 @@ +import asyncio + +import pytest + + +@pytest.mark.asyncio +async def test_sleep(): + await asyncio.sleep(0) diff --git a/testing/plugins_integration/pytest_mock_integration.py b/testing/plugins_integration/pytest_mock_integration.py new file mode 100644 index 00000000000..740469d00fb --- /dev/null +++ b/testing/plugins_integration/pytest_mock_integration.py @@ -0,0 +1,2 @@ +def test_mocker(mocker): + mocker.MagicMock() diff --git a/testing/plugins_integration/pytest_trio_integration.py b/testing/plugins_integration/pytest_trio_integration.py new file mode 100644 index 00000000000..199f7850bc4 --- /dev/null +++ b/testing/plugins_integration/pytest_trio_integration.py @@ -0,0 +1,8 @@ +import trio + +import pytest + + +@pytest.mark.trio +async def test_sleep(): + await trio.sleep(0) diff --git a/testing/plugins_integration/pytest_twisted_integration.py b/testing/plugins_integration/pytest_twisted_integration.py new file mode 100644 index 00000000000..94748d036e5 --- /dev/null +++ b/testing/plugins_integration/pytest_twisted_integration.py @@ -0,0 +1,18 @@ +import pytest_twisted +from twisted.internet.task import deferLater + + +def sleep(): + import twisted.internet.reactor + + return deferLater(clock=twisted.internet.reactor, delay=0) + + +@pytest_twisted.inlineCallbacks +def test_inlineCallbacks(): + yield sleep() + + +@pytest_twisted.ensureDeferred +async def test_inlineCallbacks_async(): + await sleep() diff --git a/testing/plugins_integration/requirements.txt b/testing/plugins_integration/requirements.txt new file mode 100644 index 00000000000..d0ee9b571e2 --- /dev/null +++ b/testing/plugins_integration/requirements.txt @@ -0,0 +1,15 @@ +anyio[curio,trio]==2.0.2 +django==3.1.4 +pytest-asyncio==0.14.0 +pytest-bdd==4.0.1 +pytest-cov==2.10.1 +pytest-django==4.1.0 +pytest-flakes==4.0.3 +pytest-html==3.1.0 +pytest-mock==3.3.1 +pytest-rerunfailures==9.1.1 +pytest-sugar==0.9.4 +pytest-trio==0.7.0 +pytest-twisted==1.13.2 +twisted==20.3.0 +pytest-xvfb==2.0.0 diff --git a/testing/plugins_integration/simple_integration.py b/testing/plugins_integration/simple_integration.py new file mode 100644 index 00000000000..20b2fc4b5bb --- /dev/null +++ b/testing/plugins_integration/simple_integration.py @@ -0,0 +1,10 @@ +import pytest + + +def test_foo(): + assert True + + +@pytest.mark.parametrize("i", range(3)) +def test_bar(i): + assert True diff --git a/testing/python/approx.py b/testing/python/approx.py index 76d995773ea..e76d6b774d6 100644 --- a/testing/python/approx.py +++ b/testing/python/approx.py @@ -1,10 +1,13 @@ import operator +import sys from decimal import Decimal from fractions import Fraction from operator import eq from operator import ne +from typing import Optional import pytest +from _pytest.pytester import Pytester from pytest import approx inf, nan = float("inf"), float("nan") @@ -121,18 +124,22 @@ def test_zero_tolerance(self): assert a == approx(x, rel=5e-1, abs=0.0) assert a != approx(x, rel=5e-2, abs=0.0) - def test_negative_tolerance(self): + @pytest.mark.parametrize( + ("rel", "abs"), + [ + (-1e100, None), + (None, -1e100), + (1e100, -1e100), + (-1e100, 1e100), + (-1e100, -1e100), + ], + ) + def test_negative_tolerance( + self, rel: Optional[float], abs: Optional[float] + ) -> None: # Negative tolerances are not allowed. - illegal_kwargs = [ - dict(rel=-1e100), - dict(abs=-1e100), - dict(rel=1e100, abs=-1e100), - dict(rel=-1e100, abs=1e100), - dict(rel=-1e100, abs=-1e100), - ] - for kwargs in illegal_kwargs: - with pytest.raises(ValueError): - 1.1 == approx(1, **kwargs) + with pytest.raises(ValueError): + 1.1 == approx(1, rel, abs) def test_inf_tolerance(self): # Everything should be equal if the tolerance is infinite. @@ -143,19 +150,21 @@ def test_inf_tolerance(self): assert a == approx(x, rel=0.0, abs=inf) assert a == approx(x, rel=inf, abs=inf) - def test_inf_tolerance_expecting_zero(self): + def test_inf_tolerance_expecting_zero(self) -> None: # If the relative tolerance is zero but the expected value is infinite, # the actual tolerance is a NaN, which should be an error. - illegal_kwargs = [dict(rel=inf, abs=0.0), dict(rel=inf, abs=inf)] - for kwargs in illegal_kwargs: - with pytest.raises(ValueError): - 1 == approx(0, **kwargs) - - def test_nan_tolerance(self): - illegal_kwargs = [dict(rel=nan), dict(abs=nan), dict(rel=nan, abs=nan)] - for kwargs in illegal_kwargs: - with pytest.raises(ValueError): - 1.1 == approx(1, **kwargs) + with pytest.raises(ValueError): + 1 == approx(0, rel=inf, abs=0.0) + with pytest.raises(ValueError): + 1 == approx(0, rel=inf, abs=inf) + + def test_nan_tolerance(self) -> None: + with pytest.raises(ValueError): + 1.1 == approx(1, rel=nan) + with pytest.raises(ValueError): + 1.1 == approx(1, abs=nan) + with pytest.raises(ValueError): + 1.1 == approx(1, rel=nan, abs=nan) def test_reasonable_defaults(self): # Whatever the defaults are, they should work for numbers close to 1 @@ -322,6 +331,9 @@ def test_tuple_wrong_len(self): assert (1, 2) != approx((1,)) assert (1, 2) != approx((1, 2, 3)) + def test_tuple_vs_other(self): + assert 1 != approx((1,)) + def test_dict(self): actual = {"a": 1 + 1e-7, "b": 2 + 1e-8} # Dictionaries became ordered in python3.6, so switch up the order here @@ -339,6 +351,13 @@ def test_dict_wrong_len(self): assert {"a": 1, "b": 2} != approx({"a": 1, "c": 2}) assert {"a": 1, "b": 2} != approx({"a": 1, "b": 2, "c": 3}) + def test_dict_nonnumeric(self): + assert {"a": 1.0, "b": None} == pytest.approx({"a": 1.0, "b": None}) + assert {"a": 1.0, "b": 1} != pytest.approx({"a": 1.0, "b": None}) + + def test_dict_vs_other(self): + assert 1 != approx({"a": 0}) + def test_numpy_array(self): np = pytest.importorskip("numpy") @@ -428,21 +447,52 @@ def test_numpy_array_wrong_shape(self): assert a12 != approx(a21) assert a21 != approx(a12) - def test_doctests(self, mocked_doctest_runner): + def test_numpy_array_protocol(self): + """ + array-like objects such as tensorflow's DeviceArray are handled like ndarray. + See issue #8132 + """ + np = pytest.importorskip("numpy") + + class DeviceArray: + def __init__(self, value, size): + self.value = value + self.size = size + + def __array__(self): + return self.value * np.ones(self.size) + + class DeviceScalar: + def __init__(self, value): + self.value = value + + def __array__(self): + return np.array(self.value) + + expected = 1 + actual = 1 + 1e-6 + assert approx(expected) == DeviceArray(actual, size=1) + assert approx(expected) == DeviceArray(actual, size=2) + assert approx(expected) == DeviceScalar(actual) + assert approx(DeviceScalar(expected)) == actual + assert approx(DeviceScalar(expected)) == DeviceScalar(actual) + + def test_doctests(self, mocked_doctest_runner) -> None: import doctest parser = doctest.DocTestParser() + assert approx.__doc__ is not None test = parser.get_doctest( approx.__doc__, {"approx": approx}, approx.__name__, None, None ) mocked_doctest_runner.run(test) - def test_unicode_plus_minus(self, testdir): + def test_unicode_plus_minus(self, pytester: Pytester) -> None: """ Comparing approx instances inside lists should not produce an error in the detailed diff. Integration test for issue #2111. """ - testdir.makepyfile( + pytester.makepyfile( """ import pytest def test_foo(): @@ -450,25 +500,71 @@ def test_foo(): """ ) expected = "4.0e-06" - result = testdir.runpytest() + result = pytester.runpytest() result.stdout.fnmatch_lines( - ["*At index 0 diff: 3 != 4 ± {}".format(expected), "=* 1 failed in *="] + [f"*At index 0 diff: 3 != 4 ± {expected}", "=* 1 failed in *="] ) + @pytest.mark.parametrize( + "x, name", + [ + pytest.param([[1]], "data structures", id="nested-list"), + pytest.param({"key": {"key": 1}}, "dictionaries", id="nested-dict"), + ], + ) + def test_expected_value_type_error(self, x, name): + with pytest.raises( + TypeError, match=fr"pytest.approx\(\) does not support nested {name}:", + ): + approx(x) + @pytest.mark.parametrize( "x", [ pytest.param(None), pytest.param("string"), pytest.param(["string"], id="nested-str"), - pytest.param([[1]], id="nested-list"), pytest.param({"key": "string"}, id="dict-with-string"), - pytest.param({"key": {"key": 1}}, id="nested-dict"), ], ) - def test_expected_value_type_error(self, x): - with pytest.raises(TypeError): - approx(x) + def test_nonnumeric_okay_if_equal(self, x): + assert x == approx(x) + + @pytest.mark.parametrize( + "x", + [ + pytest.param("string"), + pytest.param(["string"], id="nested-str"), + pytest.param({"key": "string"}, id="dict-with-string"), + ], + ) + def test_nonnumeric_false_if_unequal(self, x): + """For nonnumeric types, x != pytest.approx(y) reduces to x != y""" + assert "ab" != approx("abc") + assert ["ab"] != approx(["abc"]) + # in particular, both of these should return False + assert {"a": 1.0} != approx({"a": None}) + assert {"a": None} != approx({"a": 1.0}) + + assert 1.0 != approx(None) + assert None != approx(1.0) # noqa: E711 + + assert 1.0 != approx([None]) + assert None != approx([1.0]) # noqa: E711 + + @pytest.mark.skipif(sys.version_info < (3, 7), reason="requires ordered dicts") + def test_nonnumeric_dict_repr(self): + """Dicts with non-numerics and infinites have no tolerances""" + x1 = {"foo": 1.0000005, "bar": None, "foobar": inf} + assert ( + repr(approx(x1)) + == "approx({'foo': 1.0000005 ± 1.0e-06, 'bar': None, 'foobar': inf})" + ) + + def test_nonnumeric_list_repr(self): + """Lists with non-numerics and infinites have no tolerances""" + x1 = [1.0000005, None, inf] + assert repr(approx(x1)) == "approx([1.0000005 ± 1.0e-06, None, inf])" @pytest.mark.parametrize( "op", @@ -480,9 +576,7 @@ def test_expected_value_type_error(self, x): ], ) def test_comparison_operator_type_error(self, op): - """ - pytest.approx should raise TypeError for operators other than == and != (#2003). - """ + """pytest.approx should raise TypeError for operators other than == and != (#2003).""" with pytest.raises(TypeError): op(1, approx(1, rel=1e-6, abs=1e-12)) diff --git a/testing/python/collect.py b/testing/python/collect.py index 047d5f18e15..4d5f4c6895f 100644 --- a/testing/python/collect.py +++ b/testing/python/collect.py @@ -1,11 +1,13 @@ -import os import sys import textwrap +from typing import Any +from typing import Dict import _pytest._code import pytest from _pytest.config import ExitCode from _pytest.nodes import Collector +from _pytest.pytester import Testdir class TestModule: @@ -106,11 +108,10 @@ def test_show_traceback_import_error(self, testdir, verbose): assert result.ret == 2 stdout = result.stdout.str() - for name in ("_pytest", os.path.join("py", "_path")): - if verbose == 2: - assert name in stdout - else: - assert name not in stdout + if verbose == 2: + assert "_pytest" in stdout + else: + assert "_pytest" not in stdout def test_show_traceback_import_error_unicode(self, testdir): """Check test modules collected which raise ImportError with unicode messages @@ -293,9 +294,11 @@ def func1(): def func2(): pass - f1 = self.make_function(testdir, name="name", args=(1,), callobj=func1) + f1 = self.make_function(testdir, name="name", callobj=func1) assert f1 == f1 - f2 = self.make_function(testdir, name="name", callobj=func2) + f2 = self.make_function( + testdir, name="name", callobj=func2, originalname="foobar" + ) assert f1 != f2 def test_repr_produces_actual_test_id(self, testdir): @@ -463,7 +466,7 @@ def fix3(): return '3' @pytest.mark.parametrize('fix2', ['2']) - def test_it(fix1, fix2): + def test_it(fix1): assert fix1 == '21' assert not fix3_instantiated """ @@ -659,20 +662,47 @@ def test_passed(x): result = testdir.runpytest() result.stdout.fnmatch_lines(["* 3 passed in *"]) - def test_function_original_name(self, testdir): + def test_function_originalname(self, testdir: Testdir) -> None: items = testdir.getitems( """ import pytest + @pytest.mark.parametrize('arg', [1,2]) def test_func(arg): pass + + def test_no_param(): + pass """ ) - assert [x.originalname for x in items] == ["test_func", "test_func"] + originalnames = [] + for x in items: + assert isinstance(x, pytest.Function) + originalnames.append(x.originalname) + assert originalnames == [ + "test_func", + "test_func", + "test_no_param", + ] + + def test_function_with_square_brackets(self, testdir: Testdir) -> None: + """Check that functions with square brackets don't cause trouble.""" + p1 = testdir.makepyfile( + """ + locals()["test_foo[name]"] = lambda: None + """ + ) + result = testdir.runpytest("-v", str(p1)) + result.stdout.fnmatch_lines( + [ + "test_function_with_square_brackets.py::test_foo[[]name[]] PASSED *", + "*= 1 passed in *", + ] + ) class TestSorting: - def test_check_equality(self, testdir): + def test_check_equality(self, testdir) -> None: modcol = testdir.getmodulecol( """ def test_pass(): pass @@ -694,10 +724,10 @@ def test_fail(): assert 0 assert fn1 != fn3 for fn in fn1, fn2, fn3: - assert fn != 3 + assert fn != 3 # type: ignore[comparison-overlap] assert fn != modcol - assert fn != [1, 2, 3] - assert [1, 2, 3] != fn + assert fn != [1, 2, 3] # type: ignore[comparison-overlap] + assert [1, 2, 3] != fn # type: ignore[comparison-overlap] assert modcol != fn def test_allow_sane_sorting_for_decorators(self, testdir): @@ -732,7 +762,7 @@ class MyModule(pytest.Module): pass def pytest_pycollect_makemodule(path, parent): if path.basename == "test_xyz.py": - return MyModule(path, parent) + return MyModule.from_parent(fspath=path, parent=parent) """ ) testdir.makepyfile("def test_some(): pass") @@ -806,22 +836,13 @@ class MyFunction(pytest.Function): pass def pytest_pycollect_makeitem(collector, name, obj): if name == "some": - return MyFunction(name, collector) + return MyFunction.from_parent(name=name, parent=collector) """ ) testdir.makepyfile("def some(): pass") result = testdir.runpytest("--collect-only") result.stdout.fnmatch_lines(["*MyFunction*some*"]) - def test_makeitem_non_underscore(self, testdir, monkeypatch): - modcol = testdir.getmodulecol("def _hello(): pass") - values = [] - monkeypatch.setattr( - pytest.Module, "_makeitem", lambda self, name, obj: values.append(name) - ) - values = modcol.collect() - assert "_hello" not in values - def test_issue2369_collect_module_fileext(self, testdir): """Ensure we can collect files with weird file extensions as Python modules (#2369)""" @@ -843,7 +864,7 @@ def find_module(self, name, path=None): def pytest_collect_file(path, parent): if path.ext == ".narf": - return Module(path, parent)""" + return Module.from_parent(fspath=path, parent=parent)""" ) testdir.makefile( ".narf", @@ -855,6 +876,34 @@ def test_something(): result = testdir.runpytest_subprocess() result.stdout.fnmatch_lines(["*1 passed*"]) + def test_early_ignored_attributes(self, testdir: Testdir) -> None: + """Builtin attributes should be ignored early on, even if + configuration would otherwise allow them. + + This tests a performance optimization, not correctness, really, + although it tests PytestCollectionWarning is not raised, while + it would have been raised otherwise. + """ + testdir.makeini( + """ + [pytest] + python_classes=* + python_functions=* + """ + ) + testdir.makepyfile( + """ + class TestEmpty: + pass + test_empty = TestEmpty() + def test_real(): + pass + """ + ) + items, rec = testdir.inline_genitems() + assert rec.ret == 0 + assert len(items) == 1 + def test_setup_only_available_in_subdir(testdir): sub1 = testdir.mkpydir("sub1") @@ -954,8 +1003,7 @@ def test_traceback_error_during_import(self, testdir): result.stdout.fnmatch_lines([">*asd*", "E*NameError*"]) def test_traceback_filter_error_during_fixture_collection(self, testdir): - """integration test for issue #995. - """ + """Integration test for issue #995.""" testdir.makepyfile( """ import pytest @@ -980,34 +1028,37 @@ def test_failing_fixture(fail_fixture): assert "INTERNALERROR>" not in out result.stdout.fnmatch_lines(["*ValueError: fail me*", "* 1 error in *"]) - def test_filter_traceback_generated_code(self): - """test that filter_traceback() works with the fact that + def test_filter_traceback_generated_code(self) -> None: + """Test that filter_traceback() works with the fact that _pytest._code.code.Code.path attribute might return an str object. + In this case, one of the entries on the traceback was produced by dynamically generated code. See: https://bitbucket.org/pytest-dev/py/issues/71 This fixes #995. """ - from _pytest.python import filter_traceback + from _pytest._code import filter_traceback try: - ns = {} + ns: Dict[str, Any] = {} exec("def foo(): raise ValueError", ns) ns["foo"]() except ValueError: _, _, tb = sys.exc_info() - tb = _pytest._code.Traceback(tb) - assert isinstance(tb[-1].path, str) - assert not filter_traceback(tb[-1]) + assert tb is not None + traceback = _pytest._code.Traceback(tb) + assert isinstance(traceback[-1].path, str) + assert not filter_traceback(traceback[-1]) - def test_filter_traceback_path_no_longer_valid(self, testdir): - """test that filter_traceback() works with the fact that + def test_filter_traceback_path_no_longer_valid(self, testdir) -> None: + """Test that filter_traceback() works with the fact that _pytest._code.code.Code.path attribute might return an str object. + In this case, one of the files in the traceback no longer exists. This fixes #1133. """ - from _pytest.python import filter_traceback + from _pytest._code import filter_traceback testdir.syspathinsert() testdir.makepyfile( @@ -1023,10 +1074,11 @@ def foo(): except ValueError: _, _, tb = sys.exc_info() + assert tb is not None testdir.tmpdir.join("filter_traceback_entry_as_str.py").remove() - tb = _pytest._code.Traceback(tb) - assert isinstance(tb[-1].path, str) - assert filter_traceback(tb[-1]) + traceback = _pytest._code.Traceback(tb) + assert isinstance(traceback[-1].path, str) + assert filter_traceback(traceback[-1]) class TestReportInfo: @@ -1218,14 +1270,13 @@ def test_injection(self): def test_syntax_error_with_non_ascii_chars(testdir): - """Fix decoding issue while formatting SyntaxErrors during collection (#578) - """ + """Fix decoding issue while formatting SyntaxErrors during collection (#578).""" testdir.makepyfile("☃") result = testdir.runpytest() result.stdout.fnmatch_lines(["*ERROR collecting*", "*SyntaxError*", "*1 error in*"]) -def test_collecterror_with_fulltrace(testdir): +def test_collect_error_with_fulltrace(testdir): testdir.makepyfile("assert 0") result = testdir.runpytest("--fulltrace") result.stdout.fnmatch_lines( @@ -1233,15 +1284,12 @@ def test_collecterror_with_fulltrace(testdir): "collected 0 items / 1 error", "", "*= ERRORS =*", - "*_ ERROR collecting test_collecterror_with_fulltrace.py _*", - "", - "*/_pytest/python.py:*: ", - "_ _ _ _ _ _ _ _ *", + "*_ ERROR collecting test_collect_error_with_fulltrace.py _*", "", "> assert 0", "E assert 0", "", - "test_collecterror_with_fulltrace.py:1: AssertionError", + "test_collect_error_with_fulltrace.py:1: AssertionError", "*! Interrupted: 1 error during collection !*", ] ) diff --git a/testing/python/fixtures.py b/testing/python/fixtures.py index bfbe359515c..94547dd245c 100644 --- a/testing/python/fixtures.py +++ b/testing/python/fixtures.py @@ -1,12 +1,14 @@ import sys import textwrap +from pathlib import Path import pytest from _pytest import fixtures -from _pytest.fixtures import FixtureLookupError +from _pytest.compat import getfuncargnames +from _pytest.config import ExitCode from _pytest.fixtures import FixtureRequest -from _pytest.pathlib import Path from _pytest.pytester import get_public_names +from _pytest.pytester import Testdir def test_getfuncargnames_functions(): @@ -15,22 +17,22 @@ def test_getfuncargnames_functions(): def f(): raise NotImplementedError() - assert not fixtures.getfuncargnames(f) + assert not getfuncargnames(f) def g(arg): raise NotImplementedError() - assert fixtures.getfuncargnames(g) == ("arg",) + assert getfuncargnames(g) == ("arg",) def h(arg1, arg2="hello"): raise NotImplementedError() - assert fixtures.getfuncargnames(h) == ("arg1",) + assert getfuncargnames(h) == ("arg1",) def j(arg1, arg2, arg3="hello"): raise NotImplementedError() - assert fixtures.getfuncargnames(j) == ("arg1", "arg2") + assert getfuncargnames(j) == ("arg1", "arg2") def test_getfuncargnames_methods(): @@ -40,7 +42,7 @@ class A: def f(self, arg1, arg2="hello"): raise NotImplementedError() - assert fixtures.getfuncargnames(A().f) == ("arg1",) + assert getfuncargnames(A().f) == ("arg1",) def test_getfuncargnames_staticmethod(): @@ -51,7 +53,7 @@ class A: def static(arg1, arg2, x=1): raise NotImplementedError() - assert fixtures.getfuncargnames(A.static, cls=A) == ("arg1", "arg2") + assert getfuncargnames(A.static, cls=A) == ("arg1", "arg2") def test_getfuncargnames_partial(): @@ -64,7 +66,7 @@ def check(arg1, arg2, i): class T: test_ok = functools.partial(check, i=2) - values = fixtures.getfuncargnames(T().test_ok, name="test_ok") + values = getfuncargnames(T().test_ok, name="test_ok") assert values == ("arg1", "arg2") @@ -78,7 +80,7 @@ def check(arg1, arg2, i): class T: test_ok = staticmethod(functools.partial(check, i=2)) - values = fixtures.getfuncargnames(T().test_ok, name="test_ok") + values = getfuncargnames(T().test_ok, name="test_ok") assert values == ("arg1", "arg2") @@ -86,7 +88,7 @@ class T: class TestFillFixtures: def test_fillfuncargs_exposed(self): # used by oejskit, kept for compatibility - assert pytest._fillfuncargs == fixtures.fillfixtures + assert pytest._fillfuncargs == fixtures._fillfuncargs def test_funcarg_lookupfails(self, testdir): testdir.copy_example() @@ -110,7 +112,7 @@ def test_detect_recursive_dependency_error(self, testdir): def test_funcarg_basic(self, testdir): testdir.copy_example() item = testdir.getitem(Path("test_funcarg_basic.py")) - fixtures.fillfixtures(item) + item._request._fillfixtures() del item.funcargs["request"] assert len(get_public_names(item.funcargs)) == 2 assert item.funcargs["some"] == "test_func" @@ -142,14 +144,14 @@ def test_extend_fixture_conftest_module(self, testdir): p = testdir.copy_example() result = testdir.runpytest() result.stdout.fnmatch_lines(["*1 passed*"]) - result = testdir.runpytest(next(p.visit("test_*.py"))) + result = testdir.runpytest(str(next(Path(str(p)).rglob("test_*.py")))) result.stdout.fnmatch_lines(["*1 passed*"]) def test_extend_fixture_conftest_conftest(self, testdir): p = testdir.copy_example() result = testdir.runpytest() result.stdout.fnmatch_lines(["*1 passed*"]) - result = testdir.runpytest(next(p.visit("test_*.py"))) + result = testdir.runpytest(str(next(Path(str(p)).rglob("test_*.py")))) result.stdout.fnmatch_lines(["*1 passed*"]) def test_extend_fixture_conftest_plugin(self, testdir): @@ -395,6 +397,132 @@ def test_spam(spam): result = testdir.runpytest(testfile) result.stdout.fnmatch_lines(["*3 passed*"]) + def test_override_fixture_reusing_super_fixture_parametrization(self, testdir): + """Override a fixture at a lower level, reusing the higher-level fixture that + is parametrized (#1953). + """ + testdir.makeconftest( + """ + import pytest + + @pytest.fixture(params=[1, 2]) + def foo(request): + return request.param + """ + ) + testdir.makepyfile( + """ + import pytest + + @pytest.fixture + def foo(foo): + return foo * 2 + + def test_spam(foo): + assert foo in (2, 4) + """ + ) + result = testdir.runpytest() + result.stdout.fnmatch_lines(["*2 passed*"]) + + def test_override_parametrize_fixture_and_indirect(self, testdir): + """Override a fixture at a lower level, reusing the higher-level fixture that + is parametrized, while also using indirect parametrization. + """ + testdir.makeconftest( + """ + import pytest + + @pytest.fixture(params=[1, 2]) + def foo(request): + return request.param + """ + ) + testdir.makepyfile( + """ + import pytest + + @pytest.fixture + def foo(foo): + return foo * 2 + + @pytest.fixture + def bar(request): + return request.param * 100 + + @pytest.mark.parametrize("bar", [42], indirect=True) + def test_spam(bar, foo): + assert bar == 4200 + assert foo in (2, 4) + """ + ) + result = testdir.runpytest() + result.stdout.fnmatch_lines(["*2 passed*"]) + + def test_override_top_level_fixture_reusing_super_fixture_parametrization( + self, testdir + ): + """Same as the above test, but with another level of overwriting.""" + testdir.makeconftest( + """ + import pytest + + @pytest.fixture(params=['unused', 'unused']) + def foo(request): + return request.param + """ + ) + testdir.makepyfile( + """ + import pytest + + @pytest.fixture(params=[1, 2]) + def foo(request): + return request.param + + class Test: + + @pytest.fixture + def foo(self, foo): + return foo * 2 + + def test_spam(self, foo): + assert foo in (2, 4) + """ + ) + result = testdir.runpytest() + result.stdout.fnmatch_lines(["*2 passed*"]) + + def test_override_parametrized_fixture_with_new_parametrized_fixture(self, testdir): + """Overriding a parametrized fixture, while also parametrizing the new fixture and + simultaneously requesting the overwritten fixture as parameter, yields the same value + as ``request.param``. + """ + testdir.makeconftest( + """ + import pytest + + @pytest.fixture(params=['ignored', 'ignored']) + def foo(request): + return request.param + """ + ) + testdir.makepyfile( + """ + import pytest + + @pytest.fixture(params=[10, 20]) + def foo(foo, request): + assert request.param == foo + return foo * 2 + + def test_spam(foo): + assert foo in (20, 40) + """ + ) + result = testdir.runpytest() + result.stdout.fnmatch_lines(["*2 passed*"]) + def test_autouse_fixture_plugin(self, testdir): # A fixture from a plugin has no baseid set, which screwed up # the autouse fixture handling. @@ -493,7 +621,7 @@ def something(request): pass def test_func(something): pass """ ) - req = fixtures.FixtureRequest(item) + req = fixtures.FixtureRequest(item, _ispytest=True) assert req.function == item.obj assert req.keywords == item.keywords assert hasattr(req.module, "test_func") @@ -533,7 +661,9 @@ def test_method(self, something): ) (item1,) = testdir.genitems([modcol]) assert item1.name == "test_method" - arg2fixturedefs = fixtures.FixtureRequest(item1)._arg2fixturedefs + arg2fixturedefs = fixtures.FixtureRequest( + item1, _ispytest=True + )._arg2fixturedefs assert len(arg2fixturedefs) == 1 assert arg2fixturedefs["something"][0].argname == "something" @@ -654,7 +784,7 @@ def test_func(something): pass ) req = item._request - with pytest.raises(FixtureLookupError): + with pytest.raises(pytest.FixtureLookupError): req.getfixturevalue("notexists") val = req.getfixturevalue("something") assert val == 1 @@ -664,7 +794,7 @@ def test_func(something): pass assert val2 == 2 val2 = req.getfixturevalue("other") # see about caching assert val2 == 2 - pytest._fillfuncargs(item) + item._request._fillfixtures() assert item.funcargs["something"] == 1 assert len(get_public_names(item.funcargs)) == 2 assert "request" in item.funcargs @@ -681,7 +811,7 @@ def test_func(something): pass """ ) item.session._setupstate.prepare(item) - pytest._fillfuncargs(item) + item._request._fillfixtures() # successively check finalization calls teardownlist = item.getparent(pytest.Module).obj.teardownlist ss = item.session._setupstate @@ -782,7 +912,7 @@ def test_second(): def test_request_getmodulepath(self, testdir): modcol = testdir.getmodulecol("def test_somefunc(): pass") (item,) = testdir.genitems([modcol]) - req = fixtures.FixtureRequest(item) + req = fixtures.FixtureRequest(item, _ispytest=True) assert req.fspath == modcol.fspath def test_request_fixturenames(self, testdir): @@ -814,28 +944,6 @@ def test_request_fixturenames_dynamic_fixture(self, testdir): result = testdir.runpytest() result.stdout.fnmatch_lines(["*1 passed*"]) - def test_funcargnames_compatattr(self, testdir): - testdir.makepyfile( - """ - import pytest - def pytest_generate_tests(metafunc): - with pytest.warns(pytest.PytestDeprecationWarning): - assert metafunc.funcargnames == metafunc.fixturenames - @pytest.fixture - def fn(request): - with pytest.warns(pytest.PytestDeprecationWarning): - assert request._pyfuncitem.funcargnames == \ - request._pyfuncitem.fixturenames - with pytest.warns(pytest.PytestDeprecationWarning): - return request.funcargnames, request.fixturenames - - def test_hello(fn): - assert fn[0] == fn[1] - """ - ) - reprec = testdir.inline_run() - reprec.assertoutcome(passed=1) - def test_setupdecorator_and_xunit(self, testdir): testdir.makepyfile( """ @@ -946,7 +1054,7 @@ def test_func2(self, something): pass """ ) - req1 = fixtures.FixtureRequest(item1) + req1 = fixtures.FixtureRequest(item1, _ispytest=True) assert "xfail" not in item1.keywords req1.applymarker(pytest.mark.xfail) assert "xfail" in item1.keywords @@ -954,7 +1062,7 @@ def test_func2(self, something): req1.applymarker(pytest.mark.skipif) assert "skipif" in item1.keywords with pytest.raises(ValueError): - req1.applymarker(42) + req1.applymarker(42) # type: ignore[arg-type] def test_accesskeywords(self, testdir): testdir.makepyfile( @@ -1315,7 +1423,7 @@ def test_setup_functions_as_fixtures(self, testdir): DB_INITIALIZED = None - @pytest.yield_fixture(scope="session", autouse=True) + @pytest.fixture(scope="session", autouse=True) def db(): global DB_INITIALIZED DB_INITIALIZED = True @@ -1605,7 +1713,7 @@ def test_parsefactories_conftest(self, testdir): """ from _pytest.pytester import get_public_names def test_check_setup(item, fm): - autousenames = fm._getautousenames(item.nodeid) + autousenames = list(fm._getautousenames(item.nodeid)) assert len(get_public_names(autousenames)) == 2 assert "perfunction2" in autousenames assert "perfunction" in autousenames @@ -1683,10 +1791,8 @@ def test_func2(request): reprec.assertoutcome(passed=2) def test_callables_nocode(self, testdir): - """ - an imported mock.call would break setup/factory discovery - due to it being callable and __code__ not being a code object - """ + """An imported mock.call would break setup/factory discovery due to + it being callable and __code__ not being a code object.""" testdir.makepyfile( """ class _call(tuple): @@ -1890,11 +1996,13 @@ def test_2(self): pass """ ) - confcut = "--confcutdir={}".format(testdir.tmpdir) + confcut = f"--confcutdir={testdir.tmpdir}" reprec = testdir.inline_run("-v", "-s", confcut) reprec.assertoutcome(passed=8) config = reprec.getcalls("pytest_unconfigure")[0].config - values = config.pluginmanager._getconftestmodules(p)[0].values + values = config.pluginmanager._getconftestmodules(p, importmode="prepend")[ + 0 + ].values assert values == ["fin_a1", "fin_a2", "fin_b1", "fin_b2"] * 2 def test_scope_ordering(self, testdir): @@ -2958,8 +3066,7 @@ def test_params_and_ids_yieldfixture(self, testdir): """ import pytest - @pytest.yield_fixture(params=[object(), object()], - ids=['alpha', 'beta']) + @pytest.fixture(params=[object(), object()], ids=['alpha', 'beta']) def fix(request): yield request.param @@ -3331,9 +3438,7 @@ def fixture1(self): ) def test_show_fixtures_different_files(self, testdir): - """ - #833: --fixtures only shows fixtures from first file - """ + """`--fixtures` only shows fixtures from first file (#833).""" testdir.makepyfile( test_a=''' import pytest @@ -3424,28 +3529,11 @@ def foo(): class TestContextManagerFixtureFuncs: - @pytest.fixture(params=["fixture", "yield_fixture"]) - def flavor(self, request, testdir, monkeypatch): - monkeypatch.setenv("PYTEST_FIXTURE_FLAVOR", request.param) - testdir.makepyfile( - test_context=""" - import os - import pytest - import warnings - VAR = "PYTEST_FIXTURE_FLAVOR" - if VAR not in os.environ: - warnings.warn("PYTEST_FIXTURE_FLAVOR was not set, assuming fixture") - fixture = pytest.fixture - else: - fixture = getattr(pytest, os.environ[VAR]) - """ - ) - - def test_simple(self, testdir, flavor): + def test_simple(self, testdir: Testdir) -> None: testdir.makepyfile( """ - from test_context import fixture - @fixture + import pytest + @pytest.fixture def arg1(): print("setup") yield 1 @@ -3469,11 +3557,11 @@ def test_2(arg1): """ ) - def test_scoped(self, testdir, flavor): + def test_scoped(self, testdir: Testdir) -> None: testdir.makepyfile( """ - from test_context import fixture - @fixture(scope="module") + import pytest + @pytest.fixture(scope="module") def arg1(): print("setup") yield 1 @@ -3494,11 +3582,11 @@ def test_2(arg1): """ ) - def test_setup_exception(self, testdir, flavor): + def test_setup_exception(self, testdir: Testdir) -> None: testdir.makepyfile( """ - from test_context import fixture - @fixture(scope="module") + import pytest + @pytest.fixture(scope="module") def arg1(): pytest.fail("setup") yield 1 @@ -3514,11 +3602,11 @@ def test_1(arg1): """ ) - def test_teardown_exception(self, testdir, flavor): + def test_teardown_exception(self, testdir: Testdir) -> None: testdir.makepyfile( """ - from test_context import fixture - @fixture(scope="module") + import pytest + @pytest.fixture(scope="module") def arg1(): yield 1 pytest.fail("teardown") @@ -3534,11 +3622,11 @@ def test_1(arg1): """ ) - def test_yields_more_than_one(self, testdir, flavor): + def test_yields_more_than_one(self, testdir: Testdir) -> None: testdir.makepyfile( """ - from test_context import fixture - @fixture(scope="module") + import pytest + @pytest.fixture(scope="module") def arg1(): yield 1 yield 2 @@ -3554,11 +3642,11 @@ def test_1(arg1): """ ) - def test_custom_name(self, testdir, flavor): + def test_custom_name(self, testdir: Testdir) -> None: testdir.makepyfile( """ - from test_context import fixture - @fixture(name='meow') + import pytest + @pytest.fixture(name='meow') def arg1(): return 'mew' def test_1(meow): @@ -3694,7 +3782,7 @@ def test_foo(request): " test_foos.py::test_foo", "", "Requested fixture 'fix_with_param' defined in:", - "{}:4".format(fixfile), + f"{fixfile}:4", "Requested here:", "test_foos.py:4", "*1 failed*", @@ -3711,9 +3799,9 @@ def test_foo(request): " test_foos.py::test_foo", "", "Requested fixture 'fix_with_param' defined in:", - "{}:4".format(fixfile), + f"{fixfile}:4", "Requested here:", - "{}:4".format(testfile), + f"{testfile}:4", "*1 failed*", ] ) @@ -3796,10 +3884,10 @@ def test_func(m1): """ ) items, _ = testdir.inline_genitems() - request = FixtureRequest(items[0]) + request = FixtureRequest(items[0], _ispytest=True) assert request.fixturenames == "m1 f1".split() - def test_func_closure_with_native_fixtures(self, testdir, monkeypatch): + def test_func_closure_with_native_fixtures(self, testdir, monkeypatch) -> None: """Sanity check that verifies the order returned by the closures and the actual fixture execution order: The execution order may differ because of fixture inter-dependencies. """ @@ -3842,16 +3930,15 @@ def test_foo(f1, p1, m1, f2, s1): pass """ ) items, _ = testdir.inline_genitems() - request = FixtureRequest(items[0]) + request = FixtureRequest(items[0], _ispytest=True) # order of fixtures based on their scope and position in the parameter list assert ( request.fixturenames == "s1 my_tmpdir_factory p1 m1 f1 f2 my_tmpdir".split() ) testdir.runpytest() # actual fixture execution differs: dependent fixtures must be created first ("my_tmpdir") - assert ( - pytest.FIXTURE_ORDER == "s1 my_tmpdir_factory p1 m1 my_tmpdir f1 f2".split() - ) + FIXTURE_ORDER = pytest.FIXTURE_ORDER # type: ignore[attr-defined] + assert FIXTURE_ORDER == "s1 my_tmpdir_factory p1 m1 my_tmpdir f1 f2".split() def test_func_closure_module(self, testdir): testdir.makepyfile( @@ -3869,7 +3956,7 @@ def test_func(f1, m1): """ ) items, _ = testdir.inline_genitems() - request = FixtureRequest(items[0]) + request = FixtureRequest(items[0], _ispytest=True) assert request.fixturenames == "m1 f1".split() def test_func_closure_scopes_reordered(self, testdir): @@ -3902,7 +3989,7 @@ def test_func(self, f2, f1, c1, m1, s1): """ ) items, _ = testdir.inline_genitems() - request = FixtureRequest(items[0]) + request = FixtureRequest(items[0], _ispytest=True) assert request.fixturenames == "s1 m1 c1 f2 f1".split() def test_func_closure_same_scope_closer_root_first(self, testdir): @@ -3942,7 +4029,7 @@ def test_func(m_test, f1): } ) items, _ = testdir.inline_genitems() - request = FixtureRequest(items[0]) + request = FixtureRequest(items[0], _ispytest=True) assert request.fixturenames == "p_sub m_conf m_sub m_test f1".split() def test_func_closure_all_scopes_complex(self, testdir): @@ -3986,7 +4073,7 @@ def test_func(self, f2, f1, m2): """ ) items, _ = testdir.inline_genitems() - request = FixtureRequest(items[0]) + request = FixtureRequest(items[0], _ispytest=True) assert request.fixturenames == "s1 p1 m1 m2 c1 f2 f1".split() def test_multiple_packages(self, testdir): @@ -4155,38 +4242,6 @@ def test_fixture_named_request(testdir): ) -def test_fixture_duplicated_arguments(): - """Raise error if there are positional and keyword arguments for the same parameter (#1682).""" - with pytest.raises(TypeError) as excinfo: - - @pytest.fixture("session", scope="session") - def arg(arg): - pass - - assert ( - str(excinfo.value) - == "The fixture arguments are defined as positional and keyword: scope. " - "Use only keyword arguments." - ) - - -def test_fixture_with_positionals(): - """Raise warning, but the positionals should still works (#1682).""" - from _pytest.deprecated import FIXTURE_POSITIONAL_ARGUMENTS - - with pytest.warns(pytest.PytestDeprecationWarning) as warnings: - - @pytest.fixture("function", [0], True) - def fixture_with_positionals(): - pass - - assert str(warnings[0].message) == str(FIXTURE_POSITIONAL_ARGUMENTS) - - assert fixture_with_positionals._pytestfixturefunction.scope == "function" - assert fixture_with_positionals._pytestfixturefunction.params == (0,) - assert fixture_with_positionals._pytestfixturefunction.autouse - - def test_indirect_fixture_does_not_break_scope(testdir): """Ensure that fixture scope is respected when using indirect fixtures (#570)""" testdir.makepyfile( @@ -4291,3 +4346,23 @@ def test_suffix(fix_combined): ) result = testdir.runpytest("-vv", str(p1)) assert result.ret == 0 + + +def test_yield_fixture_with_no_value(testdir): + testdir.makepyfile( + """ + import pytest + @pytest.fixture(name='custom') + def empty_yield(): + if False: + yield + + def test_fixt(custom): + pass + """ + ) + expected = "E ValueError: custom did not yield a value" + result = testdir.runpytest() + result.assert_outcomes(errors=1) + result.stdout.fnmatch_lines([expected]) + assert result.ret == ExitCode.TESTS_FAILED diff --git a/testing/python/integration.py b/testing/python/integration.py index 35e86e6b96c..f006e5ed4ee 100644 --- a/testing/python/integration.py +++ b/testing/python/integration.py @@ -1,10 +1,14 @@ +from typing import Any + import pytest -from _pytest import python from _pytest import runner +from _pytest._code import getfslineno class TestOEJSKITSpecials: - def test_funcarg_non_pycollectobj(self, testdir): # rough jstests usage + def test_funcarg_non_pycollectobj( + self, testdir, recwarn + ) -> None: # rough jstests usage testdir.makeconftest( """ import pytest @@ -28,13 +32,14 @@ class MyClass(object): ) # this hook finds funcarg factories rep = runner.collect_one_node(collector=modcol) - clscol = rep.result[0] + # TODO: Don't treat as Any. + clscol: Any = rep.result[0] clscol.obj = lambda arg1: None clscol.funcargs = {} pytest._fillfuncargs(clscol) assert clscol.funcargs["arg1"] == 42 - def test_autouse_fixture(self, testdir): # rough jstests usage + def test_autouse_fixture(self, testdir, recwarn) -> None: # rough jstests usage testdir.makeconftest( """ import pytest @@ -61,40 +66,41 @@ class MyClass(object): ) # this hook finds funcarg factories rep = runner.collect_one_node(modcol) - clscol = rep.result[0] + # TODO: Don't treat as Any. + clscol: Any = rep.result[0] clscol.obj = lambda: None clscol.funcargs = {} pytest._fillfuncargs(clscol) assert not clscol.funcargs -def test_wrapped_getfslineno(): +def test_wrapped_getfslineno() -> None: def func(): pass def wrap(f): - func.__wrapped__ = f - func.patchings = ["qwe"] + func.__wrapped__ = f # type: ignore + func.patchings = ["qwe"] # type: ignore return func @wrap def wrapped_func(x, y, z): pass - fs, lineno = python.getfslineno(wrapped_func) - fs2, lineno2 = python.getfslineno(wrap) + fs, lineno = getfslineno(wrapped_func) + fs2, lineno2 = getfslineno(wrap) assert lineno > lineno2, "getfslineno does not unwrap correctly" class TestMockDecoration: - def test_wrapped_getfuncargnames(self): + def test_wrapped_getfuncargnames(self) -> None: from _pytest.compat import getfuncargnames def wrap(f): def func(): pass - func.__wrapped__ = f + func.__wrapped__ = f # type: ignore return func @wrap @@ -322,10 +328,11 @@ def test_fix(fix): ) -def test_pytestconfig_is_session_scoped(): +def test_pytestconfig_is_session_scoped() -> None: from _pytest.fixtures import pytestconfig - assert pytestconfig._pytestfixturefunction.scope == "session" + marker = pytestconfig._pytestfixturefunction # type: ignore + assert marker.scope == "session" class TestNoselikeTestAttribute: diff --git a/testing/python/metafunc.py b/testing/python/metafunc.py index 4e657727c32..676f1d988bc 100644 --- a/testing/python/metafunc.py +++ b/testing/python/metafunc.py @@ -3,9 +3,12 @@ import sys import textwrap from typing import Any +from typing import cast +from typing import Dict from typing import Iterator from typing import List from typing import Optional +from typing import Sequence from typing import Tuple from typing import Union @@ -16,16 +19,20 @@ import pytest from _pytest import fixtures from _pytest import python +from _pytest.compat import _format_args +from _pytest.compat import getfuncargnames +from _pytest.compat import NOTSET from _pytest.outcomes import fail from _pytest.pytester import Testdir from _pytest.python import _idval +from _pytest.python import idmaker class TestMetafunc: def Metafunc(self, func, config=None) -> python.Metafunc: - # the unit tests of this class check if things work correctly + # The unit tests of this class check if things work correctly # on the funcarg level, so we don't need a full blown - # initialization + # initialization. class FuncFixtureInfoMock: name2fixturedefs = None @@ -35,13 +42,11 @@ def __init__(self, names): @attr.s class DefinitionMock(python.FunctionDefinition): obj = attr.ib() + _nodeid = attr.ib() - def listchain(self): - return [] - - names = fixtures.getfuncargnames(func) - fixtureinfo = FuncFixtureInfoMock(names) # type: Any - definition = DefinitionMock._create(func) # type: Any + names = getfuncargnames(func) + fixtureinfo: Any = FuncFixtureInfoMock(names) + definition: Any = DefinitionMock._create(func, "mock::nodeid") return python.Metafunc(definition, fixtureinfo, config) def test_no_funcargs(self) -> None: @@ -75,7 +80,7 @@ def func(x, y): pytest.raises(ValueError, lambda: metafunc.parametrize("y", [5, 6])) with pytest.raises(TypeError, match="^ids must be a callable or an iterable$"): - metafunc.parametrize("y", [5, 6], ids=42) # type: ignore[arg-type] # noqa: F821 + metafunc.parametrize("y", [5, 6], ids=42) # type: ignore[arg-type] def test_parametrize_error_iterator(self) -> None: def func(x): @@ -93,7 +98,7 @@ def gen() -> Iterator[Union[int, None, Exc]]: metafunc = self.Metafunc(func) # When the input is an iterator, only len(args) are taken, # so the bad Exc isn't reached. - metafunc.parametrize("x", [1, 2], ids=gen()) # type: ignore[arg-type] # noqa: F821 + metafunc.parametrize("x", [1, 2], ids=gen()) # type: ignore[arg-type] assert [(x.funcargs, x.id) for x in metafunc._calls] == [ ({"x": 1}, "0"), ({"x": 2}, "2"), @@ -105,7 +110,7 @@ def gen() -> Iterator[Union[int, None, Exc]]: r" Exc\(from_gen\) \(type: \) at index 2" ), ): - metafunc.parametrize("x", [1, 2, 3], ids=gen()) # type: ignore[arg-type] # noqa: F821 + metafunc.parametrize("x", [1, 2, 3], ids=gen()) # type: ignore[arg-type] def test_parametrize_bad_scope(self) -> None: def func(x): @@ -116,7 +121,7 @@ def func(x): fail.Exception, match=r"parametrize\(\) call in func got an unexpected scope value 'doggy'", ): - metafunc.parametrize("x", [1], scope="doggy") + metafunc.parametrize("x", [1], scope="doggy") # type: ignore[arg-type] def test_parametrize_request_name(self, testdir: Testdir) -> None: """Show proper error when 'request' is used as a parameter name in parametrize (#6183)""" @@ -132,19 +137,22 @@ def func(request): metafunc.parametrize("request", [1]) def test_find_parametrized_scope(self) -> None: - """unittest for _find_parametrized_scope (#3941)""" + """Unit test for _find_parametrized_scope (#3941).""" from _pytest.python import _find_parametrized_scope @attr.s class DummyFixtureDef: scope = attr.ib() - fixtures_defs = dict( - session_fix=[DummyFixtureDef("session")], - package_fix=[DummyFixtureDef("package")], - module_fix=[DummyFixtureDef("module")], - class_fix=[DummyFixtureDef("class")], - func_fix=[DummyFixtureDef("function")], + fixtures_defs = cast( + Dict[str, Sequence[fixtures.FixtureDef[object]]], + dict( + session_fix=[DummyFixtureDef("session")], + package_fix=[DummyFixtureDef("package")], + module_fix=[DummyFixtureDef("module")], + class_fix=[DummyFixtureDef("class")], + func_fix=[DummyFixtureDef("function")], + ), ) # use arguments to determine narrow scope; the cause of the bug is that it would look on all @@ -273,35 +281,34 @@ class A: deadline=400.0 ) # very close to std deadline and CI boxes are not reliable in CPU power def test_idval_hypothesis(self, value) -> None: - escaped = _idval(value, "a", 6, None, item=None, config=None) + escaped = _idval(value, "a", 6, None, nodeid=None, config=None) assert isinstance(escaped, str) escaped.encode("ascii") def test_unicode_idval(self) -> None: - """This tests that Unicode strings outside the ASCII character set get + """Test that Unicode strings outside the ASCII character set get escaped, using byte escapes if they're in that range or unicode escapes if they're not. """ values = [ - ("", ""), - ("ascii", "ascii"), - ("ação", "a\\xe7\\xe3o"), - ("josé@blah.com", "jos\\xe9@blah.com"), + ("", r""), + ("ascii", r"ascii"), + ("ação", r"a\xe7\xe3o"), + ("josé@blah.com", r"jos\xe9@blah.com"), ( - "δοκ.ιμή@παράδειγμα.δοκιμή", - "\\u03b4\\u03bf\\u03ba.\\u03b9\\u03bc\\u03ae@\\u03c0\\u03b1\\u03c1\\u03ac\\u03b4\\u03b5\\u03b9\\u03b3" - "\\u03bc\\u03b1.\\u03b4\\u03bf\\u03ba\\u03b9\\u03bc\\u03ae", + r"δοκ.ιμή@παράδειγμα.δοκιμή", + r"\u03b4\u03bf\u03ba.\u03b9\u03bc\u03ae@\u03c0\u03b1\u03c1\u03ac\u03b4\u03b5\u03b9\u03b3" + r"\u03bc\u03b1.\u03b4\u03bf\u03ba\u03b9\u03bc\u03ae", ), ] for val, expected in values: - assert _idval(val, "a", 6, None, item=None, config=None) == expected + assert _idval(val, "a", 6, None, nodeid=None, config=None) == expected def test_unicode_idval_with_config(self) -> None: - """unittest for expected behavior to obtain ids with + """Unit test for expected behavior to obtain ids with disable_test_id_escaping_and_forfeit_all_rights_to_community_support - option. (#5294) - """ + option (#5294).""" class MockConfig: def __init__(self, config): @@ -319,34 +326,29 @@ def getini(self, name): option = "disable_test_id_escaping_and_forfeit_all_rights_to_community_support" - values = [ + values: List[Tuple[str, Any, str]] = [ ("ação", MockConfig({option: True}), "ação"), ("ação", MockConfig({option: False}), "a\\xe7\\xe3o"), - ] # type: List[Tuple[str, Any, str]] + ] for val, config, expected in values: - actual = _idval(val, "a", 6, None, item=None, config=config) + actual = _idval(val, "a", 6, None, nodeid=None, config=config) assert actual == expected def test_bytes_idval(self) -> None: - """unittest for the expected behavior to obtain ids for parametrized - bytes values: - - python2: non-ascii strings are considered bytes and formatted using - "binary escape", where any byte < 127 is escaped into its hex form. - - python3: bytes objects are always escaped using "binary escape". - """ + """Unit test for the expected behavior to obtain ids for parametrized + bytes values: bytes objects are always escaped using "binary escape".""" values = [ - (b"", ""), - (b"\xc3\xb4\xff\xe4", "\\xc3\\xb4\\xff\\xe4"), - (b"ascii", "ascii"), - ("αρά".encode(), "\\xce\\xb1\\xcf\\x81\\xce\\xac"), + (b"", r""), + (b"\xc3\xb4\xff\xe4", r"\xc3\xb4\xff\xe4"), + (b"ascii", r"ascii"), + ("αρά".encode(), r"\xce\xb1\xcf\x81\xce\xac"), ] for val, expected in values: - assert _idval(val, "a", 6, idfn=None, item=None, config=None) == expected + assert _idval(val, "a", 6, idfn=None, nodeid=None, config=None) == expected def test_class_or_function_idval(self) -> None: - """unittest for the expected behavior to obtain ids for parametrized - values that are classes or functions: their __name__. - """ + """Unit test for the expected behavior to obtain ids for parametrized + values that are classes or functions: their __name__.""" class TestClass: pass @@ -356,12 +358,18 @@ def test_function(): values = [(TestClass, "TestClass"), (test_function, "test_function")] for val, expected in values: - assert _idval(val, "a", 6, None, item=None, config=None) == expected + assert _idval(val, "a", 6, None, nodeid=None, config=None) == expected + + def test_notset_idval(self) -> None: + """Test that a NOTSET value (used by an empty parameterset) generates + a proper ID. + + Regression test for #7686. + """ + assert _idval(NOTSET, "a", 0, None, nodeid=None, config=None) == "a0" def test_idmaker_autoname(self) -> None: """#250""" - from _pytest.python import idmaker - result = idmaker( ("a", "b"), [pytest.param("string", 1.0), pytest.param("st-ring", 2.0)] ) @@ -376,14 +384,10 @@ def test_idmaker_autoname(self) -> None: assert result == ["a0-\\xc3\\xb4"] def test_idmaker_with_bytes_regex(self) -> None: - from _pytest.python import idmaker - result = idmaker(("a"), [pytest.param(re.compile(b"foo"), 1.0)]) assert result == ["foo"] def test_idmaker_native_strings(self) -> None: - from _pytest.python import idmaker - result = idmaker( ("a", "b"), [ @@ -417,8 +421,6 @@ def test_idmaker_native_strings(self) -> None: ] def test_idmaker_non_printable_characters(self) -> None: - from _pytest.python import idmaker - result = idmaker( ("s", "n"), [ @@ -433,8 +435,6 @@ def test_idmaker_non_printable_characters(self) -> None: assert result == ["\\x00-1", "\\x05-2", "\\x00-3", "\\x05-4", "\\t-5", "\\t-6"] def test_idmaker_manual_ids_must_be_printable(self) -> None: - from _pytest.python import idmaker - result = idmaker( ("s",), [ @@ -445,8 +445,6 @@ def test_idmaker_manual_ids_must_be_printable(self) -> None: assert result == ["hello \\x00", "hello \\x05"] def test_idmaker_enum(self) -> None: - from _pytest.python import idmaker - enum = pytest.importorskip("enum") e = enum.Enum("Foo", "one, two") result = idmaker(("a", "b"), [pytest.param(e.one, e.two)]) @@ -454,7 +452,6 @@ def test_idmaker_enum(self) -> None: def test_idmaker_idfn(self) -> None: """#351""" - from _pytest.python import idmaker def ids(val: object) -> Optional[str]: if isinstance(val, Exception): @@ -474,7 +471,6 @@ def ids(val: object) -> Optional[str]: def test_idmaker_idfn_unique_names(self) -> None: """#351""" - from _pytest.python import idmaker def ids(val: object) -> str: return "a" @@ -491,11 +487,10 @@ def ids(val: object) -> str: assert result == ["a-a0", "a-a1", "a-a2"] def test_idmaker_with_idfn_and_config(self) -> None: - """unittest for expected behavior to create ids with idfn and + """Unit test for expected behavior to create ids with idfn and disable_test_id_escaping_and_forfeit_all_rights_to_community_support - option. (#5294) + option (#5294). """ - from _pytest.python import idmaker class MockConfig: def __init__(self, config): @@ -513,10 +508,10 @@ def getini(self, name): option = "disable_test_id_escaping_and_forfeit_all_rights_to_community_support" - values = [ + values: List[Tuple[Any, str]] = [ (MockConfig({option: True}), "ação"), (MockConfig({option: False}), "a\\xe7\\xe3o"), - ] # type: List[Tuple[Any, str]] + ] for config, expected in values: result = idmaker( ("a",), [pytest.param("string")], idfn=lambda _: "ação", config=config, @@ -524,11 +519,10 @@ def getini(self, name): assert result == [expected] def test_idmaker_with_ids_and_config(self) -> None: - """unittest for expected behavior to create ids with ids and + """Unit test for expected behavior to create ids with ids and disable_test_id_escaping_and_forfeit_all_rights_to_community_support - option. (#5294) + option (#5294). """ - from _pytest.python import idmaker class MockConfig: def __init__(self, config): @@ -546,10 +540,10 @@ def getini(self, name): option = "disable_test_id_escaping_and_forfeit_all_rights_to_community_support" - values = [ + values: List[Tuple[Any, str]] = [ (MockConfig({option: True}), "ação"), (MockConfig({option: False}), "a\\xe7\\xe3o"), - ] # type: List[Tuple[Any, str]] + ] for config, expected in values: result = idmaker( ("a",), [pytest.param("string")], ids=["ação"], config=config, @@ -610,16 +604,12 @@ def test_int(arg): ) def test_idmaker_with_ids(self) -> None: - from _pytest.python import idmaker - result = idmaker( ("a", "b"), [pytest.param(1, 2), pytest.param(3, 4)], ids=["a", None] ) assert result == ["a", "3-4"] def test_idmaker_with_paramset_id(self) -> None: - from _pytest.python import idmaker - result = idmaker( ("a", "b"), [pytest.param(1, 2, id="me"), pytest.param(3, 4, id="you")], @@ -628,8 +618,6 @@ def test_idmaker_with_paramset_id(self) -> None: assert result == ["me", "you"] def test_idmaker_with_ids_unique_names(self) -> None: - from _pytest.python import idmaker - result = idmaker( ("a"), map(pytest.param, [1, 2, 3, 4, 5]), ids=["a", "a", "b", "c", "b"] ) @@ -692,7 +680,7 @@ def func(x, y): fail.Exception, match="In func: expected Sequence or boolean for indirect, got dict", ): - metafunc.parametrize("x, y", [("a", "b")], indirect={}) # type: ignore[arg-type] # noqa: F821 + metafunc.parametrize("x, y", [("a", "b")], indirect={}) # type: ignore[arg-type] def test_parametrize_indirect_list_functional(self, testdir: Testdir) -> None: """ @@ -971,22 +959,22 @@ def test_format_args(self) -> None: def function1(): pass - assert fixtures._format_args(function1) == "()" + assert _format_args(function1) == "()" def function2(arg1): pass - assert fixtures._format_args(function2) == "(arg1)" + assert _format_args(function2) == "(arg1)" def function3(arg1, arg2="qwe"): pass - assert fixtures._format_args(function3) == "(arg1, arg2='qwe')" + assert _format_args(function3) == "(arg1, arg2='qwe')" def function4(arg1, *args, **kwargs): pass - assert fixtures._format_args(function4) == "(arg1, *args, **kwargs)" + assert _format_args(function4) == "(arg1, *args, **kwargs)" class TestMetafuncFunctional: @@ -1429,15 +1417,14 @@ def test_foo(x): ' @pytest.mark.parametrise("x", range(2))', "E Failed: Unknown 'parametrise' mark, did you mean 'parametrize'?", "*! Interrupted: 1 error during collection !*", - "*= 1 error in *", + "*= no tests collected, 1 error in *", ] ) class TestMetafuncFunctionalAuto: - """ - Tests related to automatically find out the correct scope for parametrized tests (#1832). - """ + """Tests related to automatically find out the correct scope for + parametrized tests (#1832).""" def test_parametrize_auto_scope(self, testdir: Testdir) -> None: testdir.makepyfile( @@ -1532,9 +1519,9 @@ def test_parametrize_some_arguments_auto_scope( self, testdir: Testdir, monkeypatch ) -> None: """Integration test for (#3941)""" - class_fix_setup = [] # type: List[object] + class_fix_setup: List[object] = [] monkeypatch.setattr(sys, "class_fix_setup", class_fix_setup, raising=False) - func_fix_setup = [] # type: List[object] + func_fix_setup: List[object] = [] monkeypatch.setattr(sys, "func_fix_setup", func_fix_setup, raising=False) testdir.makepyfile( @@ -1902,51 +1889,3 @@ def test_converted_to_str(a, b): "*= 6 passed in *", ] ) - - def test_parametrize_explicit_parameters_func(self, testdir: Testdir) -> None: - testdir.makepyfile( - """ - import pytest - - - @pytest.fixture - def fixture(arg): - return arg - - @pytest.mark.parametrize("arg", ["baz"]) - def test_without_arg(fixture): - assert "baz" == fixture - """ - ) - result = testdir.runpytest() - result.assert_outcomes(error=1) - result.stdout.fnmatch_lines( - [ - '*In function "test_without_arg"*', - '*Parameter "arg" should be declared explicitly via indirect or in function itself*', - ] - ) - - def test_parametrize_explicit_parameters_method(self, testdir: Testdir) -> None: - testdir.makepyfile( - """ - import pytest - - class Test: - @pytest.fixture - def test_fixture(self, argument): - return argument - - @pytest.mark.parametrize("argument", ["foobar"]) - def test_without_argument(self, test_fixture): - assert "foobar" == test_fixture - """ - ) - result = testdir.runpytest() - result.assert_outcomes(error=1) - result.stdout.fnmatch_lines( - [ - '*In function "test_without_argument"*', - '*Parameter "argument" should be declared explicitly via indirect or in function itself*', - ] - ) diff --git a/testing/python/raises.py b/testing/python/raises.py index 6c607464d54..80634eebfbf 100644 --- a/testing/python/raises.py +++ b/testing/python/raises.py @@ -6,9 +6,9 @@ class TestRaises: - def test_check_callable(self): + def test_check_callable(self) -> None: with pytest.raises(TypeError, match=r".* must be callable"): - pytest.raises(RuntimeError, "int('qwe')") + pytest.raises(RuntimeError, "int('qwe')") # type: ignore[call-overload] def test_raises(self): excinfo = pytest.raises(ValueError, int, "qwe") @@ -18,19 +18,19 @@ def test_raises_function(self): excinfo = pytest.raises(ValueError, int, "hello") assert "invalid literal" in str(excinfo.value) - def test_raises_callable_no_exception(self): + def test_raises_callable_no_exception(self) -> None: class A: def __call__(self): pass try: pytest.raises(ValueError, A()) - except pytest.raises.Exception: + except pytest.fail.Exception: pass - def test_raises_falsey_type_error(self): + def test_raises_falsey_type_error(self) -> None: with pytest.raises(TypeError): - with pytest.raises(AssertionError, match=0): + with pytest.raises(AssertionError, match=0): # type: ignore[call-overload] raise AssertionError("ohai") def test_raises_repr_inflight(self): @@ -126,23 +126,23 @@ def test_division(example_input, expectation): result = testdir.runpytest() result.stdout.fnmatch_lines(["*2 failed*"]) - def test_noclass(self): + def test_noclass(self) -> None: with pytest.raises(TypeError): - pytest.raises("wrong", lambda: None) + pytest.raises("wrong", lambda: None) # type: ignore[call-overload] - def test_invalid_arguments_to_raises(self): + def test_invalid_arguments_to_raises(self) -> None: with pytest.raises(TypeError, match="unknown"): - with pytest.raises(TypeError, unknown="bogus"): + with pytest.raises(TypeError, unknown="bogus"): # type: ignore[call-overload] raise ValueError() def test_tuple(self): with pytest.raises((KeyError, ValueError)): raise KeyError("oops") - def test_no_raise_message(self): + def test_no_raise_message(self) -> None: try: pytest.raises(ValueError, int, "0") - except pytest.raises.Exception as e: + except pytest.fail.Exception as e: assert e.msg == "DID NOT RAISE {}".format(repr(ValueError)) else: assert False, "Expected pytest.raises.Exception" @@ -150,25 +150,18 @@ def test_no_raise_message(self): try: with pytest.raises(ValueError): pass - except pytest.raises.Exception as e: + except pytest.fail.Exception as e: assert e.msg == "DID NOT RAISE {}".format(repr(ValueError)) else: assert False, "Expected pytest.raises.Exception" @pytest.mark.parametrize("method", ["function", "function_match", "with"]) def test_raises_cyclic_reference(self, method): - """ - Ensure pytest.raises does not leave a reference cycle (#1965). - """ + """Ensure pytest.raises does not leave a reference cycle (#1965).""" import gc class T: def __call__(self): - # Early versions of Python 3.5 have some bug causing the - # __call__ frame to still refer to t even after everything - # is done. This makes the test pass for them. - if sys.version_info < (3, 5, 2): - del self raise ValueError t = T() @@ -197,7 +190,7 @@ def test_raises_match(self) -> None: int("asdf") msg = "with base 16" - expr = "Pattern {!r} does not match \"invalid literal for int() with base 10: 'asdf'\"".format( + expr = "Regex pattern {!r} does not match \"invalid literal for int() with base 10: 'asdf'\".".format( msg ) with pytest.raises(AssertionError, match=re.escape(expr)): @@ -213,7 +206,7 @@ def test_raises_match(self) -> None: pytest.raises(TypeError, int, match="invalid") def tfunc(match): - raise ValueError("match={}".format(match)) + raise ValueError(f"match={match}") pytest.raises(ValueError, tfunc, match="asdf").match("match=asdf") pytest.raises(ValueError, tfunc, match="").match("match=") @@ -223,7 +216,19 @@ def test_match_failure_string_quoting(self): with pytest.raises(AssertionError, match="'foo"): raise AssertionError("'bar") (msg,) = excinfo.value.args - assert msg == 'Pattern "\'foo" does not match "\'bar"' + assert msg == 'Regex pattern "\'foo" does not match "\'bar".' + + def test_match_failure_exact_string_message(self): + message = "Oh here is a message with (42) numbers in parameters" + with pytest.raises(AssertionError) as excinfo: + with pytest.raises(AssertionError, match=message): + raise AssertionError(message) + (msg,) = excinfo.value.args + assert msg == ( + "Regex pattern 'Oh here is a message with (42) numbers in " + "parameters' does not match 'Oh here is a message with (42) " + "numbers in parameters'. Did you mean to `re.escape()` the regex?" + ) def test_raises_match_wrong_type(self): """Raising an exception with the wrong type and match= given. @@ -252,7 +257,7 @@ class ClassLooksIterableException(Exception, metaclass=Meta): ): pytest.raises(ClassLooksIterableException, lambda: None) - def test_raises_with_raising_dunder_class(self): + def test_raises_with_raising_dunder_class(self) -> None: """Test current behavior with regard to exceptions via __class__ (#4284).""" class CrappyClass(Exception): @@ -262,12 +267,31 @@ def __class__(self): assert False, "via __class__" with pytest.raises(AssertionError) as excinfo: - with pytest.raises(CrappyClass()): + with pytest.raises(CrappyClass()): # type: ignore[call-overload] pass assert "via __class__" in excinfo.value.args[0] def test_raises_context_manager_with_kwargs(self): with pytest.raises(TypeError) as excinfo: - with pytest.raises(Exception, foo="bar"): + with pytest.raises(Exception, foo="bar"): # type: ignore[call-overload] pass assert "Unexpected keyword arguments" in str(excinfo.value) + + def test_expected_exception_is_not_a_baseexception(self) -> None: + with pytest.raises(TypeError) as excinfo: + with pytest.raises("hello"): # type: ignore[call-overload] + pass # pragma: no cover + assert "must be a BaseException type, not str" in str(excinfo.value) + + class NotAnException: + pass + + with pytest.raises(TypeError) as excinfo: + with pytest.raises(NotAnException): # type: ignore[type-var] + pass # pragma: no cover + assert "must be a BaseException type, not NotAnException" in str(excinfo.value) + + with pytest.raises(TypeError) as excinfo: + with pytest.raises(("hello", NotAnException)): # type: ignore[arg-type] + pass # pragma: no cover + assert "must be a BaseException type, not str" in str(excinfo.value) diff --git a/testing/test_argcomplete.py b/testing/test_argcomplete.py index 7ccca11ba70..a3224be5126 100644 --- a/testing/test_argcomplete.py +++ b/testing/test_argcomplete.py @@ -3,7 +3,7 @@ import pytest -# test for _argcomplete but not specific for any application +# Test for _argcomplete but not specific for any application. def equal_with_bash(prefix, ffc, fc, out=None): @@ -11,16 +11,16 @@ def equal_with_bash(prefix, ffc, fc, out=None): res_bash = set(fc(prefix)) retval = set(res) == res_bash if out: - out.write("equal_with_bash({}) {} {}\n".format(prefix, retval, res)) + out.write(f"equal_with_bash({prefix}) {retval} {res}\n") if not retval: out.write(" python - bash: %s\n" % (set(res) - res_bash)) out.write(" bash - python: %s\n" % (res_bash - set(res))) return retval -# copied from argcomplete.completers as import from there -# also pulls in argcomplete.__init__ which opens filedescriptor 9 -# this gives an IOError at the end of testrun +# Copied from argcomplete.completers as import from there. +# Also pulls in argcomplete.__init__ which opens filedescriptor 9. +# This gives an OSError at the end of testrun. def _wrapcall(*args, **kargs): @@ -31,7 +31,7 @@ def _wrapcall(*args, **kargs): class FilesCompleter: - "File completer class, optionally takes a list of allowed extensions" + """File completer class, optionally takes a list of allowed extensions.""" def __init__(self, allowednames=(), directories=True): # Fix if someone passes in a string instead of a list @@ -45,26 +45,16 @@ def __call__(self, prefix, **kwargs): completion = [] if self.allowednames: if self.directories: - files = _wrapcall( - ["bash", "-c", "compgen -A directory -- '{p}'".format(p=prefix)] - ) + files = _wrapcall(["bash", "-c", f"compgen -A directory -- '{prefix}'"]) completion += [f + "/" for f in files] for x in self.allowednames: completion += _wrapcall( - [ - "bash", - "-c", - "compgen -A file -X '!*.{0}' -- '{p}'".format(x, p=prefix), - ] + ["bash", "-c", f"compgen -A file -X '!*.{x}' -- '{prefix}'"] ) else: - completion += _wrapcall( - ["bash", "-c", "compgen -A file -- '{p}'".format(p=prefix)] - ) + completion += _wrapcall(["bash", "-c", f"compgen -A file -- '{prefix}'"]) - anticomp = _wrapcall( - ["bash", "-c", "compgen -A directory -- '{p}'".format(p=prefix)] - ) + anticomp = _wrapcall(["bash", "-c", f"compgen -A directory -- '{prefix}'"]) completion = list(set(completion) - set(anticomp)) @@ -91,9 +81,7 @@ def test_compare_with_compgen(self, tmpdir): @pytest.mark.skipif("sys.platform in ('win32', 'darwin')") def test_remove_dir_prefix(self): - """this is not compatible with compgen but it is with bash itself: - ls /usr/ - """ + """This is not compatible with compgen but it is with bash itself: ls /usr/.""" from _pytest._argcomplete import FastFilesCompleter ffc = FastFilesCompleter() diff --git a/testing/test_assertion.py b/testing/test_assertion.py index 3ce0f93e66e..289fe5b083f 100644 --- a/testing/test_assertion.py +++ b/testing/test_assertion.py @@ -1,8 +1,9 @@ -import collections.abc as collections_abc +import collections import sys import textwrap from typing import Any from typing import List +from typing import MutableSequence from typing import Optional import attr @@ -12,7 +13,7 @@ from _pytest import outcomes from _pytest.assertion import truncate from _pytest.assertion import util -from _pytest.compat import ATTRS_EQ_FIELD +from _pytest.pytester import Pytester def mock_config(verbose=0): @@ -28,11 +29,12 @@ def getoption(self, name): class TestImportHookInstallation: @pytest.mark.parametrize("initial_conftest", [True, False]) @pytest.mark.parametrize("mode", ["plain", "rewrite"]) - def test_conftest_assertion_rewrite(self, testdir, initial_conftest, mode): - """Test that conftest files are using assertion rewrite on import. - (#1619) - """ - testdir.tmpdir.join("foo/tests").ensure(dir=1) + def test_conftest_assertion_rewrite( + self, pytester: Pytester, initial_conftest, mode + ) -> None: + """Test that conftest files are using assertion rewrite on import (#1619).""" + pytester.mkdir("foo") + pytester.mkdir("foo/tests") conftest_path = "conftest.py" if initial_conftest else "foo/conftest.py" contents = { conftest_path: """ @@ -48,8 +50,8 @@ def test(check_first): check_first([10, 30], 30) """, } - testdir.makepyfile(**contents) - result = testdir.runpytest_subprocess("--assert=%s" % mode) + pytester.makepyfile(**contents) + result = pytester.runpytest_subprocess("--assert=%s" % mode) if mode == "plain": expected = "E AssertionError" elif mode == "rewrite": @@ -58,21 +60,21 @@ def test(check_first): assert 0 result.stdout.fnmatch_lines([expected]) - def test_rewrite_assertions_pytester_plugin(self, testdir): + def test_rewrite_assertions_pytester_plugin(self, pytester: Pytester) -> None: """ Assertions in the pytester plugin must also benefit from assertion rewriting (#1920). """ - testdir.makepyfile( + pytester.makepyfile( """ pytest_plugins = ['pytester'] - def test_dummy_failure(testdir): # how meta! - testdir.makepyfile('def test(): assert 0') - r = testdir.inline_run() + def test_dummy_failure(pytester): # how meta! + pytester.makepyfile('def test(): assert 0') + r = pytester.inline_run() r.assertoutcome(passed=1) """ ) - result = testdir.runpytest_subprocess() + result = pytester.runpytest_subprocess() result.stdout.fnmatch_lines( [ "> r.assertoutcome(passed=1)", @@ -92,7 +94,7 @@ def test_dummy_failure(testdir): # how meta! ) @pytest.mark.parametrize("mode", ["plain", "rewrite"]) - def test_pytest_plugins_rewrite(self, testdir, mode): + def test_pytest_plugins_rewrite(self, pytester: Pytester, mode) -> None: contents = { "conftest.py": """ pytest_plugins = ['ham'] @@ -110,8 +112,8 @@ def test_foo(check_first): check_first([10, 30], 30) """, } - testdir.makepyfile(**contents) - result = testdir.runpytest_subprocess("--assert=%s" % mode) + pytester.makepyfile(**contents) + result = pytester.runpytest_subprocess("--assert=%s" % mode) if mode == "plain": expected = "E AssertionError" elif mode == "rewrite": @@ -121,7 +123,9 @@ def test_foo(check_first): result.stdout.fnmatch_lines([expected]) @pytest.mark.parametrize("mode", ["str", "list"]) - def test_pytest_plugins_rewrite_module_names(self, testdir, mode): + def test_pytest_plugins_rewrite_module_names( + self, pytester: Pytester, mode + ) -> None: """Test that pluginmanager correct marks pytest_plugins variables for assertion rewriting if they are defined as plain strings or list of strings (#1888). @@ -141,11 +145,13 @@ def test_foo(pytestconfig): assert 'ham' in pytestconfig.pluginmanager.rewrite_hook._must_rewrite """, } - testdir.makepyfile(**contents) - result = testdir.runpytest_subprocess("--assert=rewrite") + pytester.makepyfile(**contents) + result = pytester.runpytest_subprocess("--assert=rewrite") assert result.ret == 0 - def test_pytest_plugins_rewrite_module_names_correctly(self, testdir): + def test_pytest_plugins_rewrite_module_names_correctly( + self, pytester: Pytester + ) -> None: """Test that we match files correctly when they are marked for rewriting (#2939).""" contents = { "conftest.py": """\ @@ -159,16 +165,18 @@ def test_foo(pytestconfig): assert pytestconfig.pluginmanager.rewrite_hook.find_spec('hamster') is None """, } - testdir.makepyfile(**contents) - result = testdir.runpytest_subprocess("--assert=rewrite") + pytester.makepyfile(**contents) + result = pytester.runpytest_subprocess("--assert=rewrite") assert result.ret == 0 @pytest.mark.parametrize("mode", ["plain", "rewrite"]) - def test_installed_plugin_rewrite(self, testdir, mode, monkeypatch): + def test_installed_plugin_rewrite( + self, pytester: Pytester, mode, monkeypatch + ) -> None: monkeypatch.delenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD", raising=False) # Make sure the hook is installed early enough so that plugins # installed via setuptools are rewritten. - testdir.tmpdir.join("hampkg").ensure(dir=1) + pytester.mkdir("hampkg") contents = { "hampkg/__init__.py": """\ import pytest @@ -222,8 +230,8 @@ def test2(check_first2): check_first([10, 30], 30) """, } - testdir.makepyfile(**contents) - result = testdir.run( + pytester.makepyfile(**contents) + result = pytester.run( sys.executable, "mainwrapper.py", "-s", "--assert=%s" % mode ) if mode == "plain": @@ -234,8 +242,8 @@ def test2(check_first2): assert 0 result.stdout.fnmatch_lines([expected]) - def test_rewrite_ast(self, testdir): - testdir.tmpdir.join("pkg").ensure(dir=1) + def test_rewrite_ast(self, pytester: Pytester) -> None: + pytester.mkdir("pkg") contents = { "pkg/__init__.py": """ import pytest @@ -268,8 +276,8 @@ def test_other(): pkg.other.tool() """, } - testdir.makepyfile(**contents) - result = testdir.runpytest_subprocess("--assert=rewrite") + pytester.makepyfile(**contents) + result = pytester.runpytest_subprocess("--assert=rewrite") result.stdout.fnmatch_lines( [ ">*assert a == b*", @@ -279,17 +287,17 @@ def test_other(): ] ) - def test_register_assert_rewrite_checks_types(self): + def test_register_assert_rewrite_checks_types(self) -> None: with pytest.raises(TypeError): - pytest.register_assert_rewrite(["pytest_tests_internal_non_existing"]) + pytest.register_assert_rewrite(["pytest_tests_internal_non_existing"]) # type: ignore pytest.register_assert_rewrite( "pytest_tests_internal_non_existing", "pytest_tests_internal_non_existing2" ) class TestBinReprIntegration: - def test_pytest_assertrepr_compare_called(self, testdir): - testdir.makeconftest( + def test_pytest_assertrepr_compare_called(self, pytester: Pytester) -> None: + pytester.makeconftest( """ import pytest values = [] @@ -301,7 +309,7 @@ def list(request): return values """ ) - testdir.makepyfile( + pytester.makepyfile( """ def test_hello(): assert 0 == 1 @@ -309,7 +317,7 @@ def test_check(list): assert list == [("==", 0, 1)] """ ) - result = testdir.runpytest("-v") + result = pytester.runpytest("-v") result.stdout.fnmatch_lines(["*test_hello*FAIL*", "*test_check*PASS*"]) @@ -323,39 +331,44 @@ def callequal(left: Any, right: Any, verbose: int = 0) -> Optional[List[str]]: class TestAssert_reprcompare: - def test_different_types(self): + def test_different_types(self) -> None: assert callequal([0, 1], "foo") is None - def test_summary(self): - summary = callequal([0, 1], [0, 2])[0] + def test_summary(self) -> None: + lines = callequal([0, 1], [0, 2]) + assert lines is not None + summary = lines[0] assert len(summary) < 65 - def test_text_diff(self): + def test_text_diff(self) -> None: assert callequal("spam", "eggs") == [ "'spam' == 'eggs'", "- eggs", "+ spam", ] - def test_text_skipping(self): + def test_text_skipping(self) -> None: lines = callequal("a" * 50 + "spam", "a" * 50 + "eggs") + assert lines is not None assert "Skipping" in lines[1] for line in lines: assert "a" * 50 not in line - def test_text_skipping_verbose(self): + def test_text_skipping_verbose(self) -> None: lines = callequal("a" * 50 + "spam", "a" * 50 + "eggs", verbose=1) + assert lines is not None assert "- " + "a" * 50 + "eggs" in lines assert "+ " + "a" * 50 + "spam" in lines - def test_multiline_text_diff(self): + def test_multiline_text_diff(self) -> None: left = "foo\nspam\nbar" right = "foo\neggs\nbar" diff = callequal(left, right) + assert diff is not None assert "- eggs" in diff assert "+ spam" in diff - def test_bytes_diff_normal(self): + def test_bytes_diff_normal(self) -> None: """Check special handling for bytes diff (#5260)""" diff = callequal(b"spam", b"eggs") @@ -365,7 +378,7 @@ def test_bytes_diff_normal(self): "Use -v to get the full diff", ] - def test_bytes_diff_verbose(self): + def test_bytes_diff_verbose(self) -> None: """Check special handling for bytes diff (#5260)""" diff = callequal(b"spam", b"eggs", verbose=1) assert diff == [ @@ -376,8 +389,9 @@ def test_bytes_diff_verbose(self): "+ b'spam'", ] - def test_list(self): + def test_list(self) -> None: expl = callequal([0, 1], [0, 2]) + assert expl is not None assert len(expl) > 1 @pytest.mark.parametrize( @@ -421,24 +435,28 @@ def test_list(self): ), ], ) - def test_iterable_full_diff(self, left, right, expected): + def test_iterable_full_diff(self, left, right, expected) -> None: """Test the full diff assertion failure explanation. When verbose is False, then just a -v notice to get the diff is rendered, when verbose is True, then ndiff of the pprint is returned. """ expl = callequal(left, right, verbose=0) + assert expl is not None assert expl[-1] == "Use -v to get the full diff" - expl = "\n".join(callequal(left, right, verbose=1)) - assert expl.endswith(textwrap.dedent(expected).strip()) + verbose_expl = callequal(left, right, verbose=1) + assert verbose_expl is not None + assert "\n".join(verbose_expl).endswith(textwrap.dedent(expected).strip()) - def test_list_different_lengths(self): + def test_list_different_lengths(self) -> None: expl = callequal([0, 1], [0, 1, 2]) + assert expl is not None assert len(expl) > 1 expl = callequal([0, 1, 2], [0, 1]) + assert expl is not None assert len(expl) > 1 - def test_list_wrap_for_multiple_lines(self): + def test_list_wrap_for_multiple_lines(self) -> None: long_d = "d" * 80 l1 = ["a", "b", "c"] l2 = ["a", "b", "c", long_d] @@ -468,7 +486,7 @@ def test_list_wrap_for_multiple_lines(self): " ]", ] - def test_list_wrap_for_width_rewrap_same_length(self): + def test_list_wrap_for_width_rewrap_same_length(self) -> None: long_a = "a" * 30 long_b = "b" * 30 long_c = "c" * 30 @@ -487,7 +505,7 @@ def test_list_wrap_for_width_rewrap_same_length(self): " ]", ] - def test_list_dont_wrap_strings(self): + def test_list_dont_wrap_strings(self) -> None: long_a = "a" * 10 l1 = ["a"] + [long_a for _ in range(0, 7)] l2 = ["should not get wrapped"] @@ -510,7 +528,7 @@ def test_list_dont_wrap_strings(self): " ]", ] - def test_dict_wrap(self): + def test_dict_wrap(self) -> None: d1 = {"common": 1, "env": {"env1": 1, "env2": 2}} d2 = {"common": 1, "env": {"env1": 1}} @@ -545,32 +563,36 @@ def test_dict_wrap(self): " }", ] - def test_dict(self): + def test_dict(self) -> None: expl = callequal({"a": 0}, {"a": 1}) + assert expl is not None assert len(expl) > 1 - def test_dict_omitting(self): + def test_dict_omitting(self) -> None: lines = callequal({"a": 0, "b": 1}, {"a": 1, "b": 1}) + assert lines is not None assert lines[1].startswith("Omitting 1 identical item") assert "Common items" not in lines for line in lines[1:]: assert "b" not in line - def test_dict_omitting_with_verbosity_1(self): - """ Ensure differing items are visible for verbosity=1 (#1512) """ + def test_dict_omitting_with_verbosity_1(self) -> None: + """Ensure differing items are visible for verbosity=1 (#1512).""" lines = callequal({"a": 0, "b": 1}, {"a": 1, "b": 1}, verbose=1) + assert lines is not None assert lines[1].startswith("Omitting 1 identical item") assert lines[2].startswith("Differing items") assert lines[3] == "{'a': 0} != {'a': 1}" assert "Common items" not in lines - def test_dict_omitting_with_verbosity_2(self): + def test_dict_omitting_with_verbosity_2(self) -> None: lines = callequal({"a": 0, "b": 1}, {"a": 1, "b": 1}, verbose=2) + assert lines is not None assert lines[1].startswith("Common items:") assert "Omitting" not in lines[1] assert lines[2] == "{'b': 1}" - def test_dict_different_items(self): + def test_dict_different_items(self) -> None: lines = callequal({"a": 0}, {"b": 1, "c": 2}, verbose=2) assert lines == [ "{'a': 0} == {'b': 1, 'c': 2}", @@ -594,7 +616,7 @@ def test_dict_different_items(self): "+ {'b': 1, 'c': 2}", ] - def test_sequence_different_items(self): + def test_sequence_different_items(self) -> None: lines = callequal((1, 2), (3, 4, 5), verbose=2) assert lines == [ "(1, 2) == (3, 4, 5)", @@ -614,20 +636,19 @@ def test_sequence_different_items(self): "+ (1, 2, 3)", ] - def test_set(self): + def test_set(self) -> None: expl = callequal({0, 1}, {0, 2}) + assert expl is not None assert len(expl) > 1 - def test_frozenzet(self): + def test_frozenzet(self) -> None: expl = callequal(frozenset([0, 1]), {0, 2}) + assert expl is not None assert len(expl) > 1 - def test_Sequence(self): - if not hasattr(collections_abc, "MutableSequence"): - pytest.skip("cannot import MutableSequence") - MutableSequence = collections_abc.MutableSequence - - class TestSequence(MutableSequence): # works with a Sequence subclass + def test_Sequence(self) -> None: + # Test comparing with a Sequence subclass. + class TestSequence(MutableSequence[int]): def __init__(self, iterable): self.elements = list(iterable) @@ -647,15 +668,18 @@ def insert(self, item, index): pass expl = callequal(TestSequence([0, 1]), list([0, 2])) + assert expl is not None assert len(expl) > 1 - def test_list_tuples(self): + def test_list_tuples(self) -> None: expl = callequal([], [(1, 2)]) + assert expl is not None assert len(expl) > 1 expl = callequal([(1, 2)], []) + assert expl is not None assert len(expl) > 1 - def test_repr_verbose(self): + def test_repr_verbose(self) -> None: class Nums: def __init__(self, nums): self.nums = nums @@ -672,21 +696,25 @@ def __repr__(self): assert callequal(nums_x, nums_y) is None expl = callequal(nums_x, nums_y, verbose=1) + assert expl is not None assert "+" + repr(nums_x) in expl assert "-" + repr(nums_y) in expl expl = callequal(nums_x, nums_y, verbose=2) + assert expl is not None assert "+" + repr(nums_x) in expl assert "-" + repr(nums_y) in expl - def test_list_bad_repr(self): + def test_list_bad_repr(self) -> None: class A: def __repr__(self): raise ValueError(42) expl = callequal([], [A()]) + assert expl is not None assert "ValueError" in "".join(expl) expl = callequal({}, {"1": A()}, verbose=2) + assert expl is not None assert expl[0].startswith("{} == <[ValueError") assert "raised in repr" in expl[0] assert expl[1:] == [ @@ -697,11 +725,8 @@ def __repr__(self): " Probably an object has a faulty __repr__.)", ] - def test_one_repr_empty(self): - """ - the faulty empty string repr did trigger - an unbound local error in _diff_text - """ + def test_one_repr_empty(self) -> None: + """The faulty empty string repr did trigger an unbound local error in _diff_text.""" class A(str): def __repr__(self): @@ -710,18 +735,19 @@ def __repr__(self): expl = callequal(A(), "") assert not expl - def test_repr_no_exc(self): - expl = " ".join(callequal("foo", "bar")) - assert "raised in repr()" not in expl + def test_repr_no_exc(self) -> None: + expl = callequal("foo", "bar") + assert expl is not None + assert "raised in repr()" not in " ".join(expl) - def test_unicode(self): + def test_unicode(self) -> None: assert callequal("£€", "£") == [ "'£€' == '£'", "- £", "+ £€", ] - def test_nonascii_text(self): + def test_nonascii_text(self) -> None: """ :issue: 877 non ascii python2 str caused a UnicodeDecodeError @@ -734,14 +760,15 @@ def __repr__(self): expl = callequal(A(), "1") assert expl == ["ÿ == '1'", "- 1"] - def test_format_nonascii_explanation(self): + def test_format_nonascii_explanation(self) -> None: assert util.format_explanation("λ") - def test_mojibake(self): + def test_mojibake(self) -> None: # issue 429 left = b"e" right = b"\xc3\xa9" expl = callequal(left, right) + assert expl is not None for line in expl: assert isinstance(line, str) msg = "\n".join(expl) @@ -750,22 +777,80 @@ def test_mojibake(self): class TestAssert_reprcompare_dataclass: @pytest.mark.skipif(sys.version_info < (3, 7), reason="Dataclasses in Python3.7+") - def test_dataclasses(self, testdir): - p = testdir.copy_example("dataclasses/test_compare_dataclasses.py") - result = testdir.runpytest(p) + def test_dataclasses(self, pytester: Pytester) -> None: + p = pytester.copy_example("dataclasses/test_compare_dataclasses.py") + result = pytester.runpytest(p) result.assert_outcomes(failed=1, passed=0) result.stdout.fnmatch_lines( [ - "*Omitting 1 identical items, use -vv to show*", - "*Differing attributes:*", - "*field_b: 'b' != 'c'*", - ] + "E Omitting 1 identical items, use -vv to show", + "E Differing attributes:", + "E ['field_b']", + "E ", + "E Drill down into differing attribute field_b:", + "E field_b: 'b' != 'c'...", + "E ", + "E ...Full output truncated (3 lines hidden), use '-vv' to show", + ], + consecutive=True, + ) + + @pytest.mark.skipif(sys.version_info < (3, 7), reason="Dataclasses in Python3.7+") + def test_recursive_dataclasses(self, pytester: Pytester) -> None: + p = pytester.copy_example("dataclasses/test_compare_recursive_dataclasses.py") + result = pytester.runpytest(p) + result.assert_outcomes(failed=1, passed=0) + result.stdout.fnmatch_lines( + [ + "E Omitting 1 identical items, use -vv to show", + "E Differing attributes:", + "E ['g', 'h', 'j']", + "E ", + "E Drill down into differing attribute g:", + "E g: S(a=10, b='ten') != S(a=20, b='xxx')...", + "E ", + "E ...Full output truncated (52 lines hidden), use '-vv' to show", + ], + consecutive=True, + ) + + @pytest.mark.skipif(sys.version_info < (3, 7), reason="Dataclasses in Python3.7+") + def test_recursive_dataclasses_verbose(self, pytester: Pytester) -> None: + p = pytester.copy_example("dataclasses/test_compare_recursive_dataclasses.py") + result = pytester.runpytest(p, "-vv") + result.assert_outcomes(failed=1, passed=0) + result.stdout.fnmatch_lines( + [ + "E Matching attributes:", + "E ['i']", + "E Differing attributes:", + "E ['g', 'h', 'j']", + "E ", + "E Drill down into differing attribute g:", + "E g: S(a=10, b='ten') != S(a=20, b='xxx')", + "E ", + "E Differing attributes:", + "E ['a', 'b']", + "E ", + "E Drill down into differing attribute a:", + "E a: 10 != 20", + "E +10", + "E -20", + "E ", + "E Drill down into differing attribute b:", + "E b: 'ten' != 'xxx'", + "E - xxx", + "E + ten", + "E ", + "E Drill down into differing attribute h:", + ], + consecutive=True, ) @pytest.mark.skipif(sys.version_info < (3, 7), reason="Dataclasses in Python3.7+") - def test_dataclasses_verbose(self, testdir): - p = testdir.copy_example("dataclasses/test_compare_dataclasses_verbose.py") - result = testdir.runpytest(p, "-vv") + def test_dataclasses_verbose(self, pytester: Pytester) -> None: + p = pytester.copy_example("dataclasses/test_compare_dataclasses_verbose.py") + result = pytester.runpytest(p, "-vv") result.assert_outcomes(failed=1, passed=0) result.stdout.fnmatch_lines( [ @@ -777,24 +862,26 @@ def test_dataclasses_verbose(self, testdir): ) @pytest.mark.skipif(sys.version_info < (3, 7), reason="Dataclasses in Python3.7+") - def test_dataclasses_with_attribute_comparison_off(self, testdir): - p = testdir.copy_example( + def test_dataclasses_with_attribute_comparison_off( + self, pytester: Pytester + ) -> None: + p = pytester.copy_example( "dataclasses/test_compare_dataclasses_field_comparison_off.py" ) - result = testdir.runpytest(p, "-vv") + result = pytester.runpytest(p, "-vv") result.assert_outcomes(failed=0, passed=1) @pytest.mark.skipif(sys.version_info < (3, 7), reason="Dataclasses in Python3.7+") - def test_comparing_two_different_data_classes(self, testdir): - p = testdir.copy_example( + def test_comparing_two_different_data_classes(self, pytester: Pytester) -> None: + p = pytester.copy_example( "dataclasses/test_compare_two_different_dataclasses.py" ) - result = testdir.runpytest(p, "-vv") + result = pytester.runpytest(p, "-vv") result.assert_outcomes(failed=0, passed=1) class TestAssert_reprcompare_attrsclass: - def test_attrs(self): + def test_attrs(self) -> None: @attr.s class SimpleDataObject: field_a = attr.ib() @@ -804,12 +891,53 @@ class SimpleDataObject: right = SimpleDataObject(1, "c") lines = callequal(left, right) - assert lines[1].startswith("Omitting 1 identical item") + assert lines is not None + assert lines[2].startswith("Omitting 1 identical item") assert "Matching attributes" not in lines - for line in lines[1:]: + for line in lines[2:]: assert "field_a" not in line - def test_attrs_verbose(self): + def test_attrs_recursive(self) -> None: + @attr.s + class OtherDataObject: + field_c = attr.ib() + field_d = attr.ib() + + @attr.s + class SimpleDataObject: + field_a = attr.ib() + field_b = attr.ib() + + left = SimpleDataObject(OtherDataObject(1, "a"), "b") + right = SimpleDataObject(OtherDataObject(1, "b"), "b") + + lines = callequal(left, right) + assert lines is not None + assert "Matching attributes" not in lines + for line in lines[1:]: + assert "field_b:" not in line + assert "field_c:" not in line + + def test_attrs_recursive_verbose(self) -> None: + @attr.s + class OtherDataObject: + field_c = attr.ib() + field_d = attr.ib() + + @attr.s + class SimpleDataObject: + field_a = attr.ib() + field_b = attr.ib() + + left = SimpleDataObject(OtherDataObject(1, "a"), "b") + right = SimpleDataObject(OtherDataObject(1, "b"), "b") + + lines = callequal(left, right) + assert lines is not None + # indentation in output because of nested object structure + assert " field_d: 'a' != 'b'" in lines + + def test_attrs_verbose(self) -> None: @attr.s class SimpleDataObject: field_a = attr.ib() @@ -819,27 +947,30 @@ class SimpleDataObject: right = SimpleDataObject(1, "c") lines = callequal(left, right, verbose=2) - assert lines[1].startswith("Matching attributes:") - assert "Omitting" not in lines[1] - assert lines[2] == "['field_a']" + assert lines is not None + assert lines[2].startswith("Matching attributes:") + assert "Omitting" not in lines[2] + assert lines[3] == "['field_a']" - def test_attrs_with_attribute_comparison_off(self): + def test_attrs_with_attribute_comparison_off(self) -> None: @attr.s class SimpleDataObject: field_a = attr.ib() - field_b = attr.ib(**{ATTRS_EQ_FIELD: False}) + field_b = attr.ib(eq=False) left = SimpleDataObject(1, "b") right = SimpleDataObject(1, "b") lines = callequal(left, right, verbose=2) - assert lines[1].startswith("Matching attributes:") + print(lines) + assert lines is not None + assert lines[2].startswith("Matching attributes:") assert "Omitting" not in lines[1] - assert lines[2] == "['field_a']" - for line in lines[2:]: + assert lines[3] == "['field_a']" + for line in lines[3:]: assert "field_b" not in line - def test_comparing_two_different_attrs_classes(self): + def test_comparing_two_different_attrs_classes(self) -> None: @attr.s class SimpleDataObjectOne: field_a = attr.ib() @@ -857,49 +988,87 @@ class SimpleDataObjectTwo: assert lines is None +class TestAssert_reprcompare_namedtuple: + def test_namedtuple(self) -> None: + NT = collections.namedtuple("NT", ["a", "b"]) + + left = NT(1, "b") + right = NT(1, "c") + + lines = callequal(left, right) + assert lines == [ + "NT(a=1, b='b') == NT(a=1, b='c')", + "", + "Omitting 1 identical items, use -vv to show", + "Differing attributes:", + "['b']", + "", + "Drill down into differing attribute b:", + " b: 'b' != 'c'", + " - c", + " + b", + "Use -v to get the full diff", + ] + + def test_comparing_two_different_namedtuple(self) -> None: + NT1 = collections.namedtuple("NT1", ["a", "b"]) + NT2 = collections.namedtuple("NT2", ["a", "b"]) + + left = NT1(1, "b") + right = NT2(2, "b") + + lines = callequal(left, right) + # Because the types are different, uses the generic sequence matcher. + assert lines == [ + "NT1(a=1, b='b') == NT2(a=2, b='b')", + "At index 0 diff: 1 != 2", + "Use -v to get the full diff", + ] + + class TestFormatExplanation: - def test_special_chars_full(self, testdir): + def test_special_chars_full(self, pytester: Pytester) -> None: # Issue 453, for the bug this would raise IndexError - testdir.makepyfile( + pytester.makepyfile( """ def test_foo(): assert '\\n}' == '' """ ) - result = testdir.runpytest() + result = pytester.runpytest() assert result.ret == 1 result.stdout.fnmatch_lines(["*AssertionError*"]) - def test_fmt_simple(self): + def test_fmt_simple(self) -> None: expl = "assert foo" assert util.format_explanation(expl) == "assert foo" - def test_fmt_where(self): + def test_fmt_where(self) -> None: expl = "\n".join(["assert 1", "{1 = foo", "} == 2"]) res = "\n".join(["assert 1 == 2", " + where 1 = foo"]) assert util.format_explanation(expl) == res - def test_fmt_and(self): + def test_fmt_and(self) -> None: expl = "\n".join(["assert 1", "{1 = foo", "} == 2", "{2 = bar", "}"]) res = "\n".join(["assert 1 == 2", " + where 1 = foo", " + and 2 = bar"]) assert util.format_explanation(expl) == res - def test_fmt_where_nested(self): + def test_fmt_where_nested(self) -> None: expl = "\n".join(["assert 1", "{1 = foo", "{foo = bar", "}", "} == 2"]) res = "\n".join(["assert 1 == 2", " + where 1 = foo", " + where foo = bar"]) assert util.format_explanation(expl) == res - def test_fmt_newline(self): + def test_fmt_newline(self) -> None: expl = "\n".join(['assert "foo" == "bar"', "~- foo", "~+ bar"]) res = "\n".join(['assert "foo" == "bar"', " - foo", " + bar"]) assert util.format_explanation(expl) == res - def test_fmt_newline_escaped(self): + def test_fmt_newline_escaped(self) -> None: expl = "\n".join(["assert foo == bar", "baz"]) res = "assert foo == bar\\nbaz" assert util.format_explanation(expl) == res - def test_fmt_newline_before_where(self): + def test_fmt_newline_before_where(self) -> None: expl = "\n".join( [ "the assertion message here", @@ -920,7 +1089,7 @@ def test_fmt_newline_before_where(self): ) assert util.format_explanation(expl) == res - def test_fmt_multi_newline_before_where(self): + def test_fmt_multi_newline_before_where(self) -> None: expl = "\n".join( [ "the assertion", @@ -949,17 +1118,17 @@ class TestTruncateExplanation: # to calculate that results have the expected length. LINES_IN_TRUNCATION_MSG = 2 - def test_doesnt_truncate_when_input_is_empty_list(self): - expl = [] + def test_doesnt_truncate_when_input_is_empty_list(self) -> None: + expl: List[str] = [] result = truncate._truncate_explanation(expl, max_lines=8, max_chars=100) assert result == expl - def test_doesnt_truncate_at_when_input_is_5_lines_and_LT_max_chars(self): + def test_doesnt_truncate_at_when_input_is_5_lines_and_LT_max_chars(self) -> None: expl = ["a" * 100 for x in range(5)] result = truncate._truncate_explanation(expl, max_lines=8, max_chars=8 * 80) assert result == expl - def test_truncates_at_8_lines_when_given_list_of_empty_strings(self): + def test_truncates_at_8_lines_when_given_list_of_empty_strings(self) -> None: expl = ["" for x in range(50)] result = truncate._truncate_explanation(expl, max_lines=8, max_chars=100) assert result != expl @@ -969,7 +1138,7 @@ def test_truncates_at_8_lines_when_given_list_of_empty_strings(self): last_line_before_trunc_msg = result[-self.LINES_IN_TRUNCATION_MSG - 1] assert last_line_before_trunc_msg.endswith("...") - def test_truncates_at_8_lines_when_first_8_lines_are_LT_max_chars(self): + def test_truncates_at_8_lines_when_first_8_lines_are_LT_max_chars(self) -> None: expl = ["a" for x in range(100)] result = truncate._truncate_explanation(expl, max_lines=8, max_chars=8 * 80) assert result != expl @@ -979,7 +1148,7 @@ def test_truncates_at_8_lines_when_first_8_lines_are_LT_max_chars(self): last_line_before_trunc_msg = result[-self.LINES_IN_TRUNCATION_MSG - 1] assert last_line_before_trunc_msg.endswith("...") - def test_truncates_at_8_lines_when_first_8_lines_are_EQ_max_chars(self): + def test_truncates_at_8_lines_when_first_8_lines_are_EQ_max_chars(self) -> None: expl = ["a" * 80 for x in range(16)] result = truncate._truncate_explanation(expl, max_lines=8, max_chars=8 * 80) assert result != expl @@ -989,7 +1158,7 @@ def test_truncates_at_8_lines_when_first_8_lines_are_EQ_max_chars(self): last_line_before_trunc_msg = result[-self.LINES_IN_TRUNCATION_MSG - 1] assert last_line_before_trunc_msg.endswith("...") - def test_truncates_at_4_lines_when_first_4_lines_are_GT_max_chars(self): + def test_truncates_at_4_lines_when_first_4_lines_are_GT_max_chars(self) -> None: expl = ["a" * 250 for x in range(10)] result = truncate._truncate_explanation(expl, max_lines=8, max_chars=999) assert result != expl @@ -999,7 +1168,7 @@ def test_truncates_at_4_lines_when_first_4_lines_are_GT_max_chars(self): last_line_before_trunc_msg = result[-self.LINES_IN_TRUNCATION_MSG - 1] assert last_line_before_trunc_msg.endswith("...") - def test_truncates_at_1_line_when_first_line_is_GT_max_chars(self): + def test_truncates_at_1_line_when_first_line_is_GT_max_chars(self) -> None: expl = ["a" * 250 for x in range(1000)] result = truncate._truncate_explanation(expl, max_lines=8, max_chars=100) assert result != expl @@ -1009,13 +1178,13 @@ def test_truncates_at_1_line_when_first_line_is_GT_max_chars(self): last_line_before_trunc_msg = result[-self.LINES_IN_TRUNCATION_MSG - 1] assert last_line_before_trunc_msg.endswith("...") - def test_full_output_truncated(self, monkeypatch, testdir): - """ Test against full runpytest() output. """ + def test_full_output_truncated(self, monkeypatch, pytester: Pytester) -> None: + """Test against full runpytest() output.""" line_count = 7 line_len = 100 expected_truncated_lines = 2 - testdir.makepyfile( + pytester.makepyfile( r""" def test_many_lines(): a = list([str(i)[0] * %d for i in range(%d)]) @@ -1028,7 +1197,7 @@ def test_many_lines(): ) monkeypatch.delenv("CI", raising=False) - result = testdir.runpytest() + result = pytester.runpytest() # without -vv, truncate the message showing a few diff lines only result.stdout.fnmatch_lines( [ @@ -1039,23 +1208,23 @@ def test_many_lines(): ] ) - result = testdir.runpytest("-vv") + result = pytester.runpytest("-vv") result.stdout.fnmatch_lines(["* 6*"]) monkeypatch.setenv("CI", "1") - result = testdir.runpytest() + result = pytester.runpytest() result.stdout.fnmatch_lines(["* 6*"]) -def test_python25_compile_issue257(testdir): - testdir.makepyfile( +def test_python25_compile_issue257(pytester: Pytester) -> None: + pytester.makepyfile( """ def test_rewritten(): assert 1 == 2 # some comment """ ) - result = testdir.runpytest() + result = pytester.runpytest() assert result.ret == 1 result.stdout.fnmatch_lines( """ @@ -1065,14 +1234,14 @@ def test_rewritten(): ) -def test_rewritten(testdir): - testdir.makepyfile( +def test_rewritten(pytester: Pytester) -> None: + pytester.makepyfile( """ def test_rewritten(): assert "@py_builtins" in globals() """ ) - assert testdir.runpytest().ret == 0 + assert pytester.runpytest().ret == 0 def test_reprcompare_notin() -> None: @@ -1084,7 +1253,7 @@ def test_reprcompare_notin() -> None: ] -def test_reprcompare_whitespaces(): +def test_reprcompare_whitespaces() -> None: assert callequal("\r\n", "\n") == [ r"'\r\n' == '\n'", r"Strings contain only whitespace, escaping them using repr()", @@ -1094,8 +1263,8 @@ def test_reprcompare_whitespaces(): ] -def test_pytest_assertrepr_compare_integration(testdir): - testdir.makepyfile( +def test_pytest_assertrepr_compare_integration(pytester: Pytester) -> None: + pytester.makepyfile( """ def test_hello(): x = set(range(100)) @@ -1104,7 +1273,7 @@ def test_hello(): assert x == y """ ) - result = testdir.runpytest() + result = pytester.runpytest() result.stdout.fnmatch_lines( [ "*def test_hello():*", @@ -1116,8 +1285,8 @@ def test_hello(): ) -def test_sequence_comparison_uses_repr(testdir): - testdir.makepyfile( +def test_sequence_comparison_uses_repr(pytester: Pytester) -> None: + pytester.makepyfile( """ def test_hello(): x = set("hello x") @@ -1125,7 +1294,7 @@ def test_hello(): assert x == y """ ) - result = testdir.runpytest() + result = pytester.runpytest() result.stdout.fnmatch_lines( [ "*def test_hello():*", @@ -1138,19 +1307,20 @@ def test_hello(): ) -def test_assertrepr_loaded_per_dir(testdir): - testdir.makepyfile(test_base=["def test_base(): assert 1 == 2"]) - a = testdir.mkdir("a") - a_test = a.join("test_a.py") - a_test.write("def test_a(): assert 1 == 2") - a_conftest = a.join("conftest.py") - a_conftest.write('def pytest_assertrepr_compare(): return ["summary a"]') - b = testdir.mkdir("b") - b_test = b.join("test_b.py") - b_test.write("def test_b(): assert 1 == 2") - b_conftest = b.join("conftest.py") - b_conftest.write('def pytest_assertrepr_compare(): return ["summary b"]') - result = testdir.runpytest() +def test_assertrepr_loaded_per_dir(pytester: Pytester) -> None: + pytester.makepyfile(test_base=["def test_base(): assert 1 == 2"]) + a = pytester.mkdir("a") + a.joinpath("test_a.py").write_text("def test_a(): assert 1 == 2") + a.joinpath("conftest.py").write_text( + 'def pytest_assertrepr_compare(): return ["summary a"]' + ) + b = pytester.mkdir("b") + b.joinpath("test_b.py").write_text("def test_b(): assert 1 == 2") + b.joinpath("conftest.py").write_text( + 'def pytest_assertrepr_compare(): return ["summary b"]' + ) + + result = pytester.runpytest() result.stdout.fnmatch_lines( [ "*def test_base():*", @@ -1163,34 +1333,34 @@ def test_assertrepr_loaded_per_dir(testdir): ) -def test_assertion_options(testdir): - testdir.makepyfile( +def test_assertion_options(pytester: Pytester) -> None: + pytester.makepyfile( """ def test_hello(): x = 3 assert x == 4 """ ) - result = testdir.runpytest() + result = pytester.runpytest() assert "3 == 4" in result.stdout.str() - result = testdir.runpytest_subprocess("--assert=plain") + result = pytester.runpytest_subprocess("--assert=plain") result.stdout.no_fnmatch_line("*3 == 4*") -def test_triple_quoted_string_issue113(testdir): - testdir.makepyfile( +def test_triple_quoted_string_issue113(pytester: Pytester) -> None: + pytester.makepyfile( """ def test_hello(): assert "" == ''' '''""" ) - result = testdir.runpytest("--fulltrace") + result = pytester.runpytest("--fulltrace") result.stdout.fnmatch_lines(["*1 failed*"]) result.stdout.no_fnmatch_line("*SyntaxError*") -def test_traceback_failure(testdir): - p1 = testdir.makepyfile( +def test_traceback_failure(pytester: Pytester) -> None: + p1 = pytester.makepyfile( """ def g(): return 2 @@ -1200,7 +1370,7 @@ def test_onefails(): f(3) """ ) - result = testdir.runpytest(p1, "--tb=long") + result = pytester.runpytest(p1, "--tb=long") result.stdout.fnmatch_lines( [ "*test_traceback_failure.py F*", @@ -1222,7 +1392,7 @@ def test_onefails(): ] ) - result = testdir.runpytest(p1) # "auto" + result = pytester.runpytest(p1) # "auto" result.stdout.fnmatch_lines( [ "*test_traceback_failure.py F*", @@ -1244,11 +1414,9 @@ def test_onefails(): ) -def test_exception_handling_no_traceback(testdir): - """ - Handle chain exceptions in tasks submitted by the multiprocess module (#1984). - """ - p1 = testdir.makepyfile( +def test_exception_handling_no_traceback(pytester: Pytester) -> None: + """Handle chain exceptions in tasks submitted by the multiprocess module (#1984).""" + p1 = pytester.makepyfile( """ from multiprocessing import Pool @@ -1264,7 +1432,8 @@ def test_multitask_job(): multitask_job() """ ) - result = testdir.runpytest(p1, "--tb=long") + pytester.syspathinsert() + result = pytester.runpytest(p1, "--tb=long") result.stdout.fnmatch_lines( [ "====* FAILURES *====", @@ -1278,28 +1447,51 @@ def test_multitask_job(): @pytest.mark.skipif("'__pypy__' in sys.builtin_module_names") -def test_warn_missing(testdir): - testdir.makepyfile("") - result = testdir.run(sys.executable, "-OO", "-m", "pytest", "-h") - result.stderr.fnmatch_lines(["*WARNING*assert statements are not executed*"]) - result = testdir.run(sys.executable, "-OO", "-m", "pytest") - result.stderr.fnmatch_lines(["*WARNING*assert statements are not executed*"]) - - -def test_recursion_source_decode(testdir): - testdir.makepyfile( +@pytest.mark.parametrize( + "cmdline_args, warning_output", + [ + ( + ["-OO", "-m", "pytest", "-h"], + ["warning :*PytestConfigWarning:*assert statements are not executed*"], + ), + ( + ["-OO", "-m", "pytest"], + [ + "=*= warnings summary =*=", + "*PytestConfigWarning:*assert statements are not executed*", + ], + ), + ( + ["-OO", "-m", "pytest", "--assert=plain"], + [ + "=*= warnings summary =*=", + "*PytestConfigWarning: ASSERTIONS ARE NOT EXECUTED and FAILING TESTS WILL PASS. " + "Are you using python -O?", + ], + ), + ], +) +def test_warn_missing(pytester: Pytester, cmdline_args, warning_output) -> None: + pytester.makepyfile("") + + result = pytester.run(sys.executable, *cmdline_args) + result.stdout.fnmatch_lines(warning_output) + + +def test_recursion_source_decode(pytester: Pytester) -> None: + pytester.makepyfile( """ def test_something(): pass """ ) - testdir.makeini( + pytester.makeini( """ [pytest] python_files = *.py """ ) - result = testdir.runpytest("--collect-only") + result = pytester.runpytest("--collect-only") result.stdout.fnmatch_lines( """ @@ -1307,15 +1499,15 @@ def test_something(): ) -def test_AssertionError_message(testdir): - testdir.makepyfile( +def test_AssertionError_message(pytester: Pytester) -> None: + pytester.makepyfile( """ def test_hello(): x,y = 1,2 assert 0, (x,y) """ ) - result = testdir.runpytest() + result = pytester.runpytest() result.stdout.fnmatch_lines( """ *def test_hello* @@ -1325,15 +1517,15 @@ def test_hello(): ) -def test_diff_newline_at_end(testdir): - testdir.makepyfile( +def test_diff_newline_at_end(pytester: Pytester) -> None: + pytester.makepyfile( r""" def test_diff(): assert 'asdf' == 'asdf\n' """ ) - result = testdir.runpytest() + result = pytester.runpytest() result.stdout.fnmatch_lines( r""" *assert 'asdf' == 'asdf\n' @@ -1345,67 +1537,67 @@ def test_diff(): @pytest.mark.filterwarnings("default") -def test_assert_tuple_warning(testdir): +def test_assert_tuple_warning(pytester: Pytester) -> None: msg = "assertion is always true" - testdir.makepyfile( + pytester.makepyfile( """ def test_tuple(): assert(False, 'you shall not pass') """ ) - result = testdir.runpytest() - result.stdout.fnmatch_lines(["*test_assert_tuple_warning.py:2:*{}*".format(msg)]) + result = pytester.runpytest() + result.stdout.fnmatch_lines([f"*test_assert_tuple_warning.py:2:*{msg}*"]) # tuples with size != 2 should not trigger the warning - testdir.makepyfile( + pytester.makepyfile( """ def test_tuple(): assert () """ ) - result = testdir.runpytest() + result = pytester.runpytest() assert msg not in result.stdout.str() -def test_assert_indirect_tuple_no_warning(testdir): - testdir.makepyfile( +def test_assert_indirect_tuple_no_warning(pytester: Pytester) -> None: + pytester.makepyfile( """ def test_tuple(): tpl = ('foo', 'bar') assert tpl """ ) - result = testdir.runpytest() + result = pytester.runpytest() output = "\n".join(result.stdout.lines) assert "WR1" not in output -def test_assert_with_unicode(testdir): - testdir.makepyfile( +def test_assert_with_unicode(pytester: Pytester) -> None: + pytester.makepyfile( """\ def test_unicode(): assert '유니코드' == 'Unicode' """ ) - result = testdir.runpytest() + result = pytester.runpytest() result.stdout.fnmatch_lines(["*AssertionError*"]) -def test_raise_unprintable_assertion_error(testdir): - testdir.makepyfile( +def test_raise_unprintable_assertion_error(pytester: Pytester) -> None: + pytester.makepyfile( r""" def test_raise_assertion_error(): raise AssertionError('\xff') """ ) - result = testdir.runpytest() + result = pytester.runpytest() result.stdout.fnmatch_lines( [r"> raise AssertionError('\xff')", "E AssertionError: *"] ) -def test_raise_assertion_error_raisin_repr(testdir): - testdir.makepyfile( +def test_raise_assertion_error_raisin_repr(pytester: Pytester) -> None: + pytester.makepyfile( """ class RaisingRepr(object): def __repr__(self): @@ -1414,14 +1606,14 @@ def test_raising_repr(): raise AssertionError(RaisingRepr()) """ ) - result = testdir.runpytest() + result = pytester.runpytest() result.stdout.fnmatch_lines( ["E AssertionError: "] ) -def test_issue_1944(testdir): - testdir.makepyfile( +def test_issue_1944(pytester: Pytester) -> None: + pytester.makepyfile( """ def f(): return @@ -1429,7 +1621,7 @@ def f(): assert f() == 10 """ ) - result = testdir.runpytest() + result = pytester.runpytest() result.stdout.fnmatch_lines(["*1 error*"]) assert ( "AttributeError: 'Module' object has no attribute '_obj'" @@ -1437,7 +1629,7 @@ def f(): ) -def test_exit_from_assertrepr_compare(monkeypatch): +def test_exit_from_assertrepr_compare(monkeypatch) -> None: def raise_exit(obj): outcomes.exit("Quitting debugger") @@ -1447,16 +1639,16 @@ def raise_exit(obj): callequal(1, 1) -def test_assertion_location_with_coverage(testdir): +def test_assertion_location_with_coverage(pytester: Pytester) -> None: """This used to report the wrong location when run with coverage (#5754).""" - p = testdir.makepyfile( + p = pytester.makepyfile( """ def test(): assert False, 1 assert False, 2 """ ) - result = testdir.runpytest(str(p)) + result = pytester.runpytest(str(p)) result.stdout.fnmatch_lines( [ "> assert False, 1", diff --git a/testing/test_assertrewrite.py b/testing/test_assertrewrite.py index 91a3a35e2e2..84d5276e729 100644 --- a/testing/test_assertrewrite.py +++ b/testing/test_assertrewrite.py @@ -2,6 +2,7 @@ import errno import glob import importlib +import marshal import os import py_compile import stat @@ -9,6 +10,12 @@ import textwrap import zipfile from functools import partial +from pathlib import Path +from typing import Dict +from typing import List +from typing import Mapping +from typing import Optional +from typing import Set import py @@ -22,37 +29,30 @@ from _pytest.assertion.rewrite import PYTEST_TAG from _pytest.assertion.rewrite import rewrite_asserts from _pytest.config import ExitCode -from _pytest.pathlib import Path +from _pytest.pathlib import make_numbered_dir +from _pytest.pytester import Pytester -def setup_module(mod): - mod._old_reprcompare = util._reprcompare - _pytest._code._reprcompare = None - - -def teardown_module(mod): - util._reprcompare = mod._old_reprcompare - del mod._old_reprcompare - - -def rewrite(src): +def rewrite(src: str) -> ast.Module: tree = ast.parse(src) rewrite_asserts(tree, src.encode()) return tree -def getmsg(f, extra_ns=None, must_pass=False): +def getmsg( + f, extra_ns: Optional[Mapping[str, object]] = None, *, must_pass: bool = False +) -> Optional[str]: """Rewrite the assertions in f, run it, and get the failure message.""" - src = "\n".join(_pytest._code.Code(f).source().lines) + src = "\n".join(_pytest._code.Code.from_function(f).source().lines) mod = rewrite(src) code = compile(mod, "", "exec") - ns = {} + ns: Dict[str, object] = {} if extra_ns is not None: ns.update(extra_ns) exec(code, ns) func = ns[f.__name__] try: - func() + func() # type: ignore[operator] except AssertionError: if must_pass: pytest.fail("shouldn't have raised") @@ -63,10 +63,11 @@ def getmsg(f, extra_ns=None, must_pass=False): else: if not must_pass: pytest.fail("function didn't raise at all") + return None class TestAssertionRewrite: - def test_place_initial_imports(self): + def test_place_initial_imports(self) -> None: s = """'Doc string'\nother = stuff""" m = rewrite(s) assert isinstance(m.body[0], ast.Expr) @@ -108,25 +109,26 @@ def test_place_initial_imports(self): assert imp.col_offset == 0 assert isinstance(m.body[3], ast.Expr) - def test_dont_rewrite(self): + def test_dont_rewrite(self) -> None: s = """'PYTEST_DONT_REWRITE'\nassert 14""" m = rewrite(s) assert len(m.body) == 2 + assert isinstance(m.body[1], ast.Assert) assert m.body[1].msg is None - def test_dont_rewrite_plugin(self, testdir): + def test_dont_rewrite_plugin(self, pytester: Pytester) -> None: contents = { "conftest.py": "pytest_plugins = 'plugin'; import plugin", "plugin.py": "'PYTEST_DONT_REWRITE'", "test_foo.py": "def test_foo(): pass", } - testdir.makepyfile(**contents) - result = testdir.runpytest_subprocess() + pytester.makepyfile(**contents) + result = pytester.runpytest_subprocess() assert "warning" not in "".join(result.outlines) - def test_rewrites_plugin_as_a_package(self, testdir): - pkgdir = testdir.mkpydir("plugin") - pkgdir.join("__init__.py").write( + def test_rewrites_plugin_as_a_package(self, pytester: Pytester) -> None: + pkgdir = pytester.mkpydir("plugin") + pkgdir.joinpath("__init__.py").write_text( "import pytest\n" "@pytest.fixture\n" "def special_asserter():\n" @@ -134,49 +136,50 @@ def test_rewrites_plugin_as_a_package(self, testdir): " assert x == y\n" " return special_assert\n" ) - testdir.makeconftest('pytest_plugins = ["plugin"]') - testdir.makepyfile("def test(special_asserter): special_asserter(1, 2)\n") - result = testdir.runpytest() + pytester.makeconftest('pytest_plugins = ["plugin"]') + pytester.makepyfile("def test(special_asserter): special_asserter(1, 2)\n") + result = pytester.runpytest() result.stdout.fnmatch_lines(["*assert 1 == 2*"]) - def test_honors_pep_235(self, testdir, monkeypatch): + def test_honors_pep_235(self, pytester: Pytester, monkeypatch) -> None: # note: couldn't make it fail on macos with a single `sys.path` entry # note: these modules are named `test_*` to trigger rewriting - testdir.tmpdir.join("test_y.py").write("x = 1") - xdir = testdir.tmpdir.join("x").ensure_dir() - xdir.join("test_Y").ensure_dir().join("__init__.py").write("x = 2") - testdir.makepyfile( + pytester.makepyfile(test_y="x = 1") + xdir = pytester.mkdir("x") + pytester.mkpydir(str(xdir.joinpath("test_Y"))) + xdir.joinpath("test_Y").joinpath("__init__.py").write_text("x = 2") + pytester.makepyfile( "import test_y\n" "import test_Y\n" "def test():\n" " assert test_y.x == 1\n" " assert test_Y.x == 2\n" ) - monkeypatch.syspath_prepend(xdir) - testdir.runpytest().assert_outcomes(passed=1) + monkeypatch.syspath_prepend(str(xdir)) + pytester.runpytest().assert_outcomes(passed=1) - def test_name(self, request): - def f(): + def test_name(self, request) -> None: + def f1() -> None: assert False - assert getmsg(f) == "assert False" + assert getmsg(f1) == "assert False" - def f(): + def f2() -> None: f = False assert f - assert getmsg(f) == "assert False" + assert getmsg(f2) == "assert False" - def f(): - assert a_global # noqa + def f3() -> None: + assert a_global # type: ignore[name-defined] # noqa - assert getmsg(f, {"a_global": False}) == "assert False" + assert getmsg(f3, {"a_global": False}) == "assert False" - def f(): - assert sys == 42 + def f4() -> None: + assert sys == 42 # type: ignore[comparison-overlap] verbose = request.config.getoption("verbose") - msg = getmsg(f, {"sys": sys}) + msg = getmsg(f4, {"sys": sys}) if verbose > 0: assert msg == ( "assert == 42\n" @@ -186,286 +189,296 @@ def f(): else: assert msg == "assert sys == 42" - def f(): - assert cls == 42 # noqa: F821 + def f5() -> None: + assert cls == 42 # type: ignore[name-defined] # noqa: F821 class X: pass - msg = getmsg(f, {"cls": X}).splitlines() + msg = getmsg(f5, {"cls": X}) + assert msg is not None + lines = msg.splitlines() if verbose > 1: - assert msg == ["assert {!r} == 42".format(X), " +{!r}".format(X), " -42"] + assert lines == [ + f"assert {X!r} == 42", + f" +{X!r}", + " -42", + ] elif verbose > 0: - assert msg == [ + assert lines == [ "assert .X'> == 42", - " +{!r}".format(X), + f" +{X!r}", " -42", ] else: - assert msg == ["assert cls == 42"] + assert lines == ["assert cls == 42"] - def test_assertrepr_compare_same_width(self, request): + def test_assertrepr_compare_same_width(self, request) -> None: """Should use same width/truncation with same initial width.""" - def f(): + def f() -> None: assert "1234567890" * 5 + "A" == "1234567890" * 5 + "B" - msg = getmsg(f).splitlines()[0] + msg = getmsg(f) + assert msg is not None + line = msg.splitlines()[0] if request.config.getoption("verbose") > 1: - assert msg == ( + assert line == ( "assert '12345678901234567890123456789012345678901234567890A' " "== '12345678901234567890123456789012345678901234567890B'" ) else: - assert msg == ( + assert line == ( "assert '123456789012...901234567890A' " "== '123456789012...901234567890B'" ) - def test_dont_rewrite_if_hasattr_fails(self, request): + def test_dont_rewrite_if_hasattr_fails(self, request) -> None: class Y: - """ A class whos getattr fails, but not with `AttributeError` """ + """A class whose getattr fails, but not with `AttributeError`.""" def __getattr__(self, attribute_name): raise KeyError() - def __repr__(self): + def __repr__(self) -> str: return "Y" - def __init__(self): + def __init__(self) -> None: self.foo = 3 - def f(): - assert cls().foo == 2 # noqa + def f() -> None: + assert cls().foo == 2 # type: ignore[name-defined] # noqa: F821 # XXX: looks like the "where" should also be there in verbose mode?! - message = getmsg(f, {"cls": Y}).splitlines() + msg = getmsg(f, {"cls": Y}) + assert msg is not None + lines = msg.splitlines() if request.config.getoption("verbose") > 0: - assert message == ["assert 3 == 2", " +3", " -2"] + assert lines == ["assert 3 == 2", " +3", " -2"] else: - assert message == [ + assert lines == [ "assert 3 == 2", " + where 3 = Y.foo", " + where Y = cls()", ] - def test_assert_already_has_message(self): + def test_assert_already_has_message(self) -> None: def f(): assert False, "something bad!" assert getmsg(f) == "AssertionError: something bad!\nassert False" - def test_assertion_message(self, testdir): - testdir.makepyfile( + def test_assertion_message(self, pytester: Pytester) -> None: + pytester.makepyfile( """ def test_foo(): assert 1 == 2, "The failure message" """ ) - result = testdir.runpytest() + result = pytester.runpytest() assert result.ret == 1 result.stdout.fnmatch_lines( ["*AssertionError*The failure message*", "*assert 1 == 2*"] ) - def test_assertion_message_multiline(self, testdir): - testdir.makepyfile( + def test_assertion_message_multiline(self, pytester: Pytester) -> None: + pytester.makepyfile( """ def test_foo(): assert 1 == 2, "A multiline\\nfailure message" """ ) - result = testdir.runpytest() + result = pytester.runpytest() assert result.ret == 1 result.stdout.fnmatch_lines( ["*AssertionError*A multiline*", "*failure message*", "*assert 1 == 2*"] ) - def test_assertion_message_tuple(self, testdir): - testdir.makepyfile( + def test_assertion_message_tuple(self, pytester: Pytester) -> None: + pytester.makepyfile( """ def test_foo(): assert 1 == 2, (1, 2) """ ) - result = testdir.runpytest() + result = pytester.runpytest() assert result.ret == 1 result.stdout.fnmatch_lines( ["*AssertionError*%s*" % repr((1, 2)), "*assert 1 == 2*"] ) - def test_assertion_message_expr(self, testdir): - testdir.makepyfile( + def test_assertion_message_expr(self, pytester: Pytester) -> None: + pytester.makepyfile( """ def test_foo(): assert 1 == 2, 1 + 2 """ ) - result = testdir.runpytest() + result = pytester.runpytest() assert result.ret == 1 result.stdout.fnmatch_lines(["*AssertionError*3*", "*assert 1 == 2*"]) - def test_assertion_message_escape(self, testdir): - testdir.makepyfile( + def test_assertion_message_escape(self, pytester: Pytester) -> None: + pytester.makepyfile( """ def test_foo(): assert 1 == 2, 'To be escaped: %' """ ) - result = testdir.runpytest() + result = pytester.runpytest() assert result.ret == 1 result.stdout.fnmatch_lines( ["*AssertionError: To be escaped: %", "*assert 1 == 2"] ) - def test_assertion_messages_bytes(self, testdir): - testdir.makepyfile("def test_bytes_assertion():\n assert False, b'ohai!'\n") - result = testdir.runpytest() + def test_assertion_messages_bytes(self, pytester: Pytester) -> None: + pytester.makepyfile("def test_bytes_assertion():\n assert False, b'ohai!'\n") + result = pytester.runpytest() assert result.ret == 1 result.stdout.fnmatch_lines(["*AssertionError: b'ohai!'", "*assert False"]) - def test_boolop(self): - def f(): + def test_boolop(self) -> None: + def f1() -> None: f = g = False assert f and g - assert getmsg(f) == "assert (False)" + assert getmsg(f1) == "assert (False)" - def f(): + def f2() -> None: f = True g = False assert f and g - assert getmsg(f) == "assert (True and False)" + assert getmsg(f2) == "assert (True and False)" - def f(): + def f3() -> None: f = False g = True assert f and g - assert getmsg(f) == "assert (False)" + assert getmsg(f3) == "assert (False)" - def f(): + def f4() -> None: f = g = False assert f or g - assert getmsg(f) == "assert (False or False)" + assert getmsg(f4) == "assert (False or False)" - def f(): + def f5() -> None: f = g = False assert not f and not g - getmsg(f, must_pass=True) + getmsg(f5, must_pass=True) - def x(): + def x() -> bool: return False - def f(): + def f6() -> None: assert x() and x() assert ( - getmsg(f, {"x": x}) + getmsg(f6, {"x": x}) == """assert (False) + where False = x()""" ) - def f(): - assert False or x() + def f7() -> None: + assert False or x() # type: ignore[unreachable] assert ( - getmsg(f, {"x": x}) + getmsg(f7, {"x": x}) == """assert (False or False) + where False = x()""" ) - def f(): + def f8() -> None: assert 1 in {} and 2 in {} - assert getmsg(f) == "assert (1 in {})" + assert getmsg(f8) == "assert (1 in {})" - def f(): + def f9() -> None: x = 1 y = 2 assert x in {1: None} and y in {} - assert getmsg(f) == "assert (1 in {1: None} and 2 in {})" + assert getmsg(f9) == "assert (1 in {1: None} and 2 in {})" - def f(): + def f10() -> None: f = True g = False assert f or g - getmsg(f, must_pass=True) + getmsg(f10, must_pass=True) - def f(): + def f11() -> None: f = g = h = lambda: True assert f() and g() and h() - getmsg(f, must_pass=True) + getmsg(f11, must_pass=True) - def test_short_circuit_evaluation(self): - def f(): - assert True or explode # noqa + def test_short_circuit_evaluation(self) -> None: + def f1() -> None: + assert True or explode # type: ignore[name-defined,unreachable] # noqa: F821 - getmsg(f, must_pass=True) + getmsg(f1, must_pass=True) - def f(): + def f2() -> None: x = 1 assert x == 1 or x == 2 - getmsg(f, must_pass=True) + getmsg(f2, must_pass=True) - def test_unary_op(self): - def f(): + def test_unary_op(self) -> None: + def f1() -> None: x = True assert not x - assert getmsg(f) == "assert not True" + assert getmsg(f1) == "assert not True" - def f(): + def f2() -> None: x = 0 assert ~x + 1 - assert getmsg(f) == "assert (~0 + 1)" + assert getmsg(f2) == "assert (~0 + 1)" - def f(): + def f3() -> None: x = 3 assert -x + x - assert getmsg(f) == "assert (-3 + 3)" + assert getmsg(f3) == "assert (-3 + 3)" - def f(): + def f4() -> None: x = 0 assert +x + x - assert getmsg(f) == "assert (+0 + 0)" + assert getmsg(f4) == "assert (+0 + 0)" - def test_binary_op(self): - def f(): + def test_binary_op(self) -> None: + def f1() -> None: x = 1 y = -1 assert x + y - assert getmsg(f) == "assert (1 + -1)" + assert getmsg(f1) == "assert (1 + -1)" - def f(): + def f2() -> None: assert not 5 % 4 - assert getmsg(f) == "assert not (5 % 4)" + assert getmsg(f2) == "assert not (5 % 4)" - def test_boolop_percent(self): - def f(): + def test_boolop_percent(self) -> None: + def f1() -> None: assert 3 % 2 and False - assert getmsg(f) == "assert ((3 % 2) and False)" + assert getmsg(f1) == "assert ((3 % 2) and False)" - def f(): - assert False or 4 % 2 + def f2() -> None: + assert False or 4 % 2 # type: ignore[unreachable] - assert getmsg(f) == "assert (False or (4 % 2))" + assert getmsg(f2) == "assert (False or (4 % 2))" - def test_at_operator_issue1290(self, testdir): - testdir.makepyfile( + def test_at_operator_issue1290(self, pytester: Pytester) -> None: + pytester.makepyfile( """ class Matrix(object): def __init__(self, num): @@ -476,11 +489,11 @@ def __matmul__(self, other): def test_multmat_operator(): assert Matrix(2) @ Matrix(3) == 6""" ) - testdir.runpytest().assert_outcomes(passed=1) + pytester.runpytest().assert_outcomes(passed=1) - def test_starred_with_side_effect(self, testdir): + def test_starred_with_side_effect(self, pytester: Pytester) -> None: """See #4412""" - testdir.makepyfile( + pytester.makepyfile( """\ def test(): f = lambda x: x @@ -488,137 +501,137 @@ def test(): assert 2 * next(x) == f(*[next(x)]) """ ) - testdir.runpytest().assert_outcomes(passed=1) + pytester.runpytest().assert_outcomes(passed=1) - def test_call(self): - def g(a=42, *args, **kwargs): + def test_call(self) -> None: + def g(a=42, *args, **kwargs) -> bool: return False ns = {"g": g} - def f(): + def f1() -> None: assert g() assert ( - getmsg(f, ns) + getmsg(f1, ns) == """assert False + where False = g()""" ) - def f(): + def f2() -> None: assert g(1) assert ( - getmsg(f, ns) + getmsg(f2, ns) == """assert False + where False = g(1)""" ) - def f(): + def f3() -> None: assert g(1, 2) assert ( - getmsg(f, ns) + getmsg(f3, ns) == """assert False + where False = g(1, 2)""" ) - def f(): + def f4() -> None: assert g(1, g=42) assert ( - getmsg(f, ns) + getmsg(f4, ns) == """assert False + where False = g(1, g=42)""" ) - def f(): + def f5() -> None: assert g(1, 3, g=23) assert ( - getmsg(f, ns) + getmsg(f5, ns) == """assert False + where False = g(1, 3, g=23)""" ) - def f(): + def f6() -> None: seq = [1, 2, 3] assert g(*seq) assert ( - getmsg(f, ns) + getmsg(f6, ns) == """assert False + where False = g(*[1, 2, 3])""" ) - def f(): + def f7() -> None: x = "a" assert g(**{x: 2}) assert ( - getmsg(f, ns) + getmsg(f7, ns) == """assert False + where False = g(**{'a': 2})""" ) - def test_attribute(self): + def test_attribute(self) -> None: class X: g = 3 ns = {"x": X} - def f(): - assert not x.g # noqa + def f1() -> None: + assert not x.g # type: ignore[name-defined] # noqa: F821 assert ( - getmsg(f, ns) + getmsg(f1, ns) == """assert not 3 + where 3 = x.g""" ) - def f(): - x.a = False # noqa - assert x.a # noqa + def f2() -> None: + x.a = False # type: ignore[name-defined] # noqa: F821 + assert x.a # type: ignore[name-defined] # noqa: F821 assert ( - getmsg(f, ns) + getmsg(f2, ns) == """assert False + where False = x.a""" ) - def test_comparisons(self): - def f(): + def test_comparisons(self) -> None: + def f1() -> None: a, b = range(2) assert b < a - assert getmsg(f) == """assert 1 < 0""" + assert getmsg(f1) == """assert 1 < 0""" - def f(): + def f2() -> None: a, b, c = range(3) assert a > b > c - assert getmsg(f) == """assert 0 > 1""" + assert getmsg(f2) == """assert 0 > 1""" - def f(): + def f3() -> None: a, b, c = range(3) assert a < b > c - assert getmsg(f) == """assert 1 > 2""" + assert getmsg(f3) == """assert 1 > 2""" - def f(): + def f4() -> None: a, b, c = range(3) assert a < b <= c - getmsg(f, must_pass=True) + getmsg(f4, must_pass=True) - def f(): + def f5() -> None: a, b, c = range(3) assert a < b assert b < c - getmsg(f, must_pass=True) + getmsg(f5, must_pass=True) - def test_len(self, request): + def test_len(self, request) -> None: def f(): values = list(range(10)) assert len(values) == 11 @@ -629,31 +642,31 @@ def f(): else: assert msg == "assert 10 == 11\n + where 10 = len([0, 1, 2, 3, 4, 5, ...])" - def test_custom_reprcompare(self, monkeypatch): - def my_reprcompare(op, left, right): + def test_custom_reprcompare(self, monkeypatch) -> None: + def my_reprcompare1(op, left, right) -> str: return "42" - monkeypatch.setattr(util, "_reprcompare", my_reprcompare) + monkeypatch.setattr(util, "_reprcompare", my_reprcompare1) - def f(): + def f1() -> None: assert 42 < 3 - assert getmsg(f) == "assert 42" + assert getmsg(f1) == "assert 42" - def my_reprcompare(op, left, right): - return "{} {} {}".format(left, op, right) + def my_reprcompare2(op, left, right) -> str: + return f"{left} {op} {right}" - monkeypatch.setattr(util, "_reprcompare", my_reprcompare) + monkeypatch.setattr(util, "_reprcompare", my_reprcompare2) - def f(): + def f2() -> None: assert 1 < 3 < 5 <= 4 < 7 - assert getmsg(f) == "assert 5 <= 4" + assert getmsg(f2) == "assert 5 <= 4" - def test_assert_raising_nonzero_in_comparison(self): - def f(): + def test_assert_raising__bool__in_comparison(self) -> None: + def f() -> None: class A: - def __nonzero__(self): + def __bool__(self): raise ValueError(42) def __lt__(self, other): @@ -662,21 +675,25 @@ def __lt__(self, other): def __repr__(self): return "" - def myany(x): + def myany(x) -> bool: return False assert myany(A() < 0) - assert " < 0" in getmsg(f) + msg = getmsg(f) + assert msg is not None + assert " < 0" in msg - def test_formatchar(self): - def f(): - assert "%test" == "test" + def test_formatchar(self) -> None: + def f() -> None: + assert "%test" == "test" # type: ignore[comparison-overlap] - assert getmsg(f).startswith("assert '%test' == 'test'") + msg = getmsg(f) + assert msg is not None + assert msg.startswith("assert '%test' == 'test'") - def test_custom_repr(self, request): - def f(): + def test_custom_repr(self, request) -> None: + def f() -> None: class Foo: a = 1 @@ -686,14 +703,16 @@ def __repr__(self): f = Foo() assert 0 == f.a - lines = util._format_lines([getmsg(f)]) + msg = getmsg(f) + assert msg is not None + lines = util._format_lines([msg]) if request.config.getoption("verbose") > 0: assert lines == ["assert 0 == 1\n +0\n -1"] else: assert lines == ["assert 0 == 1\n + where 1 = \\n{ \\n~ \\n}.a"] - def test_custom_repr_non_ascii(self): - def f(): + def test_custom_repr_non_ascii(self) -> None: + def f() -> None: class A: name = "ä" @@ -704,36 +723,37 @@ def __repr__(self): assert not a.name msg = getmsg(f) + assert msg is not None assert "UnicodeDecodeError" not in msg assert "UnicodeEncodeError" not in msg class TestRewriteOnImport: - def test_pycache_is_a_file(self, testdir): - testdir.tmpdir.join("__pycache__").write("Hello") - testdir.makepyfile( + def test_pycache_is_a_file(self, pytester: Pytester) -> None: + pytester.path.joinpath("__pycache__").write_text("Hello") + pytester.makepyfile( """ def test_rewritten(): assert "@py_builtins" in globals()""" ) - assert testdir.runpytest().ret == 0 + assert pytester.runpytest().ret == 0 - def test_pycache_is_readonly(self, testdir): - cache = testdir.tmpdir.mkdir("__pycache__") - old_mode = cache.stat().mode + def test_pycache_is_readonly(self, pytester: Pytester) -> None: + cache = pytester.mkdir("__pycache__") + old_mode = cache.stat().st_mode cache.chmod(old_mode ^ stat.S_IWRITE) - testdir.makepyfile( + pytester.makepyfile( """ def test_rewritten(): assert "@py_builtins" in globals()""" ) try: - assert testdir.runpytest().ret == 0 + assert pytester.runpytest().ret == 0 finally: cache.chmod(old_mode) - def test_zipfile(self, testdir): - z = testdir.tmpdir.join("myzip.zip") + def test_zipfile(self, pytester: Pytester) -> None: + z = pytester.path.joinpath("myzip.zip") z_fn = str(z) f = zipfile.ZipFile(z_fn, "w") try: @@ -742,33 +762,34 @@ def test_zipfile(self, testdir): finally: f.close() z.chmod(256) - testdir.makepyfile( + pytester.makepyfile( """ import sys sys.path.append(%r) import test_gum.test_lizard""" % (z_fn,) ) - assert testdir.runpytest().ret == ExitCode.NO_TESTS_COLLECTED + assert pytester.runpytest().ret == ExitCode.NO_TESTS_COLLECTED - def test_readonly(self, testdir): - sub = testdir.mkdir("testing") - sub.join("test_readonly.py").write( + def test_readonly(self, pytester: Pytester) -> None: + sub = pytester.mkdir("testing") + sub.joinpath("test_readonly.py").write_bytes( b""" def test_rewritten(): assert "@py_builtins" in globals() """, - "wb", ) - old_mode = sub.stat().mode + old_mode = sub.stat().st_mode sub.chmod(320) try: - assert testdir.runpytest().ret == 0 + assert pytester.runpytest().ret == 0 finally: sub.chmod(old_mode) - def test_dont_write_bytecode(self, testdir, monkeypatch): - testdir.makepyfile( + def test_dont_write_bytecode(self, pytester: Pytester, monkeypatch) -> None: + monkeypatch.delenv("PYTHONPYCACHEPREFIX", raising=False) + + pytester.makepyfile( """ import os def test_no_bytecode(): @@ -777,17 +798,20 @@ def test_no_bytecode(): assert not os.path.exists(os.path.dirname(__cached__))""" ) monkeypatch.setenv("PYTHONDONTWRITEBYTECODE", "1") - assert testdir.runpytest_subprocess().ret == 0 + assert pytester.runpytest_subprocess().ret == 0 - def test_orphaned_pyc_file(self, testdir): - testdir.makepyfile( + def test_orphaned_pyc_file(self, pytester: Pytester, monkeypatch) -> None: + monkeypatch.delenv("PYTHONPYCACHEPREFIX", raising=False) + monkeypatch.setattr(sys, "pycache_prefix", None, raising=False) + + pytester.makepyfile( """ import orphan def test_it(): assert orphan.value == 17 """ ) - testdir.makepyfile( + pytester.makepyfile( orphan=""" value = 17 """ @@ -803,118 +827,120 @@ def test_it(): assert len(pycs) == 1 os.rename(pycs[0], "orphan.pyc") - assert testdir.runpytest().ret == 0 + assert pytester.runpytest().ret == 0 - def test_cached_pyc_includes_pytest_version(self, testdir, monkeypatch): + def test_cached_pyc_includes_pytest_version( + self, pytester: Pytester, monkeypatch + ) -> None: """Avoid stale caches (#1671)""" monkeypatch.delenv("PYTHONDONTWRITEBYTECODE", raising=False) - testdir.makepyfile( + monkeypatch.delenv("PYTHONPYCACHEPREFIX", raising=False) + pytester.makepyfile( test_foo=""" def test_foo(): assert True """ ) - result = testdir.runpytest_subprocess() + result = pytester.runpytest_subprocess() assert result.ret == 0 - found_names = glob.glob( - "__pycache__/*-pytest-{}.pyc".format(pytest.__version__) - ) + found_names = glob.glob(f"__pycache__/*-pytest-{pytest.__version__}.pyc") assert found_names, "pyc with expected tag not found in names: {}".format( glob.glob("__pycache__/*.pyc") ) @pytest.mark.skipif('"__pypy__" in sys.modules') - def test_pyc_vs_pyo(self, testdir, monkeypatch): - testdir.makepyfile( + def test_pyc_vs_pyo(self, pytester: Pytester, monkeypatch) -> None: + pytester.makepyfile( """ import pytest def test_optimized(): "hello" assert test_optimized.__doc__ is None""" ) - p = py.path.local.make_numbered_dir( - prefix="runpytest-", keep=None, rootdir=testdir.tmpdir - ) + p = make_numbered_dir(root=Path(pytester.path), prefix="runpytest-") tmp = "--basetemp=%s" % p monkeypatch.setenv("PYTHONOPTIMIZE", "2") monkeypatch.delenv("PYTHONDONTWRITEBYTECODE", raising=False) - assert testdir.runpytest_subprocess(tmp).ret == 0 + monkeypatch.delenv("PYTHONPYCACHEPREFIX", raising=False) + assert pytester.runpytest_subprocess(tmp).ret == 0 tagged = "test_pyc_vs_pyo." + PYTEST_TAG assert tagged + ".pyo" in os.listdir("__pycache__") monkeypatch.undo() monkeypatch.delenv("PYTHONDONTWRITEBYTECODE", raising=False) - assert testdir.runpytest_subprocess(tmp).ret == 1 + monkeypatch.delenv("PYTHONPYCACHEPREFIX", raising=False) + assert pytester.runpytest_subprocess(tmp).ret == 1 assert tagged + ".pyc" in os.listdir("__pycache__") - def test_package(self, testdir): - pkg = testdir.tmpdir.join("pkg") + def test_package(self, pytester: Pytester) -> None: + pkg = pytester.path.joinpath("pkg") pkg.mkdir() - pkg.join("__init__.py").ensure() - pkg.join("test_blah.py").write( + pkg.joinpath("__init__.py") + pkg.joinpath("test_blah.py").write_text( """ def test_rewritten(): assert "@py_builtins" in globals()""" ) - assert testdir.runpytest().ret == 0 + assert pytester.runpytest().ret == 0 - def test_translate_newlines(self, testdir): + def test_translate_newlines(self, pytester: Pytester) -> None: content = "def test_rewritten():\r\n assert '@py_builtins' in globals()" b = content.encode("utf-8") - testdir.tmpdir.join("test_newlines.py").write(b, "wb") - assert testdir.runpytest().ret == 0 + pytester.path.joinpath("test_newlines.py").write_bytes(b) + assert pytester.runpytest().ret == 0 - def test_package_without__init__py(self, testdir): - pkg = testdir.mkdir("a_package_without_init_py") - pkg.join("module.py").ensure() - testdir.makepyfile("import a_package_without_init_py.module") - assert testdir.runpytest().ret == ExitCode.NO_TESTS_COLLECTED + def test_package_without__init__py(self, pytester: Pytester) -> None: + pkg = pytester.mkdir("a_package_without_init_py") + pkg.joinpath("module.py").touch() + pytester.makepyfile("import a_package_without_init_py.module") + assert pytester.runpytest().ret == ExitCode.NO_TESTS_COLLECTED - def test_rewrite_warning(self, testdir): - testdir.makeconftest( + def test_rewrite_warning(self, pytester: Pytester) -> None: + pytester.makeconftest( """ import pytest pytest.register_assert_rewrite("_pytest") """ ) # needs to be a subprocess because pytester explicitly disables this warning - result = testdir.runpytest_subprocess() + result = pytester.runpytest_subprocess() result.stdout.fnmatch_lines(["*Module already imported*: _pytest"]) - def test_rewrite_module_imported_from_conftest(self, testdir): - testdir.makeconftest( + def test_rewrite_module_imported_from_conftest(self, pytester: Pytester) -> None: + pytester.makeconftest( """ import test_rewrite_module_imported """ ) - testdir.makepyfile( + pytester.makepyfile( test_rewrite_module_imported=""" def test_rewritten(): assert "@py_builtins" in globals() """ ) - assert testdir.runpytest_subprocess().ret == 0 + assert pytester.runpytest_subprocess().ret == 0 - def test_remember_rewritten_modules(self, pytestconfig, testdir, monkeypatch): - """ - AssertionRewriteHook should remember rewritten modules so it - doesn't give false positives (#2005). - """ - monkeypatch.syspath_prepend(testdir.tmpdir) - testdir.makepyfile(test_remember_rewritten_modules="") + def test_remember_rewritten_modules( + self, pytestconfig, pytester: Pytester, monkeypatch + ) -> None: + """`AssertionRewriteHook` should remember rewritten modules so it + doesn't give false positives (#2005).""" + monkeypatch.syspath_prepend(pytester.path) + pytester.makepyfile(test_remember_rewritten_modules="") warnings = [] hook = AssertionRewritingHook(pytestconfig) monkeypatch.setattr( hook, "_warn_already_imported", lambda code, msg: warnings.append(msg) ) spec = hook.find_spec("test_remember_rewritten_modules") + assert spec is not None module = importlib.util.module_from_spec(spec) hook.exec_module(module) hook.mark_rewrite("test_remember_rewritten_modules") hook.mark_rewrite("test_remember_rewritten_modules") assert warnings == [] - def test_rewrite_warning_using_pytest_plugins(self, testdir): - testdir.makepyfile( + def test_rewrite_warning_using_pytest_plugins(self, pytester: Pytester) -> None: + pytester.makepyfile( **{ "conftest.py": "pytest_plugins = ['core', 'gui', 'sci']", "core.py": "", @@ -923,14 +949,16 @@ def test_rewrite_warning_using_pytest_plugins(self, testdir): "test_rewrite_warning_pytest_plugins.py": "def test(): pass", } ) - testdir.chdir() - result = testdir.runpytest_subprocess() + pytester.chdir() + result = pytester.runpytest_subprocess() result.stdout.fnmatch_lines(["*= 1 passed in *=*"]) result.stdout.no_fnmatch_line("*pytest-warning summary*") - def test_rewrite_warning_using_pytest_plugins_env_var(self, testdir, monkeypatch): + def test_rewrite_warning_using_pytest_plugins_env_var( + self, pytester: Pytester, monkeypatch + ) -> None: monkeypatch.setenv("PYTEST_PLUGINS", "plugin") - testdir.makepyfile( + pytester.makepyfile( **{ "plugin.py": "", "test_rewrite_warning_using_pytest_plugins_env_var.py": """ @@ -941,54 +969,56 @@ def test(): """, } ) - testdir.chdir() - result = testdir.runpytest_subprocess() + pytester.chdir() + result = pytester.runpytest_subprocess() result.stdout.fnmatch_lines(["*= 1 passed in *=*"]) result.stdout.no_fnmatch_line("*pytest-warning summary*") class TestAssertionRewriteHookDetails: - def test_sys_meta_path_munged(self, testdir): - testdir.makepyfile( + def test_sys_meta_path_munged(self, pytester: Pytester) -> None: + pytester.makepyfile( """ def test_meta_path(): import sys; sys.meta_path = []""" ) - assert testdir.runpytest().ret == 0 + assert pytester.runpytest().ret == 0 - def test_write_pyc(self, testdir, tmpdir, monkeypatch): + def test_write_pyc(self, pytester: Pytester, tmp_path, monkeypatch) -> None: from _pytest.assertion.rewrite import _write_pyc from _pytest.assertion import AssertionState - config = testdir.parseconfig([]) + config = pytester.parseconfig() state = AssertionState(config, "rewrite") - source_path = str(tmpdir.ensure("source.py")) - pycpath = tmpdir.join("pyc").strpath - assert _write_pyc(state, [1], os.stat(source_path), pycpath) + tmp_path.joinpath("source.py").touch() + source_path = str(tmp_path) + pycpath = tmp_path.joinpath("pyc") + co = compile("1", "f.py", "single") + assert _write_pyc(state, co, os.stat(source_path), pycpath) if sys.platform == "win32": from contextlib import contextmanager @contextmanager def atomic_write_failed(fn, mode="r", overwrite=False): - e = IOError() + e = OSError() e.errno = 10 raise e - yield + yield # type:ignore[unreachable] monkeypatch.setattr( _pytest.assertion.rewrite, "atomic_write", atomic_write_failed ) else: - def raise_ioerror(*args): - raise IOError() + def raise_oserror(*args): + raise OSError() - monkeypatch.setattr("os.rename", raise_ioerror) + monkeypatch.setattr("os.rename", raise_oserror) - assert not _write_pyc(state, [1], os.stat(source_path), pycpath) + assert not _write_pyc(state, co, os.stat(source_path), pycpath) - def test_resources_provider_for_loader(self, testdir): + def test_resources_provider_for_loader(self, pytester: Pytester) -> None: """ Attempts to load resources from a package should succeed normally, even when the AssertionRewriteHook is used to load the modules. @@ -997,7 +1027,7 @@ def test_resources_provider_for_loader(self, testdir): """ pytest.importorskip("pkg_resources") - testdir.mkpydir("testpkg") + pytester.mkpydir("testpkg") contents = { "testpkg/test_pkg": """ import pkg_resources @@ -1012,13 +1042,13 @@ def test_load_resource(): assert res == 'Load me please.' """ } - testdir.makepyfile(**contents) - testdir.maketxtfile(**{"testpkg/resource": "Load me please."}) + pytester.makepyfile(**contents) + pytester.maketxtfile(**{"testpkg/resource": "Load me please."}) - result = testdir.runpytest_subprocess() + result = pytester.runpytest_subprocess() result.assert_outcomes(passed=1) - def test_read_pyc(self, tmpdir): + def test_read_pyc(self, tmp_path: Path) -> None: """ Ensure that the `_read_pyc` can properly deal with corrupted pyc files. In those circumstances it should just give up instead of generating @@ -1027,94 +1057,103 @@ def test_read_pyc(self, tmpdir): import py_compile from _pytest.assertion.rewrite import _read_pyc - source = tmpdir.join("source.py") - pyc = source + "c" + source = tmp_path / "source.py" + pyc = Path(str(source) + "c") - source.write("def test(): pass") + source.write_text("def test(): pass") py_compile.compile(str(source), str(pyc)) - contents = pyc.read(mode="rb") - strip_bytes = 20 # header is around 8 bytes, strip a little more + contents = pyc.read_bytes() + strip_bytes = 20 # header is around 16 bytes, strip a little more assert len(contents) > strip_bytes - pyc.write(contents[:strip_bytes], mode="wb") + pyc.write_bytes(contents[:strip_bytes]) - assert _read_pyc(str(source), str(pyc)) is None # no error + assert _read_pyc(source, pyc) is None # no error - def test_reload_is_same(self, testdir): - # A file that will be picked up during collecting. - testdir.tmpdir.join("file.py").ensure() - testdir.tmpdir.join("pytest.ini").write( - textwrap.dedent( - """ - [pytest] - python_files = *.py - """ - ) - ) + @pytest.mark.skipif( + sys.version_info < (3, 7), reason="Only the Python 3.7 format for simplicity" + ) + def test_read_pyc_more_invalid(self, tmp_path: Path) -> None: + from _pytest.assertion.rewrite import _read_pyc - testdir.makepyfile( - test_fun=""" - import sys - try: - from imp import reload - except ImportError: - pass + source = tmp_path / "source.py" + pyc = tmp_path / "source.pyc" - def test_loader(): - import file - assert sys.modules["file"] is reload(file) + source_bytes = b"def test(): pass\n" + source.write_bytes(source_bytes) + + magic = importlib.util.MAGIC_NUMBER + + flags = b"\x00\x00\x00\x00" + + mtime = b"\x58\x3c\xb0\x5f" + mtime_int = int.from_bytes(mtime, "little") + os.utime(source, (mtime_int, mtime_int)) + + size = len(source_bytes).to_bytes(4, "little") + + code = marshal.dumps(compile(source_bytes, str(source), "exec")) + + # Good header. + pyc.write_bytes(magic + flags + mtime + size + code) + assert _read_pyc(source, pyc, print) is not None + + # Too short. + pyc.write_bytes(magic + flags + mtime) + assert _read_pyc(source, pyc, print) is None + + # Bad magic. + pyc.write_bytes(b"\x12\x34\x56\x78" + flags + mtime + size + code) + assert _read_pyc(source, pyc, print) is None + + # Unsupported flags. + pyc.write_bytes(magic + b"\x00\xff\x00\x00" + mtime + size + code) + assert _read_pyc(source, pyc, print) is None + + # Bad mtime. + pyc.write_bytes(magic + flags + b"\x58\x3d\xb0\x5f" + size + code) + assert _read_pyc(source, pyc, print) is None + + # Bad size. + pyc.write_bytes(magic + flags + mtime + b"\x99\x00\x00\x00" + code) + assert _read_pyc(source, pyc, print) is None + + def test_reload_is_same_and_reloads(self, pytester: Pytester) -> None: + """Reloading a (collected) module after change picks up the change.""" + pytester.makeini( + """ + [pytest] + python_files = *.py """ ) - result = testdir.runpytest("-s") - result.stdout.fnmatch_lines(["* 1 passed*"]) - - def test_reload_reloads(self, testdir): - """Reloading a module after change picks up the change.""" - testdir.tmpdir.join("file.py").write( - textwrap.dedent( - """ + pytester.makepyfile( + file=""" def reloaded(): return False def rewrite_self(): with open(__file__, 'w') as self: self.write('def reloaded(): return True') - """ - ) - ) - testdir.tmpdir.join("pytest.ini").write( - textwrap.dedent( - """ - [pytest] - python_files = *.py - """ - ) - ) - - testdir.makepyfile( + """, test_fun=""" import sys - try: - from imp import reload - except ImportError: - pass + from importlib import reload def test_loader(): import file assert not file.reloaded() file.rewrite_self() - reload(file) + assert sys.modules["file"] is reload(file) assert file.reloaded() - """ + """, ) - result = testdir.runpytest("-s") + result = pytester.runpytest() result.stdout.fnmatch_lines(["* 1 passed*"]) - def test_get_data_support(self, testdir): - """Implement optional PEP302 api (#808). - """ - path = testdir.mkpydir("foo") - path.join("test_foo.py").write( + def test_get_data_support(self, pytester: Pytester) -> None: + """Implement optional PEP302 api (#808).""" + path = pytester.mkpydir("foo") + path.joinpath("test_foo.py").write_text( textwrap.dedent( """\ class Test(object): @@ -1125,13 +1164,13 @@ def test_foo(self): """ ) ) - path.join("data.txt").write("Hey") - result = testdir.runpytest() + path.joinpath("data.txt").write_text("Hey") + result = pytester.runpytest() result.stdout.fnmatch_lines(["*1 passed*"]) -def test_issue731(testdir): - testdir.makepyfile( +def test_issue731(pytester: Pytester) -> None: + pytester.makepyfile( """ class LongReprWithBraces(object): def __repr__(self): @@ -1145,45 +1184,45 @@ def test_long_repr(): assert obj.some_method() """ ) - result = testdir.runpytest() + result = pytester.runpytest() result.stdout.no_fnmatch_line("*unbalanced braces*") class TestIssue925: - def test_simple_case(self, testdir): - testdir.makepyfile( + def test_simple_case(self, pytester: Pytester) -> None: + pytester.makepyfile( """ def test_ternary_display(): assert (False == False) == False """ ) - result = testdir.runpytest() + result = pytester.runpytest() result.stdout.fnmatch_lines(["*E*assert (False == False) == False"]) - def test_long_case(self, testdir): - testdir.makepyfile( + def test_long_case(self, pytester: Pytester) -> None: + pytester.makepyfile( """ def test_ternary_display(): assert False == (False == True) == True """ ) - result = testdir.runpytest() + result = pytester.runpytest() result.stdout.fnmatch_lines(["*E*assert (False == True) == True"]) - def test_many_brackets(self, testdir): - testdir.makepyfile( + def test_many_brackets(self, pytester: Pytester) -> None: + pytester.makepyfile( """ def test_ternary_display(): assert True == ((False == True) == True) """ ) - result = testdir.runpytest() + result = pytester.runpytest() result.stdout.fnmatch_lines(["*E*assert True == ((False == True) == True)"]) class TestIssue2121: - def test_rewrite_python_files_contain_subdirs(self, testdir): - testdir.makepyfile( + def test_rewrite_python_files_contain_subdirs(self, pytester: Pytester) -> None: + pytester.makepyfile( **{ "tests/file.py": """ def test_simple_failure(): @@ -1191,13 +1230,13 @@ def test_simple_failure(): """ } ) - testdir.makeini( + pytester.makeini( """ [pytest] python_files = tests/**.py """ ) - result = testdir.runpytest() + result = pytester.runpytest() result.stdout.fnmatch_lines(["*E*assert (1 + 1) == 3"]) @@ -1205,7 +1244,7 @@ def test_simple_failure(): sys.maxsize <= (2 ** 31 - 1), reason="Causes OverflowError on 32bit systems" ) @pytest.mark.parametrize("offset", [-1, +1]) -def test_source_mtime_long_long(testdir, offset): +def test_source_mtime_long_long(pytester: Pytester, offset) -> None: """Support modification dates after 2038 in rewritten files (#4903). pytest would crash with: @@ -1213,7 +1252,7 @@ def test_source_mtime_long_long(testdir, offset): fp.write(struct.pack(" None: """Fix infinite recursion when writing pyc files: if an import happens to be triggered when writing the pyc file, this would cause another call to the hook, which would trigger another pyc writing, which could trigger another import, and so on. (#3506)""" - from _pytest.assertion import rewrite + from _pytest.assertion import rewrite as rewritemod - testdir.syspathinsert() - testdir.makepyfile(test_foo="def test_foo(): pass") - testdir.makepyfile(test_bar="def test_bar(): pass") + pytester.syspathinsert() + pytester.makepyfile(test_foo="def test_foo(): pass") + pytester.makepyfile(test_bar="def test_bar(): pass") - original_write_pyc = rewrite._write_pyc + original_write_pyc = rewritemod._write_pyc write_pyc_called = [] @@ -1248,7 +1289,7 @@ def spy_write_pyc(*args, **kwargs): assert hook.find_spec("test_bar") is None return original_write_pyc(*args, **kwargs) - monkeypatch.setattr(rewrite, "_write_pyc", spy_write_pyc) + monkeypatch.setattr(rewritemod, "_write_pyc", spy_write_pyc) monkeypatch.setattr(sys, "dont_write_bytecode", False) hook = AssertionRewritingHook(pytestconfig) @@ -1261,14 +1302,16 @@ def spy_write_pyc(*args, **kwargs): class TestEarlyRewriteBailout: @pytest.fixture - def hook(self, pytestconfig, monkeypatch, testdir): + def hook( + self, pytestconfig, monkeypatch, pytester: Pytester + ) -> AssertionRewritingHook: """Returns a patched AssertionRewritingHook instance so we can configure its initial paths and track if PathFinder.find_spec has been called. """ import importlib.machinery - self.find_spec_calls = [] - self.initial_paths = set() + self.find_spec_calls: List[str] = [] + self.initial_paths: Set[py.path.local] = set() class StubSession: _initialpaths = self.initial_paths @@ -1284,26 +1327,26 @@ def spy_find_spec(name, path): # use default patterns, otherwise we inherit pytest's testing config hook.fnpats[:] = ["test_*.py", "*_test.py"] monkeypatch.setattr(hook, "_find_spec", spy_find_spec) - hook.set_session(StubSession()) - testdir.syspathinsert() + hook.set_session(StubSession()) # type: ignore[arg-type] + pytester.syspathinsert() return hook - def test_basic(self, testdir, hook): + def test_basic(self, pytester: Pytester, hook: AssertionRewritingHook) -> None: """ Ensure we avoid calling PathFinder.find_spec when we know for sure a certain module will not be rewritten to optimize assertion rewriting (#3918). """ - testdir.makeconftest( + pytester.makeconftest( """ import pytest @pytest.fixture def fix(): return 1 """ ) - testdir.makepyfile(test_foo="def test_foo(): pass") - testdir.makepyfile(bar="def bar(): pass") - foobar_path = testdir.makepyfile(foobar="def foobar(): pass") - self.initial_paths.add(foobar_path) + pytester.makepyfile(test_foo="def test_foo(): pass") + pytester.makepyfile(bar="def bar(): pass") + foobar_path = pytester.makepyfile(foobar="def foobar(): pass") + self.initial_paths.add(py.path.local(foobar_path)) # conftest files should always be rewritten assert hook.find_spec("conftest") is not None @@ -1321,11 +1364,13 @@ def fix(): return 1 assert hook.find_spec("foobar") is not None assert self.find_spec_calls == ["conftest", "test_foo", "foobar"] - def test_pattern_contains_subdirectories(self, testdir, hook): + def test_pattern_contains_subdirectories( + self, pytester: Pytester, hook: AssertionRewritingHook + ) -> None: """If one of the python_files patterns contain subdirectories ("tests/**.py") we can't bailout early because we need to match with the full path, which can only be found by calling PathFinder.find_spec """ - p = testdir.makepyfile( + pytester.makepyfile( **{ "tests/file.py": """\ def test_simple_failure(): @@ -1333,7 +1378,7 @@ def test_simple_failure(): """ } ) - testdir.syspathinsert(p.dirpath()) + pytester.syspathinsert("tests") hook.fnpats[:] = ["tests/**.py"] assert hook.find_spec("file") is not None assert self.find_spec_calls == ["file"] @@ -1341,14 +1386,14 @@ def test_simple_failure(): @pytest.mark.skipif( sys.platform.startswith("win32"), reason="cannot remove cwd on Windows" ) - def test_cwd_changed(self, testdir, monkeypatch): + def test_cwd_changed(self, pytester: Pytester, monkeypatch) -> None: # Setup conditions for py's fspath trying to import pathlib on py34 # always (previously triggered via xdist only). # Ref: https://github.com/pytest-dev/py/pull/207 monkeypatch.syspath_prepend("") monkeypatch.delitem(sys.modules, "pathlib", raising=False) - testdir.makepyfile( + pytester.makepyfile( **{ "test_setup_nonexisting_cwd.py": """\ import os @@ -1365,30 +1410,30 @@ def test(): """, } ) - result = testdir.runpytest() + result = pytester.runpytest() result.stdout.fnmatch_lines(["* 1 passed in *"]) class TestAssertionPass: - def test_option_default(self, testdir): - config = testdir.parseconfig() + def test_option_default(self, pytester: Pytester) -> None: + config = pytester.parseconfig() assert config.getini("enable_assertion_pass_hook") is False @pytest.fixture - def flag_on(self, testdir): - testdir.makeini("[pytest]\nenable_assertion_pass_hook = True\n") + def flag_on(self, pytester: Pytester): + pytester.makeini("[pytest]\nenable_assertion_pass_hook = True\n") @pytest.fixture - def hook_on(self, testdir): - testdir.makeconftest( + def hook_on(self, pytester: Pytester): + pytester.makeconftest( """\ def pytest_assertion_pass(item, lineno, orig, expl): raise Exception("Assertion Passed: {} {} at line {}".format(orig, expl, lineno)) """ ) - def test_hook_call(self, testdir, flag_on, hook_on): - testdir.makepyfile( + def test_hook_call(self, pytester: Pytester, flag_on, hook_on) -> None: + pytester.makepyfile( """\ def test_simple(): a=1 @@ -1403,23 +1448,25 @@ def test_fails(): assert False, "assert with message" """ ) - result = testdir.runpytest() + result = pytester.runpytest() result.stdout.fnmatch_lines( "*Assertion Passed: a+b == c+d (1 + 2) == (3 + 0) at line 7*" ) - def test_hook_call_with_parens(self, testdir, flag_on, hook_on): - testdir.makepyfile( + def test_hook_call_with_parens(self, pytester: Pytester, flag_on, hook_on) -> None: + pytester.makepyfile( """\ def f(): return 1 def test(): assert f() """ ) - result = testdir.runpytest() + result = pytester.runpytest() result.stdout.fnmatch_lines("*Assertion Passed: f() 1") - def test_hook_not_called_without_hookimpl(self, testdir, monkeypatch, flag_on): + def test_hook_not_called_without_hookimpl( + self, pytester: Pytester, monkeypatch, flag_on + ) -> None: """Assertion pass should not be called (and hence formatting should not occur) if there is no hook declared for pytest_assertion_pass""" @@ -1430,7 +1477,7 @@ def raise_on_assertionpass(*_, **__): _pytest.assertion.rewrite, "_call_assertion_pass", raise_on_assertionpass ) - testdir.makepyfile( + pytester.makepyfile( """\ def test_simple(): a=1 @@ -1441,10 +1488,12 @@ def test_simple(): assert a+b == c+d """ ) - result = testdir.runpytest() + result = pytester.runpytest() result.assert_outcomes(passed=1) - def test_hook_not_called_without_cmd_option(self, testdir, monkeypatch): + def test_hook_not_called_without_cmd_option( + self, pytester: Pytester, monkeypatch + ) -> None: """Assertion pass should not be called (and hence formatting should not occur) if there is no hook declared for pytest_assertion_pass""" @@ -1455,14 +1504,14 @@ def raise_on_assertionpass(*_, **__): _pytest.assertion.rewrite, "_call_assertion_pass", raise_on_assertionpass ) - testdir.makeconftest( + pytester.makeconftest( """\ def pytest_assertion_pass(item, lineno, orig, expl): raise Exception("Assertion Passed: {} {} at line {}".format(orig, expl, lineno)) """ ) - testdir.makepyfile( + pytester.makepyfile( """\ def test_simple(): a=1 @@ -1473,7 +1522,7 @@ def test_simple(): assert a+b == c+d """ ) - result = testdir.runpytest() + result = pytester.runpytest() result.assert_outcomes(passed=1) @@ -1560,21 +1609,21 @@ def test_simple(): # fmt: on ), ) -def test_get_assertion_exprs(src, expected): +def test_get_assertion_exprs(src, expected) -> None: assert _get_assertion_exprs(src) == expected -def test_try_makedirs(monkeypatch, tmp_path): +def test_try_makedirs(monkeypatch, tmp_path: Path) -> None: from _pytest.assertion.rewrite import try_makedirs p = tmp_path / "foo" # create - assert try_makedirs(str(p)) + assert try_makedirs(p) assert p.is_dir() # already exist - assert try_makedirs(str(p)) + assert try_makedirs(p) # monkeypatch to simulate all error situations def fake_mkdir(p, exist_ok=False, *, exc): @@ -1582,25 +1631,25 @@ def fake_mkdir(p, exist_ok=False, *, exc): raise exc monkeypatch.setattr(os, "makedirs", partial(fake_mkdir, exc=FileNotFoundError())) - assert not try_makedirs(str(p)) + assert not try_makedirs(p) monkeypatch.setattr(os, "makedirs", partial(fake_mkdir, exc=NotADirectoryError())) - assert not try_makedirs(str(p)) + assert not try_makedirs(p) monkeypatch.setattr(os, "makedirs", partial(fake_mkdir, exc=PermissionError())) - assert not try_makedirs(str(p)) + assert not try_makedirs(p) err = OSError() err.errno = errno.EROFS monkeypatch.setattr(os, "makedirs", partial(fake_mkdir, exc=err)) - assert not try_makedirs(str(p)) + assert not try_makedirs(p) # unhandled OSError should raise err = OSError() err.errno = errno.ECHILD monkeypatch.setattr(os, "makedirs", partial(fake_mkdir, exc=err)) with pytest.raises(OSError) as exc_info: - try_makedirs(str(p)) + try_makedirs(p) assert exc_info.value.errno == errno.ECHILD @@ -1614,24 +1663,27 @@ class TestPyCacheDir: (None, "/home/projects/src/foo.py", "/home/projects/src/__pycache__"), ], ) - def test_get_cache_dir(self, monkeypatch, prefix, source, expected): - if prefix: - if sys.version_info < (3, 8): - pytest.skip("pycache_prefix not available in py<38") - monkeypatch.setattr(sys, "pycache_prefix", prefix) + def test_get_cache_dir(self, monkeypatch, prefix, source, expected) -> None: + monkeypatch.delenv("PYTHONPYCACHEPREFIX", raising=False) + + if prefix is not None and sys.version_info < (3, 8): + pytest.skip("pycache_prefix not available in py<38") + monkeypatch.setattr(sys, "pycache_prefix", prefix, raising=False) assert get_cache_dir(Path(source)) == Path(expected) @pytest.mark.skipif( sys.version_info < (3, 8), reason="pycache_prefix not available in py<38" ) - def test_sys_pycache_prefix_integration(self, tmp_path, monkeypatch, testdir): + def test_sys_pycache_prefix_integration( + self, tmp_path, monkeypatch, pytester: Pytester + ) -> None: """Integration test for sys.pycache_prefix (#4730).""" pycache_prefix = tmp_path / "my/pycs" monkeypatch.setattr(sys, "pycache_prefix", str(pycache_prefix)) monkeypatch.setattr(sys, "dont_write_bytecode", False) - testdir.makepyfile( + pytester.makepyfile( **{ "src/test_foo.py": """ import bar @@ -1641,11 +1693,11 @@ def test_foo(): "src/bar/__init__.py": "", } ) - result = testdir.runpytest() + result = pytester.runpytest() assert result.ret == 0 - test_foo = Path(testdir.tmpdir) / "src/test_foo.py" - bar_init = Path(testdir.tmpdir) / "src/bar/__init__.py" + test_foo = pytester.path.joinpath("src/test_foo.py") + bar_init = pytester.path.joinpath("src/bar/__init__.py") assert test_foo.is_file() assert bar_init.is_file() diff --git a/testing/test_cacheprovider.py b/testing/test_cacheprovider.py index eb381ab500f..ccc7304b02a 100644 --- a/testing/test_cacheprovider.py +++ b/testing/test_cacheprovider.py @@ -7,6 +7,8 @@ import pytest from _pytest.config import ExitCode +from _pytest.pytester import Pytester +from _pytest.pytester import Testdir pytest_plugins = ("pytester",) @@ -31,7 +33,7 @@ def test_config_cache_dataerror(self, testdir): val = config.cache.get("key/name", -2) assert val == -2 - @pytest.mark.filterwarnings("default") + @pytest.mark.filterwarnings("ignore:could not create cache path") def test_cache_writefail_cachfile_silent(self, testdir): testdir.makeini("[pytest]") testdir.tmpdir.join(".pytest_cache").write("gone wrong") @@ -74,7 +76,7 @@ def test_cache_failure_warns(self, testdir, monkeypatch): "*/cacheprovider.py:*", " */cacheprovider.py:*: PytestCacheWarning: could not create cache path " "{}/v/cache/nodeids".format(cache_dir), - ' config.cache.set("cache/nodeids", self.cached_nodeids)', + ' config.cache.set("cache/nodeids", sorted(self.cached_nodeids))', "*1 failed, 3 warnings in*", ] ) @@ -188,9 +190,7 @@ def test_cache_reportheader_external_abspath(testdir, tmpdir_factory): ) ) result = testdir.runpytest("-v") - result.stdout.fnmatch_lines( - ["cachedir: {abscache}".format(abscache=external_cache)] - ) + result.stdout.fnmatch_lines([f"cachedir: {external_cache}"]) def test_cache_show(testdir): @@ -267,9 +267,9 @@ def test_3(): assert 0 result = testdir.runpytest(str(p), "--lf") result.stdout.fnmatch_lines( [ - "collected 2 items", + "collected 3 items / 1 deselected / 2 selected", "run-last-failure: rerun previous 2 failures", - "*= 2 passed in *", + "*= 2 passed, 1 deselected in *", ] ) result = testdir.runpytest(str(p), "--lf") @@ -345,7 +345,13 @@ def test_a2(): assert 1 result = testdir.runpytest("--lf", p2) result.stdout.fnmatch_lines(["*1 passed*"]) result = testdir.runpytest("--lf", p) - result.stdout.fnmatch_lines(["collected 1 item", "*= 1 failed in *"]) + result.stdout.fnmatch_lines( + [ + "collected 2 items / 1 deselected / 1 selected", + "run-last-failure: rerun previous 1 failure", + "*= 1 failed, 1 deselected in *", + ] + ) def test_lastfailed_usecase_splice(self, testdir, monkeypatch): monkeypatch.setattr("sys.dont_write_bytecode", True) @@ -636,9 +642,7 @@ def get_cached_last_failed(self, testdir): return sorted(config.cache.get("cache/lastfailed", {})) def test_cache_cumulative(self, testdir): - """ - Test workflow where user fixes errors gradually file by file using --lf. - """ + """Test workflow where user fixes errors gradually file by file using --lf.""" # 1. initial run test_bar = testdir.makepyfile( test_bar=""" @@ -690,9 +694,9 @@ def test_foo_4(): pass result = testdir.runpytest(test_foo, "--last-failed") result.stdout.fnmatch_lines( [ - "collected 1 item", + "collected 2 items / 1 deselected / 1 selected", "run-last-failure: rerun previous 1 failure", - "*= 1 passed in *", + "*= 1 passed, 1 deselected in *", ] ) assert self.get_cached_last_failed(testdir) == [] @@ -838,7 +842,7 @@ def test_lastfailed_with_known_failures_not_being_selected(self, testdir): ] ) - # Remove/rename test. + # Remove/rename test: collects the file again. testdir.makepyfile(**{"pkg1/test_1.py": """def test_renamed(): assert 0"""}) result = testdir.runpytest("--lf", "-rf") result.stdout.fnmatch_lines( @@ -852,6 +856,163 @@ def test_lastfailed_with_known_failures_not_being_selected(self, testdir): ] ) + result = testdir.runpytest("--lf", "--co") + result.stdout.fnmatch_lines( + [ + "collected 1 item", + "run-last-failure: rerun previous 1 failure (skipped 1 file)", + "", + "", + " ", + ] + ) + + def test_lastfailed_args_with_deselected(self, testdir: Testdir) -> None: + """Test regression with --lf running into NoMatch error. + + This was caused by it not collecting (non-failed) nodes given as + arguments. + """ + testdir.makepyfile( + **{ + "pkg1/test_1.py": """ + def test_pass(): pass + def test_fail(): assert 0 + """, + } + ) + result = testdir.runpytest() + result.stdout.fnmatch_lines(["collected 2 items", "* 1 failed, 1 passed in *"]) + assert result.ret == 1 + + result = testdir.runpytest("pkg1/test_1.py::test_pass", "--lf", "--co") + assert result.ret == 0 + result.stdout.fnmatch_lines( + [ + "*collected 1 item", + "run-last-failure: 1 known failures not in selected tests", + "", + "", + " ", + ], + consecutive=True, + ) + + result = testdir.runpytest( + "pkg1/test_1.py::test_pass", "pkg1/test_1.py::test_fail", "--lf", "--co" + ) + assert result.ret == 0 + result.stdout.fnmatch_lines( + [ + "collected 2 items / 1 deselected / 1 selected", + "run-last-failure: rerun previous 1 failure", + "", + "", + " ", + "*= 1/2 tests collected (1 deselected) in *", + ], + ) + + def test_lastfailed_with_class_items(self, testdir: Testdir) -> None: + """Test regression with --lf deselecting whole classes.""" + testdir.makepyfile( + **{ + "pkg1/test_1.py": """ + class TestFoo: + def test_pass(self): pass + def test_fail(self): assert 0 + + def test_other(): assert 0 + """, + } + ) + result = testdir.runpytest() + result.stdout.fnmatch_lines(["collected 3 items", "* 2 failed, 1 passed in *"]) + assert result.ret == 1 + + result = testdir.runpytest("--lf", "--co") + assert result.ret == 0 + result.stdout.fnmatch_lines( + [ + "collected 3 items / 1 deselected / 2 selected", + "run-last-failure: rerun previous 2 failures", + "", + "", + " ", + " ", + " ", + "", + "*= 2/3 tests collected (1 deselected) in *", + ], + consecutive=True, + ) + + def test_lastfailed_with_all_filtered(self, testdir: Testdir) -> None: + testdir.makepyfile( + **{ + "pkg1/test_1.py": """ + def test_fail(): assert 0 + def test_pass(): pass + """, + } + ) + result = testdir.runpytest() + result.stdout.fnmatch_lines(["collected 2 items", "* 1 failed, 1 passed in *"]) + assert result.ret == 1 + + # Remove known failure. + testdir.makepyfile( + **{ + "pkg1/test_1.py": """ + def test_pass(): pass + """, + } + ) + result = testdir.runpytest("--lf", "--co") + result.stdout.fnmatch_lines( + [ + "collected 1 item", + "run-last-failure: 1 known failures not in selected tests", + "", + "", + " ", + "", + "*= 1 test collected in*", + ], + consecutive=True, + ) + assert result.ret == 0 + + def test_packages(self, pytester: Pytester) -> None: + """Regression test for #7758. + + The particular issue here was that Package nodes were included in the + filtering, being themselves Modules for the __init__.py, even if they + had failed Modules in them. + + The tests includes a test in an __init__.py file just to make sure the + fix doesn't somehow regress that, it is not critical for the issue. + """ + pytester.makepyfile( + **{ + "__init__.py": "", + "a/__init__.py": "def test_a_init(): assert False", + "a/test_one.py": "def test_1(): assert False", + "b/__init__.py": "", + "b/test_two.py": "def test_2(): assert False", + }, + ) + pytester.makeini( + """ + [pytest] + python_files = *.py + """ + ) + result = pytester.runpytest() + result.assert_outcomes(failed=3) + result = pytester.runpytest("--lf") + result.assert_outcomes(failed=3) + class TestNewFirst: def test_newfirst_usecase(self, testdir): @@ -859,63 +1020,54 @@ def test_newfirst_usecase(self, testdir): **{ "test_1/test_1.py": """ def test_1(): assert 1 - def test_2(): assert 1 - def test_3(): assert 1 """, "test_2/test_2.py": """ def test_1(): assert 1 - def test_2(): assert 1 - def test_3(): assert 1 """, } ) - testdir.tmpdir.join("test_1/test_1.py").setmtime(1) result = testdir.runpytest("-v") result.stdout.fnmatch_lines( - [ - "*test_1/test_1.py::test_1 PASSED*", - "*test_1/test_1.py::test_2 PASSED*", - "*test_1/test_1.py::test_3 PASSED*", - "*test_2/test_2.py::test_1 PASSED*", - "*test_2/test_2.py::test_2 PASSED*", - "*test_2/test_2.py::test_3 PASSED*", - ] + ["*test_1/test_1.py::test_1 PASSED*", "*test_2/test_2.py::test_1 PASSED*"] ) result = testdir.runpytest("-v", "--nf") - result.stdout.fnmatch_lines( - [ - "*test_2/test_2.py::test_1 PASSED*", - "*test_2/test_2.py::test_2 PASSED*", - "*test_2/test_2.py::test_3 PASSED*", - "*test_1/test_1.py::test_1 PASSED*", - "*test_1/test_1.py::test_2 PASSED*", - "*test_1/test_1.py::test_3 PASSED*", - ] + ["*test_2/test_2.py::test_1 PASSED*", "*test_1/test_1.py::test_1 PASSED*"] ) testdir.tmpdir.join("test_1/test_1.py").write( - "def test_1(): assert 1\n" - "def test_2(): assert 1\n" - "def test_3(): assert 1\n" - "def test_4(): assert 1\n" + "def test_1(): assert 1\n" "def test_2(): assert 1\n" ) testdir.tmpdir.join("test_1/test_1.py").setmtime(1) - result = testdir.runpytest("-v", "--nf") + result = testdir.runpytest("--nf", "--collect-only", "-q") + result.stdout.fnmatch_lines( + [ + "test_1/test_1.py::test_2", + "test_2/test_2.py::test_1", + "test_1/test_1.py::test_1", + ] + ) + # Newest first with (plugin) pytest_collection_modifyitems hook. + testdir.makepyfile( + myplugin=""" + def pytest_collection_modifyitems(items): + items[:] = sorted(items, key=lambda item: item.nodeid) + print("new_items:", [x.nodeid for x in items]) + """ + ) + testdir.syspathinsert() + result = testdir.runpytest("--nf", "-p", "myplugin", "--collect-only", "-q") result.stdout.fnmatch_lines( [ - "*test_1/test_1.py::test_4 PASSED*", - "*test_2/test_2.py::test_1 PASSED*", - "*test_2/test_2.py::test_2 PASSED*", - "*test_2/test_2.py::test_3 PASSED*", - "*test_1/test_1.py::test_1 PASSED*", - "*test_1/test_1.py::test_2 PASSED*", - "*test_1/test_1.py::test_3 PASSED*", + "new_items: *test_1.py*test_1.py*test_2.py*", + "test_1/test_1.py::test_2", + "test_2/test_2.py::test_1", + "test_1/test_1.py::test_1", ] ) @@ -948,7 +1100,6 @@ def test_1(num): assert num ) result = testdir.runpytest("-v", "--nf") - result.stdout.fnmatch_lines( [ "*test_2/test_2.py::test_1[1*", @@ -1005,7 +1156,7 @@ def test_gitignore(testdir): from _pytest.cacheprovider import Cache config = testdir.parseconfig() - cache = Cache.for_config(config) + cache = Cache.for_config(config, _ispytest=True) cache.set("foo", "bar") msg = "# Created by pytest automatically.\n*\n" gitignore_path = cache._cachedir.joinpath(".gitignore") @@ -1027,7 +1178,7 @@ def test_does_not_create_boilerplate_in_existing_dirs(testdir): """ ) config = testdir.parseconfig() - cache = Cache.for_config(config) + cache = Cache.for_config(config, _ispytest=True) cache.set("foo", "bar") assert os.path.isdir("v") # cache contents @@ -1041,7 +1192,7 @@ def test_cachedir_tag(testdir): from _pytest.cacheprovider import CACHEDIR_TAG_CONTENT config = testdir.parseconfig() - cache = Cache.for_config(config) + cache = Cache.for_config(config, _ispytest=True) cache.set("foo", "bar") cachedir_tag_path = cache._cachedir.joinpath("CACHEDIR.TAG") assert cachedir_tag_path.read_bytes() == CACHEDIR_TAG_CONTENT diff --git a/testing/test_capture.py b/testing/test_capture.py index a3e558560a1..3a5c617fe5a 100644 --- a/testing/test_capture.py +++ b/testing/test_capture.py @@ -1,63 +1,63 @@ import contextlib import io import os -import pickle import subprocess import sys import textwrap -from io import StringIO from io import UnsupportedOperation from typing import BinaryIO +from typing import cast from typing import Generator -from typing import List from typing import TextIO import pytest from _pytest import capture from _pytest.capture import _get_multicapture +from _pytest.capture import CaptureFixture from _pytest.capture import CaptureManager +from _pytest.capture import CaptureResult from _pytest.capture import MultiCapture from _pytest.config import ExitCode +from _pytest.monkeypatch import MonkeyPatch +from _pytest.pytester import Pytester # note: py.io capture tests where copied from # pylib 1.4.20.dev2 (rev 13d9af95547e) -needsosdup = pytest.mark.skipif( - not hasattr(os, "dup"), reason="test needs os.dup, not available on this platform" -) - - -def StdCaptureFD(out=True, err=True, in_=True): - return capture.MultiCapture(out, err, in_, Capture=capture.FDCapture) +def StdCaptureFD( + out: bool = True, err: bool = True, in_: bool = True +) -> MultiCapture[str]: + return capture.MultiCapture( + in_=capture.FDCapture(0) if in_ else None, + out=capture.FDCapture(1) if out else None, + err=capture.FDCapture(2) if err else None, + ) -def StdCapture(out=True, err=True, in_=True): - return capture.MultiCapture(out, err, in_, Capture=capture.SysCapture) +def StdCapture( + out: bool = True, err: bool = True, in_: bool = True +) -> MultiCapture[str]: + return capture.MultiCapture( + in_=capture.SysCapture(0) if in_ else None, + out=capture.SysCapture(1) if out else None, + err=capture.SysCapture(2) if err else None, + ) -def TeeStdCapture(out=True, err=True, in_=True): - return capture.MultiCapture(out, err, in_, Capture=capture.TeeSysCapture) +def TeeStdCapture( + out: bool = True, err: bool = True, in_: bool = True +) -> MultiCapture[str]: + return capture.MultiCapture( + in_=capture.SysCapture(0, tee=True) if in_ else None, + out=capture.SysCapture(1, tee=True) if out else None, + err=capture.SysCapture(2, tee=True) if err else None, + ) class TestCaptureManager: - def test_getmethod_default_no_fd(self, monkeypatch): - from _pytest.capture import pytest_addoption - from _pytest.config.argparsing import Parser - - parser = Parser() - pytest_addoption(parser) - default = parser._groups[0].options[0].default - assert default == "fd" if hasattr(os, "dup") else "sys" - parser = Parser() - monkeypatch.delattr(os, "dup", raising=False) - pytest_addoption(parser) - assert parser._groups[0].options[0].default == "sys" - - @pytest.mark.parametrize( - "method", ["no", "sys", pytest.param("fd", marks=needsosdup)] - ) - def test_capturing_basic_api(self, method): + @pytest.mark.parametrize("method", ["no", "sys", "fd"]) + def test_capturing_basic_api(self, method) -> None: capouter = StdCaptureFD() old = sys.stdout, sys.stderr, sys.stdin try: @@ -86,7 +86,6 @@ def test_capturing_basic_api(self, method): finally: capouter.stop_capturing() - @needsosdup def test_init_capturing(self): capouter = StdCaptureFD() try: @@ -99,9 +98,9 @@ def test_init_capturing(self): @pytest.mark.parametrize("method", ["fd", "sys"]) -def test_capturing_unicode(testdir, method): +def test_capturing_unicode(pytester: Pytester, method: str) -> None: obj = "'b\u00f6y'" - testdir.makepyfile( + pytester.makepyfile( """\ # taken from issue 227 from nosetests def test_unicode(): @@ -111,24 +110,24 @@ def test_unicode(): """ % obj ) - result = testdir.runpytest("--capture=%s" % method) + result = pytester.runpytest("--capture=%s" % method) result.stdout.fnmatch_lines(["*1 passed*"]) @pytest.mark.parametrize("method", ["fd", "sys"]) -def test_capturing_bytes_in_utf8_encoding(testdir, method): - testdir.makepyfile( +def test_capturing_bytes_in_utf8_encoding(pytester: Pytester, method: str) -> None: + pytester.makepyfile( """\ def test_unicode(): print('b\\u00f6y') """ ) - result = testdir.runpytest("--capture=%s" % method) + result = pytester.runpytest("--capture=%s" % method) result.stdout.fnmatch_lines(["*1 passed*"]) -def test_collect_capturing(testdir): - p = testdir.makepyfile( +def test_collect_capturing(pytester: Pytester) -> None: + p = pytester.makepyfile( """ import sys @@ -137,7 +136,7 @@ def test_collect_capturing(testdir): import xyz42123 """ ) - result = testdir.runpytest(p) + result = pytester.runpytest(p) result.stdout.fnmatch_lines( [ "*Captured stdout*", @@ -149,8 +148,8 @@ def test_collect_capturing(testdir): class TestPerTestCapturing: - def test_capture_and_fixtures(self, testdir): - p = testdir.makepyfile( + def test_capture_and_fixtures(self, pytester: Pytester) -> None: + p = pytester.makepyfile( """ def setup_module(mod): print("setup module") @@ -164,7 +163,7 @@ def test_func2(): assert 0 """ ) - result = testdir.runpytest(p) + result = pytester.runpytest(p) result.stdout.fnmatch_lines( [ "setup module*", @@ -176,8 +175,8 @@ def test_func2(): ) @pytest.mark.xfail(reason="unimplemented feature") - def test_capture_scope_cache(self, testdir): - p = testdir.makepyfile( + def test_capture_scope_cache(self, pytester: Pytester) -> None: + p = pytester.makepyfile( """ import sys def setup_module(func): @@ -191,7 +190,7 @@ def teardown_function(func): print("in teardown") """ ) - result = testdir.runpytest(p) + result = pytester.runpytest(p) result.stdout.fnmatch_lines( [ "*test_func():*", @@ -203,8 +202,8 @@ def teardown_function(func): ] ) - def test_no_carry_over(self, testdir): - p = testdir.makepyfile( + def test_no_carry_over(self, pytester: Pytester) -> None: + p = pytester.makepyfile( """ def test_func1(): print("in func1") @@ -213,13 +212,13 @@ def test_func2(): assert 0 """ ) - result = testdir.runpytest(p) + result = pytester.runpytest(p) s = result.stdout.str() assert "in func1" not in s assert "in func2" in s - def test_teardown_capturing(self, testdir): - p = testdir.makepyfile( + def test_teardown_capturing(self, pytester: Pytester) -> None: + p = pytester.makepyfile( """ def setup_function(function): print("setup func1") @@ -231,7 +230,7 @@ def test_func1(): pass """ ) - result = testdir.runpytest(p) + result = pytester.runpytest(p) result.stdout.fnmatch_lines( [ "*teardown_function*", @@ -243,8 +242,8 @@ def test_func1(): ] ) - def test_teardown_capturing_final(self, testdir): - p = testdir.makepyfile( + def test_teardown_capturing_final(self, pytester: Pytester) -> None: + p = pytester.makepyfile( """ def teardown_module(mod): print("teardown module") @@ -253,7 +252,7 @@ def test_func(): pass """ ) - result = testdir.runpytest(p) + result = pytester.runpytest(p) result.stdout.fnmatch_lines( [ "*def teardown_module(mod):*", @@ -263,8 +262,8 @@ def test_func(): ] ) - def test_capturing_outerr(self, testdir): - p1 = testdir.makepyfile( + def test_capturing_outerr(self, pytester: Pytester) -> None: + p1 = pytester.makepyfile( """\ import sys def test_capturing(): @@ -276,7 +275,7 @@ def test_capturing_error(): raise ValueError """ ) - result = testdir.runpytest(p1) + result = pytester.runpytest(p1) result.stdout.fnmatch_lines( [ "*test_capturing_outerr.py .F*", @@ -292,8 +291,8 @@ def test_capturing_error(): class TestLoggingInteraction: - def test_logging_stream_ownership(self, testdir): - p = testdir.makepyfile( + def test_logging_stream_ownership(self, pytester: Pytester) -> None: + p = pytester.makepyfile( """\ def test_logging(): import logging @@ -303,11 +302,11 @@ def test_logging(): stream.close() # to free memory/release resources """ ) - result = testdir.runpytest_subprocess(p) + result = pytester.runpytest_subprocess(p) assert result.stderr.str().find("atexit") == -1 - def test_logging_and_immediate_setupteardown(self, testdir): - p = testdir.makepyfile( + def test_logging_and_immediate_setupteardown(self, pytester: Pytester) -> None: + p = pytester.makepyfile( """\ import logging def setup_function(function): @@ -324,7 +323,7 @@ def teardown_function(function): ) for optargs in (("--capture=sys",), ("--capture=fd",)): print(optargs) - result = testdir.runpytest_subprocess(p, *optargs) + result = pytester.runpytest_subprocess(p, *optargs) s = result.stdout.str() result.stdout.fnmatch_lines( ["*WARN*hello3", "*WARN*hello1", "*WARN*hello2"] # errors show first! @@ -332,8 +331,8 @@ def teardown_function(function): # verify proper termination assert "closed" not in s - def test_logging_and_crossscope_fixtures(self, testdir): - p = testdir.makepyfile( + def test_logging_and_crossscope_fixtures(self, pytester: Pytester) -> None: + p = pytester.makepyfile( """\ import logging def setup_module(function): @@ -350,7 +349,7 @@ def teardown_module(function): ) for optargs in (("--capture=sys",), ("--capture=fd",)): print(optargs) - result = testdir.runpytest_subprocess(p, *optargs) + result = pytester.runpytest_subprocess(p, *optargs) s = result.stdout.str() result.stdout.fnmatch_lines( ["*WARN*hello3", "*WARN*hello1", "*WARN*hello2"] # errors come first @@ -358,8 +357,8 @@ def teardown_module(function): # verify proper termination assert "closed" not in s - def test_conftestlogging_is_shown(self, testdir): - testdir.makeconftest( + def test_conftestlogging_is_shown(self, pytester: Pytester) -> None: + pytester.makeconftest( """\ import logging logging.basicConfig() @@ -367,20 +366,20 @@ def test_conftestlogging_is_shown(self, testdir): """ ) # make sure that logging is still captured in tests - result = testdir.runpytest_subprocess("-s", "-p", "no:capturelog") + result = pytester.runpytest_subprocess("-s", "-p", "no:capturelog") assert result.ret == ExitCode.NO_TESTS_COLLECTED result.stderr.fnmatch_lines(["WARNING*hello435*"]) assert "operation on closed file" not in result.stderr.str() - def test_conftestlogging_and_test_logging(self, testdir): - testdir.makeconftest( + def test_conftestlogging_and_test_logging(self, pytester: Pytester) -> None: + pytester.makeconftest( """\ import logging logging.basicConfig() """ ) # make sure that logging is still captured in tests - p = testdir.makepyfile( + p = pytester.makepyfile( """\ def test_hello(): import logging @@ -388,14 +387,14 @@ def test_hello(): assert 0 """ ) - result = testdir.runpytest_subprocess(p, "-p", "no:capturelog") + result = pytester.runpytest_subprocess(p, "-p", "no:capturelog") assert result.ret != 0 result.stdout.fnmatch_lines(["WARNING*hello433*"]) assert "something" not in result.stderr.str() assert "operation on closed file" not in result.stderr.str() - def test_logging_after_cap_stopped(self, testdir): - testdir.makeconftest( + def test_logging_after_cap_stopped(self, pytester: Pytester) -> None: + pytester.makeconftest( """\ import pytest import logging @@ -409,7 +408,7 @@ def log_on_teardown(): """ ) # make sure that logging is still captured in tests - p = testdir.makepyfile( + p = pytester.makepyfile( """\ def test_hello(log_on_teardown): import logging @@ -418,7 +417,7 @@ def test_hello(log_on_teardown): raise KeyboardInterrupt() """ ) - result = testdir.runpytest_subprocess(p, "--log-cli-level", "info") + result = pytester.runpytest_subprocess(p, "--log-cli-level", "info") assert result.ret != 0 result.stdout.fnmatch_lines( ["*WARNING*hello433*", "*WARNING*Logging on teardown*"] @@ -431,8 +430,8 @@ def test_hello(log_on_teardown): class TestCaptureFixture: @pytest.mark.parametrize("opt", [[], ["-s"]]) - def test_std_functional(self, testdir, opt): - reprec = testdir.inline_runsource( + def test_std_functional(self, pytester: Pytester, opt) -> None: + reprec = pytester.inline_runsource( """\ def test_hello(capsys): print(42) @@ -443,8 +442,8 @@ def test_hello(capsys): ) reprec.assertoutcome(passed=1) - def test_capsyscapfd(self, testdir): - p = testdir.makepyfile( + def test_capsyscapfd(self, pytester: Pytester) -> None: + p = pytester.makepyfile( """\ def test_one(capsys, capfd): pass @@ -452,7 +451,7 @@ def test_two(capfd, capsys): pass """ ) - result = testdir.runpytest(p) + result = pytester.runpytest(p) result.stdout.fnmatch_lines( [ "*ERROR*setup*test_one*", @@ -463,11 +462,11 @@ def test_two(capfd, capsys): ] ) - def test_capturing_getfixturevalue(self, testdir): + def test_capturing_getfixturevalue(self, pytester: Pytester) -> None: """Test that asking for "capfd" and "capsys" using request.getfixturevalue in the same test is an error. """ - testdir.makepyfile( + pytester.makepyfile( """\ def test_one(capsys, request): request.getfixturevalue("capfd") @@ -475,7 +474,7 @@ def test_two(capfd, request): request.getfixturevalue("capsys") """ ) - result = testdir.runpytest() + result = pytester.runpytest() result.stdout.fnmatch_lines( [ "*test_one*", @@ -486,21 +485,23 @@ def test_two(capfd, request): ] ) - def test_capsyscapfdbinary(self, testdir): - p = testdir.makepyfile( + def test_capsyscapfdbinary(self, pytester: Pytester) -> None: + p = pytester.makepyfile( """\ def test_one(capsys, capfdbinary): pass """ ) - result = testdir.runpytest(p) + result = pytester.runpytest(p) result.stdout.fnmatch_lines( ["*ERROR*setup*test_one*", "E*capfdbinary*capsys*same*time*", "*1 error*"] ) @pytest.mark.parametrize("method", ["sys", "fd"]) - def test_capture_is_represented_on_failure_issue128(self, testdir, method): - p = testdir.makepyfile( + def test_capture_is_represented_on_failure_issue128( + self, pytester: Pytester, method + ) -> None: + p = pytester.makepyfile( """\ def test_hello(cap{}): print("xxx42xxx") @@ -509,12 +510,11 @@ def test_hello(cap{}): method ) ) - result = testdir.runpytest(p) + result = pytester.runpytest(p) result.stdout.fnmatch_lines(["xxx42xxx"]) - @needsosdup - def test_stdfd_functional(self, testdir): - reprec = testdir.inline_runsource( + def test_stdfd_functional(self, pytester: Pytester) -> None: + reprec = pytester.inline_runsource( """\ def test_hello(capfd): import os @@ -526,9 +526,14 @@ def test_hello(capfd): ) reprec.assertoutcome(passed=1) - @needsosdup - def test_capfdbinary(self, testdir): - reprec = testdir.inline_runsource( + @pytest.mark.parametrize("nl", ("\n", "\r\n", "\r")) + def test_cafd_preserves_newlines(self, capfd, nl) -> None: + print("test", end=nl) + out, err = capfd.readouterr() + assert out.endswith(nl) + + def test_capfdbinary(self, pytester: Pytester) -> None: + reprec = pytester.inline_runsource( """\ def test_hello(capfdbinary): import os @@ -541,33 +546,54 @@ def test_hello(capfdbinary): ) reprec.assertoutcome(passed=1) - def test_capsysbinary(self, testdir): - reprec = testdir.inline_runsource( - """\ + def test_capsysbinary(self, pytester: Pytester) -> None: + p1 = pytester.makepyfile( + r""" def test_hello(capsysbinary): import sys - # some likely un-decodable bytes - sys.stdout.buffer.write(b'\\xfe\\x98\\x20') + + sys.stdout.buffer.write(b'hello') + + # Some likely un-decodable bytes. + sys.stdout.buffer.write(b'\xfe\x98\x20') + + sys.stdout.buffer.flush() + + # Ensure writing in text mode still works and is captured. + # https://github.com/pytest-dev/pytest/issues/6871 + print("world", flush=True) + out, err = capsysbinary.readouterr() - assert out == b'\\xfe\\x98\\x20' + assert out == b'hello\xfe\x98\x20world\n' assert err == b'' + + print("stdout after") + print("stderr after", file=sys.stderr) """ ) - reprec.assertoutcome(passed=1) + result = pytester.runpytest(str(p1), "-rA") + result.stdout.fnmatch_lines( + [ + "*- Captured stdout call -*", + "stdout after", + "*- Captured stderr call -*", + "stderr after", + "*= 1 passed in *", + ] + ) - def test_partial_setup_failure(self, testdir): - p = testdir.makepyfile( + def test_partial_setup_failure(self, pytester: Pytester) -> None: + p = pytester.makepyfile( """\ def test_hello(capsys, missingarg): pass """ ) - result = testdir.runpytest(p) + result = pytester.runpytest(p) result.stdout.fnmatch_lines(["*test_partial_setup_failure*", "*1 error*"]) - @needsosdup - def test_keyboardinterrupt_disables_capturing(self, testdir): - p = testdir.makepyfile( + def test_keyboardinterrupt_disables_capturing(self, pytester: Pytester) -> None: + p = pytester.makepyfile( """\ def test_hello(capfd): import os @@ -575,26 +601,28 @@ def test_hello(capfd): raise KeyboardInterrupt() """ ) - result = testdir.runpytest_subprocess(p) + result = pytester.runpytest_subprocess(p) result.stdout.fnmatch_lines(["*KeyboardInterrupt*"]) assert result.ret == 2 - def test_capture_and_logging(self, testdir): + def test_capture_and_logging(self, pytester: Pytester) -> None: """#14""" - p = testdir.makepyfile( + p = pytester.makepyfile( """\ import logging def test_log(capsys): logging.error('x') """ ) - result = testdir.runpytest_subprocess(p) + result = pytester.runpytest_subprocess(p) assert "closed" not in result.stderr.str() @pytest.mark.parametrize("fixture", ["capsys", "capfd"]) @pytest.mark.parametrize("no_capture", [True, False]) - def test_disabled_capture_fixture(self, testdir, fixture, no_capture): - testdir.makepyfile( + def test_disabled_capture_fixture( + self, pytester: Pytester, fixture: str, no_capture: bool + ) -> None: + pytester.makepyfile( """\ def test_disabled({fixture}): print('captured before') @@ -610,7 +638,7 @@ def test_normal(): ) ) args = ("-s",) if no_capture else () - result = testdir.runpytest_subprocess(*args) + result = pytester.runpytest_subprocess(*args) result.stdout.fnmatch_lines(["*while capture is disabled*", "*= 2 passed in *"]) result.stdout.no_fnmatch_line("*captured before*") result.stdout.no_fnmatch_line("*captured after*") @@ -619,12 +647,39 @@ def test_normal(): else: result.stdout.no_fnmatch_line("*test_normal executed*") - @pytest.mark.parametrize("fixture", ["capsys", "capfd"]) - def test_fixture_use_by_other_fixtures(self, testdir, fixture): + def test_disabled_capture_fixture_twice(self, pytester: Pytester) -> None: + """Test that an inner disabled() exit doesn't undo an outer disabled(). + + Issue #7148. """ - Ensure that capsys and capfd can be used by other fixtures during setup and teardown. + pytester.makepyfile( + """ + def test_disabled(capfd): + print('captured before') + with capfd.disabled(): + print('while capture is disabled 1') + with capfd.disabled(): + print('while capture is disabled 2') + print('while capture is disabled 1 after') + print('captured after') + assert capfd.readouterr() == ('captured before\\ncaptured after\\n', '') """ - testdir.makepyfile( + ) + result = pytester.runpytest_subprocess() + result.stdout.fnmatch_lines( + [ + "*while capture is disabled 1", + "*while capture is disabled 2", + "*while capture is disabled 1 after", + ], + consecutive=True, + ) + + @pytest.mark.parametrize("fixture", ["capsys", "capfd"]) + def test_fixture_use_by_other_fixtures(self, pytester: Pytester, fixture) -> None: + """Ensure that capsys and capfd can be used by other fixtures during + setup and teardown.""" + pytester.makepyfile( """\ import sys import pytest @@ -651,15 +706,17 @@ def test_captured_print(captured_print): fixture=fixture ) ) - result = testdir.runpytest_subprocess() + result = pytester.runpytest_subprocess() result.stdout.fnmatch_lines(["*1 passed*"]) result.stdout.no_fnmatch_line("*stdout contents begin*") result.stdout.no_fnmatch_line("*stderr contents begin*") @pytest.mark.parametrize("cap", ["capsys", "capfd"]) - def test_fixture_use_by_other_fixtures_teardown(self, testdir, cap): + def test_fixture_use_by_other_fixtures_teardown( + self, pytester: Pytester, cap + ) -> None: """Ensure we can access setup and teardown buffers from teardown when using capsys/capfd (##3033)""" - testdir.makepyfile( + pytester.makepyfile( """\ import sys import pytest @@ -681,13 +738,13 @@ def test_a(fix): cap=cap ) ) - reprec = testdir.inline_run() + reprec = pytester.inline_run() reprec.assertoutcome(passed=1) -def test_setup_failure_does_not_kill_capturing(testdir): - sub1 = testdir.mkpydir("sub1") - sub1.join("conftest.py").write( +def test_setup_failure_does_not_kill_capturing(pytester: Pytester) -> None: + sub1 = pytester.mkpydir("sub1") + sub1.joinpath("conftest.py").write_text( textwrap.dedent( """\ def pytest_runtest_setup(item): @@ -695,40 +752,26 @@ def pytest_runtest_setup(item): """ ) ) - sub1.join("test_mod.py").write("def test_func1(): pass") - result = testdir.runpytest(testdir.tmpdir, "--traceconfig") + sub1.joinpath("test_mod.py").write_text("def test_func1(): pass") + result = pytester.runpytest(pytester.path, "--traceconfig") result.stdout.fnmatch_lines(["*ValueError(42)*", "*1 error*"]) -def test_fdfuncarg_skips_on_no_osdup(testdir): - testdir.makepyfile( - """ - import os - if hasattr(os, 'dup'): - del os.dup - def test_hello(capfd): - pass - """ - ) - result = testdir.runpytest_subprocess("--capture=no") - result.stdout.fnmatch_lines(["*1 skipped*"]) - - -def test_capture_conftest_runtest_setup(testdir): - testdir.makeconftest( +def test_capture_conftest_runtest_setup(pytester: Pytester) -> None: + pytester.makeconftest( """ def pytest_runtest_setup(): print("hello19") """ ) - testdir.makepyfile("def test_func(): pass") - result = testdir.runpytest() + pytester.makepyfile("def test_func(): pass") + result = pytester.runpytest() assert result.ret == 0 result.stdout.no_fnmatch_line("*hello19*") -def test_capture_badoutput_issue412(testdir): - testdir.makepyfile( +def test_capture_badoutput_issue412(pytester: Pytester) -> None: + pytester.makepyfile( """ import os @@ -738,7 +781,7 @@ def test_func(): assert 0 """ ) - result = testdir.runpytest("--capture=fd") + result = pytester.runpytest("--capture=fd") result.stdout.fnmatch_lines( """ *def test_func* @@ -749,21 +792,21 @@ def test_func(): ) -def test_capture_early_option_parsing(testdir): - testdir.makeconftest( +def test_capture_early_option_parsing(pytester: Pytester) -> None: + pytester.makeconftest( """ def pytest_runtest_setup(): print("hello19") """ ) - testdir.makepyfile("def test_func(): pass") - result = testdir.runpytest("-vs") + pytester.makepyfile("def test_func(): pass") + result = pytester.runpytest("-vs") assert result.ret == 0 assert "hello19" in result.stdout.str() -def test_capture_binary_output(testdir): - testdir.makepyfile( +def test_capture_binary_output(pytester: Pytester) -> None: + pytester.makepyfile( r""" import pytest @@ -779,13 +822,13 @@ def test_foo(): test_foo() """ ) - result = testdir.runpytest("--assert=plain") + result = pytester.runpytest("--assert=plain") result.assert_outcomes(passed=2) -def test_error_during_readouterr(testdir): +def test_error_during_readouterr(pytester: Pytester) -> None: """Make sure we suspend capturing if errors occur during readouterr""" - testdir.makepyfile( + pytester.makepyfile( pytest_xyz=""" from _pytest.capture import FDCapture @@ -796,26 +839,26 @@ def bad_snap(self): FDCapture.snap = bad_snap """ ) - result = testdir.runpytest_subprocess("-p", "pytest_xyz", "--version") + result = pytester.runpytest_subprocess("-p", "pytest_xyz", "--version") result.stderr.fnmatch_lines( ["*in bad_snap", " raise Exception('boom')", "Exception: boom"] ) class TestCaptureIO: - def test_text(self): + def test_text(self) -> None: f = capture.CaptureIO() f.write("hello") s = f.getvalue() assert s == "hello" f.close() - def test_unicode_and_str_mixture(self): + def test_unicode_and_str_mixture(self) -> None: f = capture.CaptureIO() f.write("\u00f6") pytest.raises(TypeError, f.write, b"hello") - def test_write_bytes_to_buffer(self): + def test_write_bytes_to_buffer(self) -> None: """In python3, stdout / stderr are text io wrappers (exposing a buffer property of the underlying bytestream). See issue #1407 """ @@ -824,10 +867,10 @@ def test_write_bytes_to_buffer(self): assert f.getvalue() == "foo\r\n" -class TestCaptureAndPassthroughIO(TestCaptureIO): - def test_text(self): +class TestTeeCaptureIO(TestCaptureIO): + def test_text(self) -> None: sio = io.StringIO() - f = capture.CaptureAndPassthroughIO(sio) + f = capture.TeeCaptureIO(sio) f.write("hello") s1 = f.getvalue() assert s1 == "hello" @@ -836,78 +879,65 @@ def test_text(self): f.close() sio.close() - def test_unicode_and_str_mixture(self): + def test_unicode_and_str_mixture(self) -> None: sio = io.StringIO() - f = capture.CaptureAndPassthroughIO(sio) + f = capture.TeeCaptureIO(sio) f.write("\u00f6") pytest.raises(TypeError, f.write, b"hello") -def test_dontreadfrominput(): +def test_dontreadfrominput() -> None: from _pytest.capture import DontReadFromInput f = DontReadFromInput() assert f.buffer is f assert not f.isatty() - pytest.raises(IOError, f.read) - pytest.raises(IOError, f.readlines) + pytest.raises(OSError, f.read) + pytest.raises(OSError, f.readlines) iter_f = iter(f) - pytest.raises(IOError, next, iter_f) + pytest.raises(OSError, next, iter_f) pytest.raises(UnsupportedOperation, f.fileno) f.close() # just for completeness +def test_captureresult() -> None: + cr = CaptureResult("out", "err") + assert len(cr) == 2 + assert cr.out == "out" + assert cr.err == "err" + out, err = cr + assert out == "out" + assert err == "err" + assert cr[0] == "out" + assert cr[1] == "err" + assert cr == cr + assert cr == CaptureResult("out", "err") + assert cr != CaptureResult("wrong", "err") + assert cr == ("out", "err") + assert cr != ("out", "wrong") + assert hash(cr) == hash(CaptureResult("out", "err")) + assert hash(cr) == hash(("out", "err")) + assert hash(cr) != hash(("out", "wrong")) + assert cr < ("z",) + assert cr < ("z", "b") + assert cr < ("z", "b", "c") + assert cr.count("err") == 1 + assert cr.count("wrong") == 0 + assert cr.index("err") == 1 + with pytest.raises(ValueError): + assert cr.index("wrong") == 0 + assert next(iter(cr)) == "out" + assert cr._replace(err="replaced") == ("out", "replaced") + + @pytest.fixture -def tmpfile(testdir) -> Generator[BinaryIO, None, None]: - f = testdir.makepyfile("").open("wb+") +def tmpfile(pytester: Pytester) -> Generator[BinaryIO, None, None]: + f = pytester.makepyfile("").open("wb+") yield f if not f.closed: f.close() -@needsosdup -def test_dupfile(tmpfile) -> None: - flist = [] # type: List[TextIO] - for i in range(5): - nf = capture.safe_text_dupfile(tmpfile, "wb") - assert nf != tmpfile - assert nf.fileno() != tmpfile.fileno() - assert nf not in flist - print(i, end="", file=nf) - flist.append(nf) - - fname_open = flist[0].name - assert fname_open == repr(flist[0].buffer) - - for i in range(5): - f = flist[i] - f.close() - fname_closed = flist[0].name - assert fname_closed == repr(flist[0].buffer) - assert fname_closed != fname_open - tmpfile.seek(0) - s = tmpfile.read() - assert "01234" in repr(s) - tmpfile.close() - assert fname_closed == repr(flist[0].buffer) - - -def test_dupfile_on_bytesio(): - bio = io.BytesIO() - f = capture.safe_text_dupfile(bio, "wb") - f.write("hello") - assert bio.getvalue() == b"hello" - assert "BytesIO object" in f.name - - -def test_dupfile_on_textio(): - sio = StringIO() - f = capture.safe_text_dupfile(sio, "wb") - f.write("hello") - assert sio.getvalue() == "hello" - assert not hasattr(f, "name") - - @contextlib.contextmanager def lsof_check(): pid = os.getpid() @@ -915,7 +945,7 @@ def lsof_check(): out = subprocess.check_output(("lsof", "-p", str(pid))).decode() except (OSError, subprocess.CalledProcessError, UnicodeDecodeError) as exc: # about UnicodeDecodeError, see note on pytester - pytest.skip("could not run 'lsof' ({!r})".format(exc)) + pytest.skip(f"could not run 'lsof' ({exc!r})") yield out2 = subprocess.check_output(("lsof", "-p", str(pid))).decode() len1 = len([x for x in out.split("\n") if "REG" in x]) @@ -924,16 +954,13 @@ def lsof_check(): class TestFDCapture: - pytestmark = needsosdup - - def test_simple(self, tmpfile): + def test_simple(self, tmpfile: BinaryIO) -> None: fd = tmpfile.fileno() cap = capture.FDCapture(fd) data = b"hello" os.write(fd, data) - s = cap.snap() + pytest.raises(AssertionError, cap.snap) cap.done() - assert not s cap = capture.FDCapture(fd) cap.start() os.write(fd, data) @@ -941,22 +968,22 @@ def test_simple(self, tmpfile): cap.done() assert s == "hello" - def test_simple_many(self, tmpfile): + def test_simple_many(self, tmpfile: BinaryIO) -> None: for i in range(10): self.test_simple(tmpfile) - def test_simple_many_check_open_files(self, testdir): + def test_simple_many_check_open_files(self, pytester: Pytester) -> None: with lsof_check(): - with testdir.makepyfile("").open("wb+") as tmpfile: + with pytester.makepyfile("").open("wb+") as tmpfile: self.test_simple_many(tmpfile) - def test_simple_fail_second_start(self, tmpfile): + def test_simple_fail_second_start(self, tmpfile: BinaryIO) -> None: fd = tmpfile.fileno() cap = capture.FDCapture(fd) cap.done() - pytest.raises(ValueError, cap.start) + pytest.raises(AssertionError, cap.start) - def test_stderr(self): + def test_stderr(self) -> None: cap = capture.FDCapture(2) cap.start() print("hello", file=sys.stderr) @@ -964,20 +991,20 @@ def test_stderr(self): cap.done() assert s == "hello\n" - def test_stdin(self): + def test_stdin(self) -> None: cap = capture.FDCapture(0) cap.start() x = os.read(0, 100).strip() cap.done() assert x == b"" - def test_writeorg(self, tmpfile): + def test_writeorg(self, tmpfile: BinaryIO) -> None: data1, data2 = b"foo", b"bar" cap = capture.FDCapture(tmpfile.fileno()) cap.start() tmpfile.write(data1) tmpfile.flush() - cap.writeorg(data2) + cap.writeorg(data2.decode("ascii")) scap = cap.snap() cap.done() assert scap == data1.decode("ascii") @@ -985,7 +1012,7 @@ def test_writeorg(self, tmpfile): stmp = stmp_file.read() assert stmp == data2 - def test_simple_resume_suspend(self): + def test_simple_resume_suspend(self) -> None: with saved_fd(1): cap = capture.FDCapture(1) cap.start() @@ -1005,11 +1032,11 @@ def test_simple_resume_suspend(self): assert s == "but now yes\n" cap.suspend() cap.done() - pytest.raises(AttributeError, cap.suspend) + pytest.raises(AssertionError, cap.suspend) assert repr(cap) == ( - " _state='done' tmpfile={!r}>".format( - cap.tmpfile + "".format( + cap.targetfd_save, cap.tmpfile ) ) # Should not crash with missing "_old". @@ -1019,7 +1046,7 @@ def test_simple_resume_suspend(self): ) ) - def test_capfd_sys_stdout_mode(self, capfd): + def test_capfd_sys_stdout_mode(self, capfd) -> None: assert "b" not in sys.stdout.mode @@ -1045,7 +1072,7 @@ def getcapture(self, **kw): finally: cap.stop_capturing() - def test_capturing_done_simple(self): + def test_capturing_done_simple(self) -> None: with self.getcapture() as cap: sys.stdout.write("hello") sys.stderr.write("world") @@ -1053,7 +1080,7 @@ def test_capturing_done_simple(self): assert out == "hello" assert err == "world" - def test_capturing_reset_simple(self): + def test_capturing_reset_simple(self) -> None: with self.getcapture() as cap: print("hello world") sys.stderr.write("hello error\n") @@ -1061,7 +1088,7 @@ def test_capturing_reset_simple(self): assert out == "hello world\n" assert err == "hello error\n" - def test_capturing_readouterr(self): + def test_capturing_readouterr(self) -> None: with self.getcapture() as cap: print("hello world") sys.stderr.write("hello error\n") @@ -1072,7 +1099,7 @@ def test_capturing_readouterr(self): out, err = cap.readouterr() assert err == "error2" - def test_capture_results_accessible_by_attribute(self): + def test_capture_results_accessible_by_attribute(self) -> None: with self.getcapture() as cap: sys.stdout.write("hello") sys.stderr.write("world") @@ -1080,13 +1107,13 @@ def test_capture_results_accessible_by_attribute(self): assert capture_result.out == "hello" assert capture_result.err == "world" - def test_capturing_readouterr_unicode(self): + def test_capturing_readouterr_unicode(self) -> None: with self.getcapture() as cap: print("hxąć") out, err = cap.readouterr() assert out == "hxąć\n" - def test_reset_twice_error(self): + def test_reset_twice_error(self) -> None: with self.getcapture() as cap: print("hello") out, err = cap.readouterr() @@ -1094,7 +1121,7 @@ def test_reset_twice_error(self): assert out == "hello\n" assert not err - def test_capturing_modify_sysouterr_in_between(self): + def test_capturing_modify_sysouterr_in_between(self) -> None: oldout = sys.stdout olderr = sys.stderr with self.getcapture() as cap: @@ -1110,7 +1137,7 @@ def test_capturing_modify_sysouterr_in_between(self): assert sys.stdout == oldout assert sys.stderr == olderr - def test_capturing_error_recursive(self): + def test_capturing_error_recursive(self) -> None: with self.getcapture() as cap1: print("cap1") with self.getcapture() as cap2: @@ -1120,7 +1147,7 @@ def test_capturing_error_recursive(self): assert out1 == "cap1\n" assert out2 == "cap2\n" - def test_just_out_capture(self): + def test_just_out_capture(self) -> None: with self.getcapture(out=True, err=False) as cap: sys.stdout.write("hello") sys.stderr.write("world") @@ -1128,7 +1155,7 @@ def test_just_out_capture(self): assert out == "hello" assert not err - def test_just_err_capture(self): + def test_just_err_capture(self) -> None: with self.getcapture(out=False, err=True) as cap: sys.stdout.write("hello") sys.stderr.write("world") @@ -1136,27 +1163,27 @@ def test_just_err_capture(self): assert err == "world" assert not out - def test_stdin_restored(self): + def test_stdin_restored(self) -> None: old = sys.stdin with self.getcapture(in_=True): newstdin = sys.stdin assert newstdin != sys.stdin assert sys.stdin is old - def test_stdin_nulled_by_default(self): + def test_stdin_nulled_by_default(self) -> None: print("XXX this test may well hang instead of crashing") print("XXX which indicates an error in the underlying capturing") print("XXX mechanisms") with self.getcapture(): - pytest.raises(IOError, sys.stdin.read) + pytest.raises(OSError, sys.stdin.read) class TestTeeStdCapture(TestStdCapture): captureclass = staticmethod(TeeStdCapture) - def test_capturing_error_recursive(self): - """ for TeeStdCapture since we passthrough stderr/stdout, cap1 - should get all output, while cap2 should only get "cap2\n" """ + def test_capturing_error_recursive(self) -> None: + r"""For TeeStdCapture since we passthrough stderr/stdout, cap1 + should get all output, while cap2 should only get "cap2\n".""" with self.getcapture() as cap1: print("cap1") @@ -1169,11 +1196,10 @@ def test_capturing_error_recursive(self): class TestStdCaptureFD(TestStdCapture): - pytestmark = needsosdup captureclass = staticmethod(StdCaptureFD) - def test_simple_only_fd(self, testdir): - testdir.makepyfile( + def test_simple_only_fd(self, pytester: Pytester) -> None: + pytester.makepyfile( """\ import os def test_x(): @@ -1181,7 +1207,7 @@ def test_x(): assert 0 """ ) - result = testdir.runpytest_subprocess() + result = pytester.runpytest_subprocess() result.stdout.fnmatch_lines( """ *test_x* @@ -1208,51 +1234,94 @@ def test_many(self, capfd): with lsof_check(): for i in range(10): cap = StdCaptureFD() + cap.start_capturing() cap.stop_capturing() class TestStdCaptureFDinvalidFD: - pytestmark = needsosdup - - def test_stdcapture_fd_invalid_fd(self, testdir): - testdir.makepyfile( + def test_stdcapture_fd_invalid_fd(self, pytester: Pytester) -> None: + pytester.makepyfile( """ import os + from fnmatch import fnmatch from _pytest import capture def StdCaptureFD(out=True, err=True, in_=True): - return capture.MultiCapture(out, err, in_, Capture=capture.FDCapture) + return capture.MultiCapture( + in_=capture.FDCapture(0) if in_ else None, + out=capture.FDCapture(1) if out else None, + err=capture.FDCapture(2) if err else None, + ) def test_stdout(): os.close(1) cap = StdCaptureFD(out=True, err=False, in_=False) - assert repr(cap.out) == " _state=None tmpfile=>" + assert fnmatch(repr(cap.out), "") + cap.start_capturing() + os.write(1, b"stdout") + assert cap.readouterr() == ("stdout", "") cap.stop_capturing() def test_stderr(): os.close(2) cap = StdCaptureFD(out=False, err=True, in_=False) - assert repr(cap.err) == " _state=None tmpfile=>" + assert fnmatch(repr(cap.err), "") + cap.start_capturing() + os.write(2, b"stderr") + assert cap.readouterr() == ("", "stderr") cap.stop_capturing() def test_stdin(): os.close(0) cap = StdCaptureFD(out=False, err=False, in_=True) - assert repr(cap.in_) == " _state=None tmpfile=>" + assert fnmatch(repr(cap.in_), "") cap.stop_capturing() """ ) - result = testdir.runpytest_subprocess("--capture=fd") + result = pytester.runpytest_subprocess("--capture=fd") assert result.ret == 0 assert result.parseoutcomes()["passed"] == 3 + def test_fdcapture_invalid_fd_with_fd_reuse(self, pytester: Pytester) -> None: + with saved_fd(1): + os.close(1) + cap = capture.FDCaptureBinary(1) + cap.start() + os.write(1, b"started") + cap.suspend() + os.write(1, b" suspended") + cap.resume() + os.write(1, b" resumed") + assert cap.snap() == b"started resumed" + cap.done() + with pytest.raises(OSError): + os.write(1, b"done") -def test_capture_not_started_but_reset(): + def test_fdcapture_invalid_fd_without_fd_reuse(self, pytester: Pytester) -> None: + with saved_fd(1), saved_fd(2): + os.close(1) + os.close(2) + cap = capture.FDCaptureBinary(2) + cap.start() + os.write(2, b"started") + cap.suspend() + os.write(2, b" suspended") + cap.resume() + os.write(2, b" resumed") + assert cap.snap() == b"started resumed" + cap.done() + with pytest.raises(OSError): + os.write(2, b"done") + + +def test_capture_not_started_but_reset() -> None: capsys = StdCapture() capsys.stop_capturing() -def test_using_capsys_fixture_works_with_sys_stdout_encoding(capsys): +def test_using_capsys_fixture_works_with_sys_stdout_encoding( + capsys: CaptureFixture[str], +) -> None: test_text = "test text" print(test_text.encode(sys.stdout.encoding, "replace")) @@ -1261,7 +1330,7 @@ def test_using_capsys_fixture_works_with_sys_stdout_encoding(capsys): assert err == "" -def test_capsys_results_accessible_by_attribute(capsys): +def test_capsys_results_accessible_by_attribute(capsys: CaptureFixture[str]) -> None: sys.stdout.write("spam") sys.stderr.write("eggs") capture_result = capsys.readouterr() @@ -1269,12 +1338,8 @@ def test_capsys_results_accessible_by_attribute(capsys): assert capture_result.err == "eggs" -@needsosdup -@pytest.mark.parametrize("use", [True, False]) -def test_fdcapture_tmpfile_remains_the_same(tmpfile, use): - if not use: - tmpfile = True - cap = StdCaptureFD(out=False, err=tmpfile) +def test_fdcapture_tmpfile_remains_the_same() -> None: + cap = StdCaptureFD(out=False, err=True) try: cap.start_capturing() capfile = cap.err.tmpfile @@ -1285,9 +1350,8 @@ def test_fdcapture_tmpfile_remains_the_same(tmpfile, use): assert capfile2 == capfile -@needsosdup -def test_close_and_capture_again(testdir): - testdir.makepyfile( +def test_close_and_capture_again(pytester: Pytester) -> None: + pytester.makepyfile( """ import os def test_close(): @@ -1297,7 +1361,7 @@ def test_capture_again(): assert 0 """ ) - result = testdir.runpytest_subprocess() + result = pytester.runpytest_subprocess() result.stdout.fnmatch_lines( """ *test_capture_again* @@ -1308,18 +1372,21 @@ def test_capture_again(): ) -@pytest.mark.parametrize("method", ["SysCapture", "FDCapture", "TeeSysCapture"]) -def test_capturing_and_logging_fundamentals(testdir, method): - if method == "StdCaptureFD" and not hasattr(os, "dup"): - pytest.skip("need os.dup") +@pytest.mark.parametrize( + "method", ["SysCapture(2)", "SysCapture(2, tee=True)", "FDCapture(2)"] +) +def test_capturing_and_logging_fundamentals(pytester: Pytester, method: str) -> None: # here we check a fundamental feature - p = testdir.makepyfile( + p = pytester.makepyfile( """ import sys, os import py, logging from _pytest import capture - cap = capture.MultiCapture(out=False, in_=False, - Capture=capture.%s) + cap = capture.MultiCapture( + in_=None, + out=None, + err=capture.%s, + ) cap.start_capturing() logging.warning("hello1") @@ -1335,7 +1402,7 @@ def test_capturing_and_logging_fundamentals(testdir, method): """ % (method,) ) - result = testdir.runpython(p) + result = pytester.runpython(p) result.stdout.fnmatch_lines( """ suspend, captured*hello1* @@ -1350,24 +1417,23 @@ def test_capturing_and_logging_fundamentals(testdir, method): assert "atexit" not in result.stderr.str() -def test_error_attribute_issue555(testdir): - testdir.makepyfile( +def test_error_attribute_issue555(pytester: Pytester) -> None: + pytester.makepyfile( """ import sys def test_capattr(): - assert sys.stdout.errors == "strict" - assert sys.stderr.errors == "strict" + assert sys.stdout.errors == "replace" + assert sys.stderr.errors == "replace" """ ) - reprec = testdir.inline_run() + reprec = pytester.inline_run() reprec.assertoutcome(passed=1) @pytest.mark.skipif( - not sys.platform.startswith("win") and sys.version_info[:2] >= (3, 6), - reason="only py3.6+ on windows", + not sys.platform.startswith("win"), reason="only on windows", ) -def test_py36_windowsconsoleio_workaround_non_standard_streams(): +def test_py36_windowsconsoleio_workaround_non_standard_streams() -> None: """ Ensure _py36_windowsconsoleio_workaround function works with objects that do not implement the full ``io``-based stream protocol, for example execnet channels (#2666). @@ -1378,12 +1444,12 @@ class DummyStream: def write(self, s): pass - stream = DummyStream() + stream = cast(TextIO, DummyStream()) _py36_windowsconsoleio_workaround(stream) -def test_dontreadfrominput_has_encoding(testdir): - testdir.makepyfile( +def test_dontreadfrominput_has_encoding(pytester: Pytester) -> None: + pytester.makepyfile( """ import sys def test_capattr(): @@ -1392,12 +1458,14 @@ def test_capattr(): assert sys.stderr.encoding """ ) - reprec = testdir.inline_run() + reprec = pytester.inline_run() reprec.assertoutcome(passed=1) -def test_crash_on_closing_tmpfile_py27(testdir): - p = testdir.makepyfile( +def test_crash_on_closing_tmpfile_py27( + pytester: Pytester, monkeypatch: MonkeyPatch +) -> None: + p = pytester.makepyfile( """ import threading import sys @@ -1424,28 +1492,19 @@ def test_spam_in_thread(): """ ) # Do not consider plugins like hypothesis, which might output to stderr. - testdir.monkeypatch.setenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD", "1") - result = testdir.runpytest_subprocess(str(p)) + monkeypatch.setenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD", "1") + result = pytester.runpytest_subprocess(str(p)) assert result.ret == 0 assert result.stderr.str() == "" - result.stdout.no_fnmatch_line("*IOError*") - - -def test_pickling_and_unpickling_encoded_file(): - # See https://bitbucket.org/pytest-dev/pytest/pull-request/194 - # pickle.loads() raises infinite recursion if - # EncodedFile.__getattr__ is not implemented properly - ef = capture.EncodedFile(None, None) - ef_as_str = pickle.dumps(ef) - pickle.loads(ef_as_str) + result.stdout.no_fnmatch_line("*OSError*") -def test_global_capture_with_live_logging(testdir): +def test_global_capture_with_live_logging(pytester: Pytester) -> None: # Issue 3819 # capture should work with live cli logging # Teardown report seems to have the capture for the whole process (setup, capture, teardown) - testdir.makeconftest( + pytester.makeconftest( """ def pytest_runtest_logreport(report): if "test_global" in report.nodeid: @@ -1457,7 +1516,7 @@ def pytest_runtest_logreport(report): """ ) - testdir.makepyfile( + pytester.makepyfile( """ import logging import sys @@ -1479,17 +1538,17 @@ def test_global(fix1): print("end test") """ ) - result = testdir.runpytest_subprocess("--log-cli-level=INFO") + result = pytester.runpytest_subprocess("--log-cli-level=INFO") assert result.ret == 0 - with open("caplog", "r") as f: + with open("caplog") as f: caplog = f.read() assert "fix setup" in caplog assert "something in test" in caplog assert "fix teardown" in caplog - with open("capstdout", "r") as f: + with open("capstdout") as f: capstdout = f.read() assert "fix setup" in capstdout @@ -1499,11 +1558,13 @@ def test_global(fix1): @pytest.mark.parametrize("capture_fixture", ["capsys", "capfd"]) -def test_capture_with_live_logging(testdir, capture_fixture): +def test_capture_with_live_logging( + pytester: Pytester, capture_fixture: CaptureFixture[str] +) -> None: # Issue 3819 # capture should work with live cli logging - testdir.makepyfile( + pytester.makepyfile( """ import logging import sys @@ -1528,47 +1589,78 @@ def test_capture({0}): ) ) - result = testdir.runpytest_subprocess("--log-cli-level=INFO") + result = pytester.runpytest_subprocess("--log-cli-level=INFO") assert result.ret == 0 -def test_typeerror_encodedfile_write(testdir): +def test_typeerror_encodedfile_write(pytester: Pytester) -> None: """It should behave the same with and without output capturing (#4861).""" - p = testdir.makepyfile( + p = pytester.makepyfile( """ def test_fails(): import sys sys.stdout.write(b"foo") """ ) - result_without_capture = testdir.runpytest("-s", str(p)) - result_with_capture = testdir.runpytest(str(p)) + result_without_capture = pytester.runpytest("-s", str(p)) + result_with_capture = pytester.runpytest(str(p)) assert result_with_capture.ret == result_without_capture.ret - result_with_capture.stdout.fnmatch_lines( - ["E * TypeError: write() argument must be str, not bytes"] + out = result_with_capture.stdout.str() + assert ("TypeError: write() argument must be str, not bytes" in out) or ( + "TypeError: unicode argument expected, got 'bytes'" in out ) -def test_stderr_write_returns_len(capsys): +def test_stderr_write_returns_len(capsys: CaptureFixture[str]) -> None: """Write on Encoded files, namely captured stderr, should return number of characters written.""" assert sys.stderr.write("Foo") == 3 def test_encodedfile_writelines(tmpfile: BinaryIO) -> None: - ef = capture.EncodedFile(tmpfile, "utf-8") - with pytest.raises(AttributeError): - ef.writelines([b"line1", b"line2"]) # type: ignore[list-item] # noqa: F821 - assert ef.writelines(["line1", "line2"]) is None # type: ignore[func-returns-value] # noqa: F821 + ef = capture.EncodedFile(tmpfile, encoding="utf-8") + with pytest.raises(TypeError): + ef.writelines([b"line1", b"line2"]) # type: ignore[list-item] + assert ef.writelines(["line3", "line4"]) is None # type: ignore[func-returns-value] + ef.flush() tmpfile.seek(0) - assert tmpfile.read() == b"line1line2" + assert tmpfile.read() == b"line3line4" tmpfile.close() with pytest.raises(ValueError): ef.read() def test__get_multicapture() -> None: - assert isinstance(_get_multicapture("fd"), MultiCapture) + assert isinstance(_get_multicapture("no"), MultiCapture) pytest.raises(ValueError, _get_multicapture, "unknown").match( r"^unknown capturing method: 'unknown'" ) + + +def test_logging_while_collecting(pytester: Pytester) -> None: + """Issue #6240: Calls to logging.xxx() during collection causes all logging calls to be duplicated to stderr""" + p = pytester.makepyfile( + """\ + import logging + + logging.warning("during collection") + + def test_logging(): + logging.warning("during call") + assert False + """ + ) + result = pytester.runpytest_subprocess(p) + assert result.ret == ExitCode.TESTS_FAILED + result.stdout.fnmatch_lines( + [ + "*test_*.py F*", + "====* FAILURES *====", + "____*____", + "*--- Captured log call*", + "WARNING * during call", + "*1 failed*", + ] + ) + result.stdout.no_fnmatch_line("*Captured stderr call*") + result.stdout.no_fnmatch_line("*during collection*") diff --git a/testing/test_collection.py b/testing/test_collection.py index 90c248b4ab2..1138c2bd6f5 100644 --- a/testing/test_collection.py +++ b/testing/test_collection.py @@ -1,78 +1,97 @@ import os import pprint +import shutil import sys import textwrap - -import py +from pathlib import Path +from typing import List import pytest from _pytest.config import ExitCode +from _pytest.fixtures import FixtureRequest from _pytest.main import _in_venv from _pytest.main import Session +from _pytest.monkeypatch import MonkeyPatch +from _pytest.nodes import Item +from _pytest.pathlib import symlink_or_skip +from _pytest.pytester import HookRecorder +from _pytest.pytester import Pytester from _pytest.pytester import Testdir +def ensure_file(file_path: Path) -> Path: + """Ensure that file exists""" + file_path.parent.mkdir(parents=True, exist_ok=True) + file_path.touch(exist_ok=True) + return file_path + + class TestCollector: - def test_collect_versus_item(self): - from pytest import Collector, Item + def test_collect_versus_item(self) -> None: + from pytest import Collector + from pytest import Item assert not issubclass(Collector, Item) assert not issubclass(Item, Collector) - def test_check_equality(self, testdir: Testdir) -> None: - modcol = testdir.getmodulecol( + def test_check_equality(self, pytester: Pytester) -> None: + modcol = pytester.getmodulecol( """ def test_pass(): pass def test_fail(): assert 0 """ ) - fn1 = testdir.collect_by_name(modcol, "test_pass") + fn1 = pytester.collect_by_name(modcol, "test_pass") assert isinstance(fn1, pytest.Function) - fn2 = testdir.collect_by_name(modcol, "test_pass") + fn2 = pytester.collect_by_name(modcol, "test_pass") assert isinstance(fn2, pytest.Function) assert fn1 == fn2 assert fn1 != modcol assert hash(fn1) == hash(fn2) - fn3 = testdir.collect_by_name(modcol, "test_fail") + fn3 = pytester.collect_by_name(modcol, "test_fail") assert isinstance(fn3, pytest.Function) assert not (fn1 == fn3) assert fn1 != fn3 for fn in fn1, fn2, fn3: assert isinstance(fn, pytest.Function) - assert fn != 3 # type: ignore[comparison-overlap] # noqa: F821 + assert fn != 3 # type: ignore[comparison-overlap] assert fn != modcol - assert fn != [1, 2, 3] # type: ignore[comparison-overlap] # noqa: F821 - assert [1, 2, 3] != fn # type: ignore[comparison-overlap] # noqa: F821 + assert fn != [1, 2, 3] # type: ignore[comparison-overlap] + assert [1, 2, 3] != fn # type: ignore[comparison-overlap] assert modcol != fn - assert testdir.collect_by_name(modcol, "doesnotexist") is None + assert pytester.collect_by_name(modcol, "doesnotexist") is None - def test_getparent(self, testdir): - modcol = testdir.getmodulecol( + def test_getparent(self, pytester: Pytester) -> None: + modcol = pytester.getmodulecol( """ - class TestClass(object): - def test_foo(): + class TestClass: + def test_foo(self): pass """ ) - cls = testdir.collect_by_name(modcol, "TestClass") - fn = testdir.collect_by_name(testdir.collect_by_name(cls, "()"), "test_foo") + cls = pytester.collect_by_name(modcol, "TestClass") + assert isinstance(cls, pytest.Class) + instance = pytester.collect_by_name(cls, "()") + assert isinstance(instance, pytest.Instance) + fn = pytester.collect_by_name(instance, "test_foo") + assert isinstance(fn, pytest.Function) - parent = fn.getparent(pytest.Module) - assert parent is modcol + module_parent = fn.getparent(pytest.Module) + assert module_parent is modcol - parent = fn.getparent(pytest.Function) - assert parent is fn + function_parent = fn.getparent(pytest.Function) + assert function_parent is fn - parent = fn.getparent(pytest.Class) - assert parent is cls + class_parent = fn.getparent(pytest.Class) + assert class_parent is cls - def test_getcustomfile_roundtrip(self, testdir): - hello = testdir.makefile(".xxx", hello="world") - testdir.makepyfile( + def test_getcustomfile_roundtrip(self, pytester: Pytester) -> None: + hello = pytester.makefile(".xxx", hello="world") + pytester.makepyfile( conftest=""" import pytest class CustomFile(pytest.File): @@ -82,16 +101,16 @@ def pytest_collect_file(path, parent): return CustomFile.from_parent(fspath=path, parent=parent) """ ) - node = testdir.getpathnode(hello) + node = pytester.getpathnode(hello) assert isinstance(node, pytest.File) assert node.name == "hello.xxx" nodes = node.session.perform_collect([node.nodeid], genitems=False) assert len(nodes) == 1 assert isinstance(nodes[0], pytest.File) - def test_can_skip_class_with_test_attr(self, testdir): + def test_can_skip_class_with_test_attr(self, pytester: Pytester) -> None: """Assure test class is skipped when using `__test__=False` (See #2007).""" - testdir.makepyfile( + pytester.makepyfile( """ class TestFoo(object): __test__ = False @@ -101,25 +120,25 @@ def test_foo(): assert True """ ) - result = testdir.runpytest() + result = pytester.runpytest() result.stdout.fnmatch_lines(["collected 0 items", "*no tests ran in*"]) class TestCollectFS: - def test_ignored_certain_directories(self, testdir): - tmpdir = testdir.tmpdir - tmpdir.ensure("build", "test_notfound.py") - tmpdir.ensure("dist", "test_notfound.py") - tmpdir.ensure("_darcs", "test_notfound.py") - tmpdir.ensure("CVS", "test_notfound.py") - tmpdir.ensure("{arch}", "test_notfound.py") - tmpdir.ensure(".whatever", "test_notfound.py") - tmpdir.ensure(".bzr", "test_notfound.py") - tmpdir.ensure("normal", "test_found.py") - for x in tmpdir.visit("test_*.py"): - x.write("def test_hello(): pass") - - result = testdir.runpytest("--collect-only") + def test_ignored_certain_directories(self, pytester: Pytester) -> None: + tmpdir = pytester.path + ensure_file(tmpdir / "build" / "test_notfound.py") + ensure_file(tmpdir / "dist" / "test_notfound.py") + ensure_file(tmpdir / "_darcs" / "test_notfound.py") + ensure_file(tmpdir / "CVS" / "test_notfound.py") + ensure_file(tmpdir / "{arch}" / "test_notfound.py") + ensure_file(tmpdir / ".whatever" / "test_notfound.py") + ensure_file(tmpdir / ".bzr" / "test_notfound.py") + ensure_file(tmpdir / "normal" / "test_found.py") + for x in Path(str(tmpdir)).rglob("test_*.py"): + x.write_text("def test_hello(): pass", "utf-8") + + result = pytester.runpytest("--collect-only") s = result.stdout.str() assert "test_notfound" not in s assert "test_found" in s @@ -135,20 +154,20 @@ def test_ignored_certain_directories(self, testdir): "Activate.ps1", ), ) - def test_ignored_virtualenvs(self, testdir, fname): + def test_ignored_virtualenvs(self, pytester: Pytester, fname: str) -> None: bindir = "Scripts" if sys.platform.startswith("win") else "bin" - testdir.tmpdir.ensure("virtual", bindir, fname) - testfile = testdir.tmpdir.ensure("virtual", "test_invenv.py") - testfile.write("def test_hello(): pass") + ensure_file(pytester.path / "virtual" / bindir / fname) + testfile = ensure_file(pytester.path / "virtual" / "test_invenv.py") + testfile.write_text("def test_hello(): pass") # by default, ignore tests inside a virtualenv - result = testdir.runpytest() + result = pytester.runpytest() result.stdout.no_fnmatch_line("*test_invenv*") # allow test collection if user insists - result = testdir.runpytest("--collect-in-virtualenv") + result = pytester.runpytest("--collect-in-virtualenv") assert "test_invenv" in result.stdout.str() # allow test collection if user directly passes in the directory - result = testdir.runpytest("virtual") + result = pytester.runpytest("virtual") assert "test_invenv" in result.stdout.str() @pytest.mark.parametrize( @@ -162,16 +181,18 @@ def test_ignored_virtualenvs(self, testdir, fname): "Activate.ps1", ), ) - def test_ignored_virtualenvs_norecursedirs_precedence(self, testdir, fname): + def test_ignored_virtualenvs_norecursedirs_precedence( + self, pytester: Pytester, fname: str + ) -> None: bindir = "Scripts" if sys.platform.startswith("win") else "bin" # norecursedirs takes priority - testdir.tmpdir.ensure(".virtual", bindir, fname) - testfile = testdir.tmpdir.ensure(".virtual", "test_invenv.py") - testfile.write("def test_hello(): pass") - result = testdir.runpytest("--collect-in-virtualenv") + ensure_file(pytester.path / ".virtual" / bindir / fname) + testfile = ensure_file(pytester.path / ".virtual" / "test_invenv.py") + testfile.write_text("def test_hello(): pass") + result = pytester.runpytest("--collect-in-virtualenv") result.stdout.no_fnmatch_line("*test_invenv*") # ...unless the virtualenv is explicitly given on the CLI - result = testdir.runpytest("--collect-in-virtualenv", ".virtual") + result = pytester.runpytest("--collect-in-virtualenv", ".virtual") assert "test_invenv" in result.stdout.str() @pytest.mark.parametrize( @@ -185,7 +206,7 @@ def test_ignored_virtualenvs_norecursedirs_precedence(self, testdir, fname): "Activate.ps1", ), ) - def test__in_venv(self, testdir, fname): + def test__in_venv(self, testdir: Testdir, fname: str) -> None: """Directly test the virtual env detection function""" bindir = "Scripts" if sys.platform.startswith("win") else "bin" # no bin/activate, not a virtualenv @@ -195,55 +216,55 @@ def test__in_venv(self, testdir, fname): base_path.ensure(bindir, fname) assert _in_venv(base_path) is True - def test_custom_norecursedirs(self, testdir): - testdir.makeini( + def test_custom_norecursedirs(self, pytester: Pytester) -> None: + pytester.makeini( """ [pytest] norecursedirs = mydir xyz* """ ) - tmpdir = testdir.tmpdir - tmpdir.ensure("mydir", "test_hello.py").write("def test_1(): pass") - tmpdir.ensure("xyz123", "test_2.py").write("def test_2(): 0/0") - tmpdir.ensure("xy", "test_ok.py").write("def test_3(): pass") - rec = testdir.inline_run() + tmpdir = pytester.path + ensure_file(tmpdir / "mydir" / "test_hello.py").write_text("def test_1(): pass") + ensure_file(tmpdir / "xyz123" / "test_2.py").write_text("def test_2(): 0/0") + ensure_file(tmpdir / "xy" / "test_ok.py").write_text("def test_3(): pass") + rec = pytester.inline_run() rec.assertoutcome(passed=1) - rec = testdir.inline_run("xyz123/test_2.py") + rec = pytester.inline_run("xyz123/test_2.py") rec.assertoutcome(failed=1) - def test_testpaths_ini(self, testdir, monkeypatch): - testdir.makeini( + def test_testpaths_ini(self, pytester: Pytester, monkeypatch: MonkeyPatch) -> None: + pytester.makeini( """ [pytest] testpaths = gui uts """ ) - tmpdir = testdir.tmpdir - tmpdir.ensure("env", "test_1.py").write("def test_env(): pass") - tmpdir.ensure("gui", "test_2.py").write("def test_gui(): pass") - tmpdir.ensure("uts", "test_3.py").write("def test_uts(): pass") + tmpdir = pytester.path + ensure_file(tmpdir / "env" / "test_1.py").write_text("def test_env(): pass") + ensure_file(tmpdir / "gui" / "test_2.py").write_text("def test_gui(): pass") + ensure_file(tmpdir / "uts" / "test_3.py").write_text("def test_uts(): pass") # executing from rootdir only tests from `testpaths` directories # are collected - items, reprec = testdir.inline_genitems("-v") + items, reprec = pytester.inline_genitems("-v") assert [x.name for x in items] == ["test_gui", "test_uts"] # check that explicitly passing directories in the command-line # collects the tests for dirname in ("env", "gui", "uts"): - items, reprec = testdir.inline_genitems(tmpdir.join(dirname)) + items, reprec = pytester.inline_genitems(tmpdir.joinpath(dirname)) assert [x.name for x in items] == ["test_%s" % dirname] # changing cwd to each subdirectory and running pytest without # arguments collects the tests in that directory normally for dirname in ("env", "gui", "uts"): - monkeypatch.chdir(testdir.tmpdir.join(dirname)) - items, reprec = testdir.inline_genitems() + monkeypatch.chdir(pytester.path.joinpath(dirname)) + items, reprec = pytester.inline_genitems() assert [x.name for x in items] == ["test_%s" % dirname] class TestCollectPluginHookRelay: - def test_pytest_collect_file(self, testdir): + def test_pytest_collect_file(self, testdir: Testdir) -> None: wascalled = [] class Plugin: @@ -253,37 +274,23 @@ def pytest_collect_file(self, path): wascalled.append(path) testdir.makefile(".abc", "xyz") - pytest.main([testdir.tmpdir], plugins=[Plugin()]) + pytest.main(testdir.tmpdir, plugins=[Plugin()]) assert len(wascalled) == 1 assert wascalled[0].ext == ".abc" - @pytest.mark.filterwarnings("ignore:.*pytest_collect_directory.*") - def test_pytest_collect_directory(self, testdir): - wascalled = [] - - class Plugin: - def pytest_collect_directory(self, path): - wascalled.append(path.basename) - - testdir.mkdir("hello") - testdir.mkdir("world") - pytest.main(testdir.tmpdir, plugins=[Plugin()]) - assert "hello" in wascalled - assert "world" in wascalled - class TestPrunetraceback: - def test_custom_repr_failure(self, testdir): - p = testdir.makepyfile( + def test_custom_repr_failure(self, pytester: Pytester) -> None: + p = pytester.makepyfile( """ import not_exists """ ) - testdir.makeconftest( + pytester.makeconftest( """ import pytest def pytest_collect_file(path, parent): - return MyFile(path, parent) + return MyFile.from_parent(fspath=path, parent=parent) class MyError(Exception): pass class MyFile(pytest.File): @@ -296,17 +303,17 @@ def repr_failure(self, excinfo): """ ) - result = testdir.runpytest(p) + result = pytester.runpytest(p) result.stdout.fnmatch_lines(["*ERROR collecting*", "*hello world*"]) @pytest.mark.xfail(reason="other mechanism for adding to reporting needed") - def test_collect_report_postprocessing(self, testdir): - p = testdir.makepyfile( + def test_collect_report_postprocessing(self, pytester: Pytester) -> None: + p = pytester.makepyfile( """ import not_exists """ ) - testdir.makeconftest( + pytester.makeconftest( """ import pytest @pytest.hookimpl(hookwrapper=True) @@ -317,45 +324,45 @@ def pytest_make_collect_report(): outcome.force_result(rep) """ ) - result = testdir.runpytest(p) + result = pytester.runpytest(p) result.stdout.fnmatch_lines(["*ERROR collecting*", "*header1*"]) class TestCustomConftests: - def test_ignore_collect_path(self, testdir): - testdir.makeconftest( + def test_ignore_collect_path(self, pytester: Pytester) -> None: + pytester.makeconftest( """ def pytest_ignore_collect(path, config): return path.basename.startswith("x") or \ path.basename == "test_one.py" """ ) - sub = testdir.mkdir("xy123") - sub.ensure("test_hello.py").write("syntax error") - sub.join("conftest.py").write("syntax error") - testdir.makepyfile("def test_hello(): pass") - testdir.makepyfile(test_one="syntax error") - result = testdir.runpytest("--fulltrace") + sub = pytester.mkdir("xy123") + ensure_file(sub / "test_hello.py").write_text("syntax error") + sub.joinpath("conftest.py").write_text("syntax error") + pytester.makepyfile("def test_hello(): pass") + pytester.makepyfile(test_one="syntax error") + result = pytester.runpytest("--fulltrace") assert result.ret == 0 result.stdout.fnmatch_lines(["*1 passed*"]) - def test_ignore_collect_not_called_on_argument(self, testdir): - testdir.makeconftest( + def test_ignore_collect_not_called_on_argument(self, pytester: Pytester) -> None: + pytester.makeconftest( """ def pytest_ignore_collect(path, config): return True """ ) - p = testdir.makepyfile("def test_hello(): pass") - result = testdir.runpytest(p) + p = pytester.makepyfile("def test_hello(): pass") + result = pytester.runpytest(p) assert result.ret == 0 result.stdout.fnmatch_lines(["*1 passed*"]) - result = testdir.runpytest() + result = pytester.runpytest() assert result.ret == ExitCode.NO_TESTS_COLLECTED result.stdout.fnmatch_lines(["*collected 0 items*"]) - def test_collectignore_exclude_on_option(self, testdir): - testdir.makeconftest( + def test_collectignore_exclude_on_option(self, pytester: Pytester) -> None: + pytester.makeconftest( """ collect_ignore = ['hello', 'test_world.py'] def pytest_addoption(parser): @@ -365,17 +372,17 @@ def pytest_configure(config): collect_ignore[:] = [] """ ) - testdir.mkdir("hello") - testdir.makepyfile(test_world="def test_hello(): pass") - result = testdir.runpytest() + pytester.mkdir("hello") + pytester.makepyfile(test_world="def test_hello(): pass") + result = pytester.runpytest() assert result.ret == ExitCode.NO_TESTS_COLLECTED result.stdout.no_fnmatch_line("*passed*") - result = testdir.runpytest("--XX") + result = pytester.runpytest("--XX") assert result.ret == 0 assert "passed" in result.stdout.str() - def test_collectignoreglob_exclude_on_option(self, testdir): - testdir.makeconftest( + def test_collectignoreglob_exclude_on_option(self, pytester: Pytester) -> None: + pytester.makeconftest( """ collect_ignore_glob = ['*w*l[dt]*'] def pytest_addoption(parser): @@ -385,89 +392,70 @@ def pytest_configure(config): collect_ignore_glob[:] = [] """ ) - testdir.makepyfile(test_world="def test_hello(): pass") - testdir.makepyfile(test_welt="def test_hallo(): pass") - result = testdir.runpytest() + pytester.makepyfile(test_world="def test_hello(): pass") + pytester.makepyfile(test_welt="def test_hallo(): pass") + result = pytester.runpytest() assert result.ret == ExitCode.NO_TESTS_COLLECTED result.stdout.fnmatch_lines(["*collected 0 items*"]) - result = testdir.runpytest("--XX") + result = pytester.runpytest("--XX") assert result.ret == 0 result.stdout.fnmatch_lines(["*2 passed*"]) - def test_pytest_fs_collect_hooks_are_seen(self, testdir): - testdir.makeconftest( + def test_pytest_fs_collect_hooks_are_seen(self, pytester: Pytester) -> None: + pytester.makeconftest( """ import pytest class MyModule(pytest.Module): pass def pytest_collect_file(path, parent): if path.ext == ".py": - return MyModule(path, parent) + return MyModule.from_parent(fspath=path, parent=parent) """ ) - testdir.mkdir("sub") - testdir.makepyfile("def test_x(): pass") - result = testdir.runpytest("--co") + pytester.mkdir("sub") + pytester.makepyfile("def test_x(): pass") + result = pytester.runpytest("--co") result.stdout.fnmatch_lines(["*MyModule*", "*test_x*"]) - def test_pytest_collect_file_from_sister_dir(self, testdir): - sub1 = testdir.mkpydir("sub1") - sub2 = testdir.mkpydir("sub2") - conf1 = testdir.makeconftest( + def test_pytest_collect_file_from_sister_dir(self, pytester: Pytester) -> None: + sub1 = pytester.mkpydir("sub1") + sub2 = pytester.mkpydir("sub2") + conf1 = pytester.makeconftest( """ import pytest class MyModule1(pytest.Module): pass def pytest_collect_file(path, parent): if path.ext == ".py": - return MyModule1(path, parent) + return MyModule1.from_parent(fspath=path, parent=parent) """ ) - conf1.move(sub1.join(conf1.basename)) - conf2 = testdir.makeconftest( + conf1.replace(sub1.joinpath(conf1.name)) + conf2 = pytester.makeconftest( """ import pytest class MyModule2(pytest.Module): pass def pytest_collect_file(path, parent): if path.ext == ".py": - return MyModule2(path, parent) + return MyModule2.from_parent(fspath=path, parent=parent) """ ) - conf2.move(sub2.join(conf2.basename)) - p = testdir.makepyfile("def test_x(): pass") - p.copy(sub1.join(p.basename)) - p.copy(sub2.join(p.basename)) - result = testdir.runpytest("--co") + conf2.replace(sub2.joinpath(conf2.name)) + p = pytester.makepyfile("def test_x(): pass") + shutil.copy(p, sub1.joinpath(p.name)) + shutil.copy(p, sub2.joinpath(p.name)) + result = pytester.runpytest("--co") result.stdout.fnmatch_lines(["*MyModule1*", "*MyModule2*", "*test_x*"]) class TestSession: - def test_parsearg(self, testdir) -> None: - p = testdir.makepyfile("def test_func(): pass") - subdir = testdir.mkdir("sub") - subdir.ensure("__init__.py") - target = subdir.join(p.basename) - p.move(target) - subdir.chdir() - config = testdir.parseconfig(p.basename) - rcol = Session.from_config(config) - assert rcol.fspath == subdir - fspath, parts = rcol._parsearg(p.basename) - - assert fspath == target - assert len(parts) == 0 - fspath, parts = rcol._parsearg(p.basename + "::test_func") - assert fspath == target - assert parts[0] == "test_func" - assert len(parts) == 1 - - def test_collect_topdir(self, testdir): - p = testdir.makepyfile("def test_func(): pass") - id = "::".join([p.basename, "test_func"]) + def test_collect_topdir(self, pytester: Pytester) -> None: + p = pytester.makepyfile("def test_func(): pass") + id = "::".join([p.name, "test_func"]) # XXX migrate to collectonly? (see below) - config = testdir.parseconfig(id) - topdir = testdir.tmpdir + config = pytester.parseconfig(id) + topdir = pytester.path rcol = Session.from_config(config) assert topdir == rcol.fspath # rootid = rcol.nodeid @@ -477,7 +465,7 @@ def test_collect_topdir(self, testdir): assert len(colitems) == 1 assert colitems[0].fspath == p - def get_reported_items(self, hookrec): + def get_reported_items(self, hookrec: HookRecorder) -> List[Item]: """Return pytest.Item instances reported by the pytest_collectreport hook""" calls = hookrec.getcalls("pytest_collectreport") return [ @@ -487,16 +475,16 @@ def get_reported_items(self, hookrec): if isinstance(x, pytest.Item) ] - def test_collect_protocol_single_function(self, testdir): - p = testdir.makepyfile("def test_func(): pass") - id = "::".join([p.basename, "test_func"]) - items, hookrec = testdir.inline_genitems(id) + def test_collect_protocol_single_function(self, pytester: Pytester) -> None: + p = pytester.makepyfile("def test_func(): pass") + id = "::".join([p.name, "test_func"]) + items, hookrec = pytester.inline_genitems(id) (item,) = items assert item.name == "test_func" newid = item.nodeid assert newid == id pprint.pprint(hookrec.calls) - topdir = testdir.tmpdir # noqa + topdir = pytester.path # noqa hookrec.assert_contains( [ ("pytest_collectstart", "collector.fspath == topdir"), @@ -510,17 +498,17 @@ def test_collect_protocol_single_function(self, testdir): # ensure we are reporting the collection of the single test item (#2464) assert [x.name for x in self.get_reported_items(hookrec)] == ["test_func"] - def test_collect_protocol_method(self, testdir): - p = testdir.makepyfile( + def test_collect_protocol_method(self, pytester: Pytester) -> None: + p = pytester.makepyfile( """ class TestClass(object): def test_method(self): pass """ ) - normid = p.basename + "::TestClass::test_method" - for id in [p.basename, p.basename + "::TestClass", normid]: - items, hookrec = testdir.inline_genitems(id) + normid = p.name + "::TestClass::test_method" + for id in [p.name, p.name + "::TestClass", normid]: + items, hookrec = pytester.inline_genitems(id) assert len(items) == 1 assert items[0].name == "test_method" newid = items[0].nodeid @@ -528,9 +516,9 @@ def test_method(self): # ensure we are reporting the collection of the single test item (#2464) assert [x.name for x in self.get_reported_items(hookrec)] == ["test_method"] - def test_collect_custom_nodes_multi_id(self, testdir): - p = testdir.makepyfile("def test_func(): pass") - testdir.makeconftest( + def test_collect_custom_nodes_multi_id(self, pytester: Pytester) -> None: + p = pytester.makepyfile("def test_func(): pass") + pytester.makeconftest( """ import pytest class SpecialItem(pytest.Item): @@ -538,16 +526,16 @@ def runtest(self): return # ok class SpecialFile(pytest.File): def collect(self): - return [SpecialItem(name="check", parent=self)] + return [SpecialItem.from_parent(name="check", parent=self)] def pytest_collect_file(path, parent): if path.basename == %r: - return SpecialFile(fspath=path, parent=parent) + return SpecialFile.from_parent(fspath=path, parent=parent) """ - % p.basename + % p.name ) - id = p.basename + id = p.name - items, hookrec = testdir.inline_genitems(id) + items, hookrec = pytester.inline_genitems(id) pprint.pprint(hookrec.calls) assert len(items) == 2 hookrec.assert_contains( @@ -559,18 +547,18 @@ def pytest_collect_file(path, parent): ), ("pytest_collectstart", "collector.__class__.__name__ == 'Module'"), ("pytest_pycollect_makeitem", "name == 'test_func'"), - ("pytest_collectreport", "report.nodeid.startswith(p.basename)"), + ("pytest_collectreport", "report.nodeid.startswith(p.name)"), ] ) assert len(self.get_reported_items(hookrec)) == 2 - def test_collect_subdir_event_ordering(self, testdir): - p = testdir.makepyfile("def test_func(): pass") - aaa = testdir.mkpydir("aaa") - test_aaa = aaa.join("test_aaa.py") - p.move(test_aaa) + def test_collect_subdir_event_ordering(self, pytester: Pytester) -> None: + p = pytester.makepyfile("def test_func(): pass") + aaa = pytester.mkpydir("aaa") + test_aaa = aaa.joinpath("test_aaa.py") + p.replace(test_aaa) - items, hookrec = testdir.inline_genitems() + items, hookrec = pytester.inline_genitems() assert len(items) == 1 pprint.pprint(hookrec.calls) hookrec.assert_contains( @@ -581,18 +569,18 @@ def test_collect_subdir_event_ordering(self, testdir): ] ) - def test_collect_two_commandline_args(self, testdir): - p = testdir.makepyfile("def test_func(): pass") - aaa = testdir.mkpydir("aaa") - bbb = testdir.mkpydir("bbb") - test_aaa = aaa.join("test_aaa.py") - p.copy(test_aaa) - test_bbb = bbb.join("test_bbb.py") - p.move(test_bbb) + def test_collect_two_commandline_args(self, pytester: Pytester) -> None: + p = pytester.makepyfile("def test_func(): pass") + aaa = pytester.mkpydir("aaa") + bbb = pytester.mkpydir("bbb") + test_aaa = aaa.joinpath("test_aaa.py") + shutil.copy(p, test_aaa) + test_bbb = bbb.joinpath("test_bbb.py") + p.replace(test_bbb) id = "." - items, hookrec = testdir.inline_genitems(id) + items, hookrec = pytester.inline_genitems(id) assert len(items) == 2 pprint.pprint(hookrec.calls) hookrec.assert_contains( @@ -606,26 +594,26 @@ def test_collect_two_commandline_args(self, testdir): ] ) - def test_serialization_byid(self, testdir): - testdir.makepyfile("def test_func(): pass") - items, hookrec = testdir.inline_genitems() + def test_serialization_byid(self, pytester: Pytester) -> None: + pytester.makepyfile("def test_func(): pass") + items, hookrec = pytester.inline_genitems() assert len(items) == 1 (item,) = items - items2, hookrec = testdir.inline_genitems(item.nodeid) + items2, hookrec = pytester.inline_genitems(item.nodeid) (item2,) = items2 assert item2.name == item.name assert item2.fspath == item.fspath - def test_find_byid_without_instance_parents(self, testdir): - p = testdir.makepyfile( + def test_find_byid_without_instance_parents(self, pytester: Pytester) -> None: + p = pytester.makepyfile( """ class TestClass(object): def test_method(self): pass """ ) - arg = p.basename + "::TestClass::test_method" - items, hookrec = testdir.inline_genitems(arg) + arg = p.name + "::TestClass::test_method" + items, hookrec = pytester.inline_genitems(arg) assert len(items) == 1 (item,) = items assert item.nodeid.endswith("TestClass::test_method") @@ -634,42 +622,45 @@ def test_method(self): class Test_getinitialnodes: - def test_global_file(self, testdir, tmpdir): - x = tmpdir.ensure("x.py") - with tmpdir.as_cwd(): - config = testdir.parseconfigure(x) - col = testdir.getnode(config, x) + def test_global_file(self, pytester: Pytester) -> None: + tmpdir = pytester.path + x = ensure_file(tmpdir / "x.py") + with tmpdir.cwd(): + config = pytester.parseconfigure(x) + col = pytester.getnode(config, x) assert isinstance(col, pytest.Module) assert col.name == "x.py" + assert col.parent is not None assert col.parent.parent is None - for col in col.listchain(): - assert col.config is config + for parent in col.listchain(): + assert parent.config is config - def test_pkgfile(self, testdir): + def test_pkgfile(self, pytester: Pytester) -> None: """Verify nesting when a module is within a package. The parent chain should match: Module -> Package -> Session. Session's parent should always be None. """ - tmpdir = testdir.tmpdir - subdir = tmpdir.join("subdir") - x = subdir.ensure("x.py") - subdir.ensure("__init__.py") - with subdir.as_cwd(): - config = testdir.parseconfigure(x) - col = testdir.getnode(config, x) + tmpdir = pytester.path + subdir = tmpdir.joinpath("subdir") + x = ensure_file(subdir / "x.py") + ensure_file(subdir / "__init__.py") + with subdir.cwd(): + config = pytester.parseconfigure(x) + col = pytester.getnode(config, x) + assert col is not None assert col.name == "x.py" assert isinstance(col, pytest.Module) assert isinstance(col.parent, pytest.Package) assert isinstance(col.parent.parent, pytest.Session) # session is batman (has no parents) assert col.parent.parent.parent is None - for col in col.listchain(): - assert col.config is config + for parent in col.listchain(): + assert parent.config is config class Test_genitems: - def test_check_collect_hashes(self, testdir): - p = testdir.makepyfile( + def test_check_collect_hashes(self, pytester: Pytester) -> None: + p = pytester.makepyfile( """ def test_1(): pass @@ -678,8 +669,8 @@ def test_2(): pass """ ) - p.copy(p.dirpath(p.purebasename + "2" + ".py")) - items, reprec = testdir.inline_genitems(p.dirpath()) + shutil.copy(p, p.parent / (p.stem + "2" + ".py")) + items, reprec = pytester.inline_genitems(p.parent) assert len(items) == 4 for numi, i in enumerate(items): for numj, j in enumerate(items): @@ -687,8 +678,8 @@ def test_2(): assert hash(i) != hash(j) assert i != j - def test_example_items1(self, testdir): - p = testdir.makepyfile( + def test_example_items1(self, pytester: Pytester) -> None: + p = pytester.makepyfile( """ import pytest @@ -705,7 +696,7 @@ def testmethod_two(self, arg0): pass """ ) - items, reprec = testdir.inline_genitems(p) + items, reprec = pytester.inline_genitems(p) assert len(items) == 4 assert items[0].name == "testone" assert items[1].name == "testmethod_one" @@ -713,29 +704,27 @@ def testmethod_two(self, arg0): assert items[3].name == "testmethod_two[.[]" # let's also test getmodpath here - assert items[0].getmodpath() == "testone" - assert items[1].getmodpath() == "TestX.testmethod_one" - assert items[2].getmodpath() == "TestY.testmethod_one" + assert items[0].getmodpath() == "testone" # type: ignore[attr-defined] + assert items[1].getmodpath() == "TestX.testmethod_one" # type: ignore[attr-defined] + assert items[2].getmodpath() == "TestY.testmethod_one" # type: ignore[attr-defined] # PR #6202: Fix incorrect result of getmodpath method. (Resolves issue #6189) - assert items[3].getmodpath() == "TestY.testmethod_two[.[]" + assert items[3].getmodpath() == "TestY.testmethod_two[.[]" # type: ignore[attr-defined] - s = items[0].getmodpath(stopatmodule=False) + s = items[0].getmodpath(stopatmodule=False) # type: ignore[attr-defined] assert s.endswith("test_example_items1.testone") print(s) - def test_class_and_functions_discovery_using_glob(self, testdir): - """ - tests that python_classes and python_functions config options work - as prefixes and glob-like patterns (issue #600). - """ - testdir.makeini( + def test_class_and_functions_discovery_using_glob(self, pytester: Pytester) -> None: + """Test that Python_classes and Python_functions config options work + as prefixes and glob-like patterns (#600).""" + pytester.makeini( """ [pytest] python_classes = *Suite Test python_functions = *_test test """ ) - p = testdir.makepyfile( + p = pytester.makepyfile( """ class MyTestSuite(object): def x_test(self): @@ -746,13 +735,13 @@ def test_y(self): pass """ ) - items, reprec = testdir.inline_genitems(p) - ids = [x.getmodpath() for x in items] + items, reprec = pytester.inline_genitems(p) + ids = [x.getmodpath() for x in items] # type: ignore[attr-defined] assert ids == ["MyTestSuite.x_test", "TestCase.test_y"] -def test_matchnodes_two_collections_same_file(testdir): - testdir.makeconftest( +def test_matchnodes_two_collections_same_file(pytester: Pytester) -> None: + pytester.makeconftest( """ import pytest def pytest_configure(config): @@ -761,35 +750,40 @@ def pytest_configure(config): class Plugin2(object): def pytest_collect_file(self, path, parent): if path.ext == ".abc": - return MyFile2(path, parent) + return MyFile2.from_parent(fspath=path, parent=parent) def pytest_collect_file(path, parent): if path.ext == ".abc": - return MyFile1(path, parent) + return MyFile1.from_parent(fspath=path, parent=parent) + + class MyFile1(pytest.File): + def collect(self): + yield Item1.from_parent(name="item1", parent=self) - class MyFile1(pytest.Item, pytest.File): - def runtest(self): - pass class MyFile2(pytest.File): def collect(self): - return [Item2("hello", parent=self)] + yield Item2.from_parent(name="item2", parent=self) + + class Item1(pytest.Item): + def runtest(self): + pass class Item2(pytest.Item): def runtest(self): pass """ ) - p = testdir.makefile(".abc", "") - result = testdir.runpytest() + p = pytester.makefile(".abc", "") + result = pytester.runpytest() assert result.ret == 0 result.stdout.fnmatch_lines(["*2 passed*"]) - res = testdir.runpytest("%s::hello" % p.basename) + res = pytester.runpytest("%s::item2" % p.name) res.stdout.fnmatch_lines(["*1 passed*"]) class TestNodekeywords: - def test_no_under(self, testdir): - modcol = testdir.getmodulecol( + def test_no_under(self, pytester: Pytester) -> None: + modcol = pytester.getmodulecol( """ def test_pass(): pass def test_fail(): assert 0 @@ -801,8 +795,8 @@ def test_fail(): assert 0 assert not x.startswith("_") assert modcol.name in repr(modcol.keywords) - def test_issue345(self, testdir): - testdir.makepyfile( + def test_issue345(self, pytester: Pytester) -> None: + pytester.makepyfile( """ def test_should_not_be_selected(): assert False, 'I should not have been selected to run' @@ -811,17 +805,19 @@ def test___repr__(): pass """ ) - reprec = testdir.inline_run("-k repr") + reprec = pytester.inline_run("-k repr") reprec.assertoutcome(passed=1, failed=0) - def test_keyword_matching_is_case_insensitive_by_default(self, testdir): + def test_keyword_matching_is_case_insensitive_by_default( + self, pytester: Pytester + ) -> None: """Check that selection via -k EXPRESSION is case-insensitive. Since markers are also added to the node keywords, they too can be matched without having to think about case sensitivity. """ - testdir.makepyfile( + pytester.makepyfile( """ import pytest @@ -848,7 +844,7 @@ def test_failing_5(): ) num_matching_tests = 4 for expression in ("specifictopic", "SPECIFICTOPIC", "SpecificTopic"): - reprec = testdir.inline_run("-k " + expression) + reprec = pytester.inline_run("-k " + expression) reprec.assertoutcome(passed=num_matching_tests, failed=0) @@ -874,11 +870,11 @@ def test_4(): ) -def test_exit_on_collection_error(testdir): +def test_exit_on_collection_error(pytester: Pytester) -> None: """Verify that all collection errors are collected and no tests executed""" - testdir.makepyfile(**COLLECTION_ERROR_PY_FILES) + pytester.makepyfile(**COLLECTION_ERROR_PY_FILES) - res = testdir.runpytest() + res = pytester.runpytest() assert res.ret == 2 res.stdout.fnmatch_lines( @@ -892,14 +888,16 @@ def test_exit_on_collection_error(testdir): ) -def test_exit_on_collection_with_maxfail_smaller_than_n_errors(testdir): +def test_exit_on_collection_with_maxfail_smaller_than_n_errors( + pytester: Pytester, +) -> None: """ Verify collection is aborted once maxfail errors are encountered ignoring further modules which would cause more collection errors. """ - testdir.makepyfile(**COLLECTION_ERROR_PY_FILES) + pytester.makepyfile(**COLLECTION_ERROR_PY_FILES) - res = testdir.runpytest("--maxfail=1") + res = pytester.runpytest("--maxfail=1") assert res.ret == 1 res.stdout.fnmatch_lines( [ @@ -913,14 +911,16 @@ def test_exit_on_collection_with_maxfail_smaller_than_n_errors(testdir): res.stdout.no_fnmatch_line("*test_03*") -def test_exit_on_collection_with_maxfail_bigger_than_n_errors(testdir): +def test_exit_on_collection_with_maxfail_bigger_than_n_errors( + pytester: Pytester, +) -> None: """ Verify the test run aborts due to collection errors even if maxfail count of errors was not reached. """ - testdir.makepyfile(**COLLECTION_ERROR_PY_FILES) + pytester.makepyfile(**COLLECTION_ERROR_PY_FILES) - res = testdir.runpytest("--maxfail=4") + res = pytester.runpytest("--maxfail=4") assert res.ret == 2 res.stdout.fnmatch_lines( [ @@ -935,14 +935,14 @@ def test_exit_on_collection_with_maxfail_bigger_than_n_errors(testdir): ) -def test_continue_on_collection_errors(testdir): +def test_continue_on_collection_errors(pytester: Pytester) -> None: """ Verify tests are executed even when collection errors occur when the --continue-on-collection-errors flag is set """ - testdir.makepyfile(**COLLECTION_ERROR_PY_FILES) + pytester.makepyfile(**COLLECTION_ERROR_PY_FILES) - res = testdir.runpytest("--continue-on-collection-errors") + res = pytester.runpytest("--continue-on-collection-errors") assert res.ret == 1 res.stdout.fnmatch_lines( @@ -950,7 +950,7 @@ def test_continue_on_collection_errors(testdir): ) -def test_continue_on_collection_errors_maxfail(testdir): +def test_continue_on_collection_errors_maxfail(pytester: Pytester) -> None: """ Verify tests are executed even when collection errors occur and that maxfail is honoured (including the collection error count). @@ -958,18 +958,18 @@ def test_continue_on_collection_errors_maxfail(testdir): test_4 is never executed because the test run is with --maxfail=3 which means it is interrupted after the 2 collection errors + 1 failure. """ - testdir.makepyfile(**COLLECTION_ERROR_PY_FILES) + pytester.makepyfile(**COLLECTION_ERROR_PY_FILES) - res = testdir.runpytest("--continue-on-collection-errors", "--maxfail=3") + res = pytester.runpytest("--continue-on-collection-errors", "--maxfail=3") assert res.ret == 1 res.stdout.fnmatch_lines(["collected 2 items / 2 errors", "*1 failed, 2 errors*"]) -def test_fixture_scope_sibling_conftests(testdir): +def test_fixture_scope_sibling_conftests(pytester: Pytester) -> None: """Regression test case for https://github.com/pytest-dev/pytest/issues/2836""" - foo_path = testdir.mkdir("foo") - foo_path.join("conftest.py").write( + foo_path = pytester.mkdir("foo") + foo_path.joinpath("conftest.py").write_text( textwrap.dedent( """\ import pytest @@ -979,13 +979,13 @@ def fix(): """ ) ) - foo_path.join("test_foo.py").write("def test_foo(fix): assert fix == 1") + foo_path.joinpath("test_foo.py").write_text("def test_foo(fix): assert fix == 1") # Tests in `food/` should not see the conftest fixture from `foo/` - food_path = testdir.mkpydir("food") - food_path.join("test_food.py").write("def test_food(fix): assert fix == 1") + food_path = pytester.mkpydir("food") + food_path.joinpath("test_food.py").write_text("def test_food(fix): assert fix == 1") - res = testdir.runpytest() + res = pytester.runpytest() assert res.ret == 1 res.stdout.fnmatch_lines( @@ -997,25 +997,25 @@ def fix(): ) -def test_collect_init_tests(testdir): +def test_collect_init_tests(pytester: Pytester) -> None: """Check that we collect files from __init__.py files when they patch the 'python_files' (#3773)""" - p = testdir.copy_example("collect/collect_init_tests") - result = testdir.runpytest(p, "--collect-only") + p = pytester.copy_example("collect/collect_init_tests") + result = pytester.runpytest(p, "--collect-only") result.stdout.fnmatch_lines( [ "collected 2 items", - "", " ", " ", " ", " ", ] ) - result = testdir.runpytest("./tests", "--collect-only") + result = pytester.runpytest("./tests", "--collect-only") result.stdout.fnmatch_lines( [ "collected 2 items", - "", " ", " ", " ", @@ -1023,11 +1023,11 @@ def test_collect_init_tests(testdir): ] ) # Ignores duplicates with "." and pkginit (#4310). - result = testdir.runpytest("./tests", ".", "--collect-only") + result = pytester.runpytest("./tests", ".", "--collect-only") result.stdout.fnmatch_lines( [ "collected 2 items", - "", + "", " ", " ", " ", @@ -1035,34 +1035,34 @@ def test_collect_init_tests(testdir): ] ) # Same as before, but different order. - result = testdir.runpytest(".", "tests", "--collect-only") + result = pytester.runpytest(".", "tests", "--collect-only") result.stdout.fnmatch_lines( [ "collected 2 items", - "", + "", " ", " ", " ", " ", ] ) - result = testdir.runpytest("./tests/test_foo.py", "--collect-only") + result = pytester.runpytest("./tests/test_foo.py", "--collect-only") result.stdout.fnmatch_lines( - ["", " ", " "] + ["", " ", " "] ) result.stdout.no_fnmatch_line("*test_init*") - result = testdir.runpytest("./tests/__init__.py", "--collect-only") + result = pytester.runpytest("./tests/__init__.py", "--collect-only") result.stdout.fnmatch_lines( - ["", " ", " "] + ["", " ", " "] ) result.stdout.no_fnmatch_line("*test_foo*") -def test_collect_invalid_signature_message(testdir): +def test_collect_invalid_signature_message(pytester: Pytester) -> None: """Check that we issue a proper message when we can't determine the signature of a test function (#4026). """ - testdir.makepyfile( + pytester.makepyfile( """ import pytest @@ -1072,17 +1072,17 @@ def fix(): pass """ ) - result = testdir.runpytest() + result = pytester.runpytest() result.stdout.fnmatch_lines( ["Could not determine arguments of *.fix *: invalid method signature"] ) -def test_collect_handles_raising_on_dunder_class(testdir): +def test_collect_handles_raising_on_dunder_class(pytester: Pytester) -> None: """Handle proxy classes like Django's LazySettings that might raise on ``isinstance`` (#4266). """ - testdir.makepyfile( + pytester.makepyfile( """ class ImproperlyConfigured(Exception): pass @@ -1100,14 +1100,14 @@ def test_1(): pass """ ) - result = testdir.runpytest() + result = pytester.runpytest() result.stdout.fnmatch_lines(["*1 passed in*"]) assert result.ret == 0 -def test_collect_with_chdir_during_import(testdir): - subdir = testdir.tmpdir.mkdir("sub") - testdir.tmpdir.join("conftest.py").write( +def test_collect_with_chdir_during_import(pytester: Pytester) -> None: + subdir = pytester.mkdir("sub") + pytester.path.joinpath("conftest.py").write_text( textwrap.dedent( """ import os @@ -1116,7 +1116,7 @@ def test_collect_with_chdir_during_import(testdir): % (str(subdir),) ) ) - testdir.makepyfile( + pytester.makepyfile( """ def test_1(): import os @@ -1124,31 +1124,33 @@ def test_1(): """ % (str(subdir),) ) - with testdir.tmpdir.as_cwd(): - result = testdir.runpytest() + with pytester.path.cwd(): + result = pytester.runpytest() result.stdout.fnmatch_lines(["*1 passed in*"]) assert result.ret == 0 # Handles relative testpaths. - testdir.makeini( + pytester.makeini( """ [pytest] testpaths = . """ ) - with testdir.tmpdir.as_cwd(): - result = testdir.runpytest("--collect-only") + with pytester.path.cwd(): + result = pytester.runpytest("--collect-only") result.stdout.fnmatch_lines(["collected 1 item"]) -def test_collect_pyargs_with_testpaths(testdir, monkeypatch): - testmod = testdir.mkdir("testmod") +def test_collect_pyargs_with_testpaths( + pytester: Pytester, monkeypatch: MonkeyPatch +) -> None: + testmod = pytester.mkdir("testmod") # NOTE: __init__.py is not collected since it does not match python_files. - testmod.ensure("__init__.py").write("def test_func(): pass") - testmod.ensure("test_file.py").write("def test_func(): pass") + testmod.joinpath("__init__.py").write_text("def test_func(): pass") + testmod.joinpath("test_file.py").write_text("def test_func(): pass") - root = testdir.mkdir("root") - root.ensure("pytest.ini").write( + root = pytester.mkdir("root") + root.joinpath("pytest.ini").write_text( textwrap.dedent( """ [pytest] @@ -1157,40 +1159,32 @@ def test_collect_pyargs_with_testpaths(testdir, monkeypatch): """ ) ) - monkeypatch.setenv("PYTHONPATH", str(testdir.tmpdir), prepend=os.pathsep) - with root.as_cwd(): - result = testdir.runpytest_subprocess() + monkeypatch.setenv("PYTHONPATH", str(pytester.path), prepend=os.pathsep) + with root.cwd(): + result = pytester.runpytest_subprocess() result.stdout.fnmatch_lines(["*1 passed in*"]) -@pytest.mark.skipif( - not hasattr(py.path.local, "mksymlinkto"), - reason="symlink not available on this platform", -) -def test_collect_symlink_file_arg(testdir): - """Test that collecting a direct symlink, where the target does not match python_files works (#4325).""" - real = testdir.makepyfile( +def test_collect_symlink_file_arg(pytester: Pytester) -> None: + """Collect a direct symlink works even if it does not match python_files (#4325).""" + real = pytester.makepyfile( real=""" def test_nodeid(request): - assert request.node.nodeid == "real.py::test_nodeid" + assert request.node.nodeid == "symlink.py::test_nodeid" """ ) - symlink = testdir.tmpdir.join("symlink.py") - symlink.mksymlinkto(real) - result = testdir.runpytest("-v", symlink) - result.stdout.fnmatch_lines(["real.py::test_nodeid PASSED*", "*1 passed in*"]) + symlink = pytester.path.joinpath("symlink.py") + symlink_or_skip(real, symlink) + result = pytester.runpytest("-v", symlink) + result.stdout.fnmatch_lines(["symlink.py::test_nodeid PASSED*", "*1 passed in*"]) assert result.ret == 0 -@pytest.mark.skipif( - not hasattr(py.path.local, "mksymlinkto"), - reason="symlink not available on this platform", -) -def test_collect_symlink_out_of_tree(testdir): +def test_collect_symlink_out_of_tree(pytester: Pytester) -> None: """Test collection of symlink via out-of-tree rootdir.""" - sub = testdir.tmpdir.join("sub") - real = sub.join("test_real.py") - real.write( + sub = pytester.mkdir("sub") + real = sub.joinpath("test_real.py") + real.write_text( textwrap.dedent( """ def test_nodeid(request): @@ -1198,14 +1192,13 @@ def test_nodeid(request): assert request.node.nodeid == "test_real.py::test_nodeid" """ ), - ensure=True, ) - out_of_tree = testdir.tmpdir.join("out_of_tree").ensure(dir=True) - symlink_to_sub = out_of_tree.join("symlink_to_sub") - symlink_to_sub.mksymlinkto(sub) - sub.chdir() - result = testdir.runpytest("-vs", "--rootdir=%s" % sub, symlink_to_sub) + out_of_tree = pytester.mkdir("out_of_tree") + symlink_to_sub = out_of_tree.joinpath("symlink_to_sub") + symlink_or_skip(sub, symlink_to_sub) + os.chdir(sub) + result = pytester.runpytest("-vs", "--rootdir=%s" % sub, symlink_to_sub) result.stdout.fnmatch_lines( [ # Should not contain "sub/"! @@ -1215,30 +1208,40 @@ def test_nodeid(request): assert result.ret == 0 -def test_collectignore_via_conftest(testdir): +def test_collect_symlink_dir(pytester: Pytester) -> None: + """A symlinked directory is collected.""" + dir = pytester.mkdir("dir") + dir.joinpath("test_it.py").write_text("def test_it(): pass", "utf-8") + pytester.path.joinpath("symlink_dir").symlink_to(dir) + result = pytester.runpytest() + result.assert_outcomes(passed=2) + + +def test_collectignore_via_conftest(pytester: Pytester) -> None: """collect_ignore in parent conftest skips importing child (issue #4592).""" - tests = testdir.mkpydir("tests") - tests.ensure("conftest.py").write("collect_ignore = ['ignore_me']") + tests = pytester.mkpydir("tests") + tests.joinpath("conftest.py").write_text("collect_ignore = ['ignore_me']") - ignore_me = tests.mkdir("ignore_me") - ignore_me.ensure("__init__.py") - ignore_me.ensure("conftest.py").write("assert 0, 'should_not_be_called'") + ignore_me = tests.joinpath("ignore_me") + ignore_me.mkdir() + ignore_me.joinpath("__init__.py").touch() + ignore_me.joinpath("conftest.py").write_text("assert 0, 'should_not_be_called'") - result = testdir.runpytest() + result = pytester.runpytest() assert result.ret == ExitCode.NO_TESTS_COLLECTED -def test_collect_pkg_init_and_file_in_args(testdir): - subdir = testdir.mkdir("sub") - init = subdir.ensure("__init__.py") - init.write("def test_init(): pass") - p = subdir.ensure("test_file.py") - p.write("def test_file(): pass") +def test_collect_pkg_init_and_file_in_args(pytester: Pytester) -> None: + subdir = pytester.mkdir("sub") + init = subdir.joinpath("__init__.py") + init.write_text("def test_init(): pass") + p = subdir.joinpath("test_file.py") + p.write_text("def test_file(): pass") # NOTE: without "-o python_files=*.py" this collects test_file.py twice. # This changed/broke with "Add package scoped fixtures #2283" (2b1410895) # initially (causing a RecursionError). - result = testdir.runpytest("-v", str(init), str(p)) + result = pytester.runpytest("-v", str(init), str(p)) result.stdout.fnmatch_lines( [ "sub/test_file.py::test_file PASSED*", @@ -1247,7 +1250,7 @@ def test_collect_pkg_init_and_file_in_args(testdir): ] ) - result = testdir.runpytest("-v", "-o", "python_files=*.py", str(init), str(p)) + result = pytester.runpytest("-v", "-o", "python_files=*.py", str(init), str(p)) result.stdout.fnmatch_lines( [ "sub/__init__.py::test_init PASSED*", @@ -1257,36 +1260,33 @@ def test_collect_pkg_init_and_file_in_args(testdir): ) -def test_collect_pkg_init_only(testdir): - subdir = testdir.mkdir("sub") - init = subdir.ensure("__init__.py") - init.write("def test_init(): pass") +def test_collect_pkg_init_only(pytester: Pytester) -> None: + subdir = pytester.mkdir("sub") + init = subdir.joinpath("__init__.py") + init.write_text("def test_init(): pass") - result = testdir.runpytest(str(init)) + result = pytester.runpytest(str(init)) result.stdout.fnmatch_lines(["*no tests ran in*"]) - result = testdir.runpytest("-v", "-o", "python_files=*.py", str(init)) + result = pytester.runpytest("-v", "-o", "python_files=*.py", str(init)) result.stdout.fnmatch_lines(["sub/__init__.py::test_init PASSED*", "*1 passed in*"]) -@pytest.mark.skipif( - not hasattr(py.path.local, "mksymlinkto"), - reason="symlink not available on this platform", -) @pytest.mark.parametrize("use_pkg", (True, False)) -def test_collect_sub_with_symlinks(use_pkg, testdir): - sub = testdir.mkdir("sub") +def test_collect_sub_with_symlinks(use_pkg: bool, pytester: Pytester) -> None: + """Collection works with symlinked files and broken symlinks""" + sub = pytester.mkdir("sub") if use_pkg: - sub.ensure("__init__.py") - sub.ensure("test_file.py").write("def test_file(): pass") + sub.joinpath("__init__.py").touch() + sub.joinpath("test_file.py").write_text("def test_file(): pass") # Create a broken symlink. - sub.join("test_broken.py").mksymlinkto("test_doesnotexist.py") + symlink_or_skip("test_doesnotexist.py", sub.joinpath("test_broken.py")) # Symlink that gets collected. - sub.join("test_symlink.py").mksymlinkto("test_file.py") + symlink_or_skip("test_file.py", sub.joinpath("test_symlink.py")) - result = testdir.runpytest("-v", str(sub)) + result = pytester.runpytest("-v", str(sub)) result.stdout.fnmatch_lines( [ "sub/test_file.py::test_file PASSED*", @@ -1296,9 +1296,9 @@ def test_collect_sub_with_symlinks(use_pkg, testdir): ) -def test_collector_respects_tbstyle(testdir): - p1 = testdir.makepyfile("assert 0") - result = testdir.runpytest(p1, "--tb=native") +def test_collector_respects_tbstyle(pytester: Pytester) -> None: + p1 = pytester.makepyfile("assert 0") + result = pytester.runpytest(p1, "--tb=native") assert result.ret == ExitCode.INTERRUPTED result.stdout.fnmatch_lines( [ @@ -1313,22 +1313,148 @@ def test_collector_respects_tbstyle(testdir): ) -def test_does_not_eagerly_collect_packages(testdir): - testdir.makepyfile("def test(): pass") - pydir = testdir.mkpydir("foopkg") - pydir.join("__init__.py").write("assert False") - result = testdir.runpytest() +def test_does_not_eagerly_collect_packages(pytester: Pytester) -> None: + pytester.makepyfile("def test(): pass") + pydir = pytester.mkpydir("foopkg") + pydir.joinpath("__init__.py").write_text("assert False") + result = pytester.runpytest() assert result.ret == ExitCode.OK -def test_does_not_put_src_on_path(testdir): +def test_does_not_put_src_on_path(pytester: Pytester) -> None: # `src` is not on sys.path so it should not be importable - testdir.tmpdir.join("src/nope/__init__.py").ensure() - testdir.makepyfile( + ensure_file(pytester.path / "src/nope/__init__.py") + pytester.makepyfile( "import pytest\n" "def test():\n" " with pytest.raises(ImportError):\n" " import nope\n" ) - result = testdir.runpytest() + result = pytester.runpytest() + assert result.ret == ExitCode.OK + + +def test_fscollector_from_parent(testdir: Testdir, request: FixtureRequest) -> None: + """Ensure File.from_parent can forward custom arguments to the constructor. + + Context: https://github.com/pytest-dev/pytest-cpp/pull/47 + """ + + class MyCollector(pytest.File): + def __init__(self, fspath, parent, x): + super().__init__(fspath, parent) + self.x = x + + @classmethod + def from_parent(cls, parent, *, fspath, x): + return super().from_parent(parent=parent, fspath=fspath, x=x) + + collector = MyCollector.from_parent( + parent=request.session, fspath=testdir.tmpdir / "foo", x=10 + ) + assert collector.x == 10 + + +class TestImportModeImportlib: + def test_collect_duplicate_names(self, pytester: Pytester) -> None: + """--import-mode=importlib can import modules with same names that are not in packages.""" + pytester.makepyfile( + **{ + "tests_a/test_foo.py": "def test_foo1(): pass", + "tests_b/test_foo.py": "def test_foo2(): pass", + } + ) + result = pytester.runpytest("-v", "--import-mode=importlib") + result.stdout.fnmatch_lines( + [ + "tests_a/test_foo.py::test_foo1 *", + "tests_b/test_foo.py::test_foo2 *", + "* 2 passed in *", + ] + ) + + def test_conftest(self, pytester: Pytester) -> None: + """Directory containing conftest modules are not put in sys.path as a side-effect of + importing them.""" + tests_dir = pytester.path.joinpath("tests") + pytester.makepyfile( + **{ + "tests/conftest.py": "", + "tests/test_foo.py": """ + import sys + def test_check(): + assert r"{tests_dir}" not in sys.path + """.format( + tests_dir=tests_dir + ), + } + ) + result = pytester.runpytest("-v", "--import-mode=importlib") + result.stdout.fnmatch_lines(["* 1 passed in *"]) + + def setup_conftest_and_foo(self, pytester: Pytester) -> None: + """Setup a tests folder to be used to test if modules in that folder can be imported + due to side-effects of --import-mode or not.""" + pytester.makepyfile( + **{ + "tests/conftest.py": "", + "tests/foo.py": """ + def foo(): return 42 + """, + "tests/test_foo.py": """ + def test_check(): + from foo import foo + assert foo() == 42 + """, + } + ) + + def test_modules_importable_as_side_effect(self, pytester: Pytester) -> None: + """In import-modes `prepend` and `append`, we are able to import modules from folders + containing conftest.py files due to the side effect of changing sys.path.""" + self.setup_conftest_and_foo(pytester) + result = pytester.runpytest("-v", "--import-mode=prepend") + result.stdout.fnmatch_lines(["* 1 passed in *"]) + + def test_modules_not_importable_as_side_effect(self, pytester: Pytester) -> None: + """In import-mode `importlib`, modules in folders containing conftest.py are not + importable, as don't change sys.path or sys.modules as side effect of importing + the conftest.py file. + """ + self.setup_conftest_and_foo(pytester) + result = pytester.runpytest("-v", "--import-mode=importlib") + result.stdout.fnmatch_lines( + [ + "*ModuleNotFoundError: No module named 'foo'", + "tests?test_foo.py:2: ModuleNotFoundError", + "* 1 failed in *", + ] + ) + + +def test_does_not_crash_on_error_from_decorated_function(pytester: Pytester) -> None: + """Regression test for an issue around bad exception formatting due to + assertion rewriting mangling lineno's (#4984).""" + pytester.makepyfile( + """ + @pytest.fixture + def a(): return 4 + """ + ) + result = pytester.runpytest() + # Not INTERNAL_ERROR + assert result.ret == ExitCode.INTERRUPTED + + +def test_does_not_crash_on_recursive_symlink(pytester: Pytester) -> None: + """Regression test for an issue around recursive symlinks (#7951).""" + symlink_or_skip("recursive", pytester.path.joinpath("recursive")) + pytester.makepyfile( + """ + def test_foo(): assert True + """ + ) + result = pytester.runpytest() + assert result.ret == ExitCode.OK + assert result.parseoutcomes() == {"passed": 1} diff --git a/testing/test_compat.py b/testing/test_compat.py index 45468b5f8dc..9f48a31d689 100644 --- a/testing/test_compat.py +++ b/testing/test_compat.py @@ -1,18 +1,25 @@ -import sys +import enum from functools import partial from functools import wraps +from typing import TYPE_CHECKING +from typing import Union import pytest from _pytest.compat import _PytestWrapper +from _pytest.compat import assert_never from _pytest.compat import cached_property from _pytest.compat import get_real_func from _pytest.compat import is_generator from _pytest.compat import safe_getattr from _pytest.compat import safe_isclass from _pytest.outcomes import OutcomeException +from _pytest.pytester import Pytester +if TYPE_CHECKING: + from typing_extensions import Literal -def test_is_generator(): + +def test_is_generator() -> None: def zap(): yield # pragma: no cover @@ -23,13 +30,13 @@ def foo(): assert not is_generator(foo) -def test_real_func_loop_limit(): +def test_real_func_loop_limit() -> None: class Evil: def __init__(self): self.left = 1000 def __repr__(self): - return "".format(left=self.left) + return f"" def __getattr__(self, attr): if not self.left: @@ -49,7 +56,7 @@ def __getattr__(self, attr): get_real_func(evil) -def test_get_real_func(): +def test_get_real_func() -> None: """Check that get_real_func correctly unwraps decorators until reaching the real function""" def decorator(f): @@ -74,7 +81,7 @@ def func(): assert get_real_func(wrapped_func2) is wrapped_func -def test_get_real_func_partial(): +def test_get_real_func_partial() -> None: """Test get_real_func handles partial instances correctly""" def foo(x): @@ -84,8 +91,8 @@ def foo(x): assert get_real_func(partial(foo)) is foo -def test_is_generator_asyncio(testdir): - testdir.makepyfile( +def test_is_generator_asyncio(pytester: Pytester) -> None: + pytester.makepyfile( """ from _pytest.compat import is_generator import asyncio @@ -99,12 +106,12 @@ def test_is_generator_asyncio(): ) # avoid importing asyncio into pytest's own process, # which in turn imports logging (#8) - result = testdir.runpytest_subprocess() + result = pytester.runpytest_subprocess() result.stdout.fnmatch_lines(["*1 passed*"]) -def test_is_generator_async_syntax(testdir): - testdir.makepyfile( +def test_is_generator_async_syntax(pytester: Pytester) -> None: + pytester.makepyfile( """ from _pytest.compat import is_generator def test_is_generator_py35(): @@ -118,15 +125,12 @@ async def bar(): assert not is_generator(bar) """ ) - result = testdir.runpytest() + result = pytester.runpytest() result.stdout.fnmatch_lines(["*1 passed*"]) -@pytest.mark.skipif( - sys.version_info < (3, 6), reason="async gen syntax available in Python 3.6+" -) -def test_is_generator_async_gen_syntax(testdir): - testdir.makepyfile( +def test_is_generator_async_gen_syntax(pytester: Pytester) -> None: + pytester.makepyfile( """ from _pytest.compat import is_generator def test_is_generator_py36(): @@ -141,7 +145,7 @@ async def bar(): assert not is_generator(bar) """ ) - result = testdir.runpytest() + result = pytester.runpytest() result.stdout.fnmatch_lines(["*1 passed*"]) @@ -159,7 +163,7 @@ def raise_fail_outcome(self): pytest.fail("fail should be catched") -def test_helper_failures(): +def test_helper_failures() -> None: helper = ErrorsHelper() with pytest.raises(Exception): helper.raise_exception @@ -167,7 +171,7 @@ def test_helper_failures(): helper.raise_fail_outcome -def test_safe_getattr(): +def test_safe_getattr() -> None: helper = ErrorsHelper() assert safe_getattr(helper, "raise_exception", "default") == "default" assert safe_getattr(helper, "raise_fail_outcome", "default") == "default" @@ -175,7 +179,7 @@ def test_safe_getattr(): assert safe_getattr(helper, "raise_baseexception", "default") -def test_safe_isclass(): +def test_safe_isclass() -> None: assert safe_isclass(type) is True class CrappyClass(Exception): @@ -205,3 +209,55 @@ def prop(self) -> int: assert ncalls == 1 assert c2.prop == 2 assert c1.prop == 1 + + +def test_assert_never_union() -> None: + x: Union[int, str] = 10 + + if isinstance(x, int): + pass + else: + with pytest.raises(AssertionError): + assert_never(x) # type: ignore[arg-type] + + if isinstance(x, int): + pass + elif isinstance(x, str): + pass + else: + assert_never(x) + + +def test_assert_never_enum() -> None: + E = enum.Enum("E", "a b") + x: E = E.a + + if x is E.a: + pass + else: + with pytest.raises(AssertionError): + assert_never(x) # type: ignore[arg-type] + + if x is E.a: + pass + elif x is E.b: + pass + else: + assert_never(x) + + +def test_assert_never_literal() -> None: + x: Literal["a", "b"] = "a" + + if x == "a": + pass + else: + with pytest.raises(AssertionError): + assert_never(x) # type: ignore[arg-type] + + if x == "a": + pass + elif x == "b": + pass + else: + assert_never(x) diff --git a/testing/test_config.py b/testing/test_config.py index 9035407b76b..b931797d429 100644 --- a/testing/test_config.py +++ b/testing/test_config.py @@ -2,28 +2,51 @@ import re import sys import textwrap +from pathlib import Path +from typing import Dict +from typing import List +from typing import Sequence +from typing import Tuple +from typing import Type +from typing import Union + +import attr +import py.path import _pytest._code import pytest from _pytest.compat import importlib_metadata +from _pytest.config import _get_plugin_specs_as_list from _pytest.config import _iter_rewritable_modules +from _pytest.config import _strtobool from _pytest.config import Config +from _pytest.config import ConftestImportFailure from _pytest.config import ExitCode +from _pytest.config import parse_warning_filter from _pytest.config.exceptions import UsageError from _pytest.config.findpaths import determine_setup from _pytest.config.findpaths import get_common_ancestor -from _pytest.config.findpaths import getcfg -from _pytest.pathlib import Path +from _pytest.config.findpaths import locate_config +from _pytest.monkeypatch import MonkeyPatch +from _pytest.pytester import Pytester class TestParseIni: @pytest.mark.parametrize( "section, filename", [("pytest", "pytest.ini"), ("tool:pytest", "setup.cfg")] ) - def test_getcfg_and_config(self, testdir, tmpdir, section, filename): - sub = tmpdir.mkdir("sub") - sub.chdir() - tmpdir.join(filename).write( + def test_getcfg_and_config( + self, + pytester: Pytester, + tmp_path: Path, + section: str, + filename: str, + monkeypatch: MonkeyPatch, + ) -> None: + sub = tmp_path / "sub" + sub.mkdir() + monkeypatch.chdir(sub) + (tmp_path / filename).write_text( textwrap.dedent( """\ [{section}] @@ -31,20 +54,17 @@ def test_getcfg_and_config(self, testdir, tmpdir, section, filename): """.format( section=section ) - ) + ), + encoding="utf-8", ) - _, _, cfg = getcfg([sub]) + _, _, cfg = locate_config([sub]) assert cfg["name"] == "value" - config = testdir.parseconfigure(sub) + config = pytester.parseconfigure(str(sub)) assert config.inicfg["name"] == "value" - def test_getcfg_empty_path(self): - """correctly handle zero length arguments (a la pytest '')""" - getcfg([""]) - - def test_setupcfg_uses_toolpytest_with_pytest(self, testdir): - p1 = testdir.makepyfile("def test(): pass") - testdir.makefile( + def test_setupcfg_uses_toolpytest_with_pytest(self, pytester: Pytester) -> None: + p1 = pytester.makepyfile("def test(): pass") + pytester.makefile( ".cfg", setup=""" [tool:pytest] @@ -52,15 +72,17 @@ def test_setupcfg_uses_toolpytest_with_pytest(self, testdir): [pytest] testpaths=ignored """ - % p1.basename, + % p1.name, ) - result = testdir.runpytest() - result.stdout.fnmatch_lines(["*, inifile: setup.cfg, *", "* 1 passed in *"]) + result = pytester.runpytest() + result.stdout.fnmatch_lines(["*, configfile: setup.cfg, *", "* 1 passed in *"]) assert result.ret == 0 - def test_append_parse_args(self, testdir, tmpdir, monkeypatch): + def test_append_parse_args( + self, pytester: Pytester, tmp_path: Path, monkeypatch: MonkeyPatch + ) -> None: monkeypatch.setenv("PYTEST_ADDOPTS", '--color no -rs --tb="short"') - tmpdir.join("pytest.ini").write( + tmp_path.joinpath("pytest.ini").write_text( textwrap.dedent( """\ [pytest] @@ -68,30 +90,32 @@ def test_append_parse_args(self, testdir, tmpdir, monkeypatch): """ ) ) - config = testdir.parseconfig(tmpdir) + config = pytester.parseconfig(tmp_path) assert config.option.color == "no" assert config.option.reportchars == "s" assert config.option.tbstyle == "short" assert config.option.verbose - def test_tox_ini_wrong_version(self, testdir): - testdir.makefile( + def test_tox_ini_wrong_version(self, pytester: Pytester) -> None: + pytester.makefile( ".ini", tox=""" [pytest] - minversion=9.0 + minversion=999.0 """, ) - result = testdir.runpytest() + result = pytester.runpytest() assert result.ret != 0 - result.stderr.fnmatch_lines(["*tox.ini:2*requires*9.0*actual*"]) + result.stderr.fnmatch_lines( + ["*tox.ini: 'minversion' requires pytest-999.0, actual pytest-*"] + ) @pytest.mark.parametrize( "section, name", [("tool:pytest", "setup.cfg"), ("pytest", "tox.ini"), ("pytest", "pytest.ini")], ) - def test_ini_names(self, testdir, name, section): - testdir.tmpdir.join(name).write( + def test_ini_names(self, pytester: Pytester, name, section) -> None: + pytester.path.joinpath(name).write_text( textwrap.dedent( """ [{section}] @@ -101,12 +125,22 @@ def test_ini_names(self, testdir, name, section): ) ) ) - config = testdir.parseconfig() + config = pytester.parseconfig() + assert config.getini("minversion") == "1.0" + + def test_pyproject_toml(self, pytester: Pytester) -> None: + pytester.makepyprojecttoml( + """ + [tool.pytest.ini_options] + minversion = "1.0" + """ + ) + config = pytester.parseconfig() assert config.getini("minversion") == "1.0" - def test_toxini_before_lower_pytestini(self, testdir): - sub = testdir.tmpdir.mkdir("sub") - sub.join("tox.ini").write( + def test_toxini_before_lower_pytestini(self, pytester: Pytester) -> None: + sub = pytester.mkdir("sub") + sub.joinpath("tox.ini").write_text( textwrap.dedent( """ [pytest] @@ -114,7 +148,7 @@ def test_toxini_before_lower_pytestini(self, testdir): """ ) ) - testdir.tmpdir.join("pytest.ini").write( + pytester.path.joinpath("pytest.ini").write_text( textwrap.dedent( """ [pytest] @@ -122,69 +156,357 @@ def test_toxini_before_lower_pytestini(self, testdir): """ ) ) - config = testdir.parseconfigure(sub) + config = pytester.parseconfigure(sub) assert config.getini("minversion") == "2.0" - def test_ini_parse_error(self, testdir): - testdir.tmpdir.join("pytest.ini").write("addopts = -x") - result = testdir.runpytest() + def test_ini_parse_error(self, pytester: Pytester) -> None: + pytester.path.joinpath("pytest.ini").write_text("addopts = -x") + result = pytester.runpytest() assert result.ret != 0 result.stderr.fnmatch_lines(["ERROR: *pytest.ini:1: no section header defined"]) @pytest.mark.xfail(reason="probably not needed") - def test_confcutdir(self, testdir): - sub = testdir.mkdir("sub") - sub.chdir() - testdir.makeini( + def test_confcutdir(self, pytester: Pytester) -> None: + sub = pytester.mkdir("sub") + os.chdir(sub) + pytester.makeini( """ [pytest] addopts = --qwe """ ) - result = testdir.inline_run("--confcutdir=.") + result = pytester.inline_run("--confcutdir=.") assert result.ret == 0 + @pytest.mark.parametrize( + "ini_file_text, invalid_keys, warning_output, exception_text", + [ + pytest.param( + """ + [pytest] + unknown_ini = value1 + another_unknown_ini = value2 + """, + ["unknown_ini", "another_unknown_ini"], + [ + "=*= warnings summary =*=", + "*PytestConfigWarning:*Unknown config option: another_unknown_ini", + "*PytestConfigWarning:*Unknown config option: unknown_ini", + ], + "Unknown config option: another_unknown_ini", + id="2-unknowns", + ), + pytest.param( + """ + [pytest] + unknown_ini = value1 + minversion = 5.0.0 + """, + ["unknown_ini"], + [ + "=*= warnings summary =*=", + "*PytestConfigWarning:*Unknown config option: unknown_ini", + ], + "Unknown config option: unknown_ini", + id="1-unknown", + ), + pytest.param( + """ + [some_other_header] + unknown_ini = value1 + [pytest] + minversion = 5.0.0 + """, + [], + [], + "", + id="unknown-in-other-header", + ), + pytest.param( + """ + [pytest] + minversion = 5.0.0 + """, + [], + [], + "", + id="no-unknowns", + ), + pytest.param( + """ + [pytest] + conftest_ini_key = 1 + """, + [], + [], + "", + id="1-known", + ), + ], + ) + @pytest.mark.filterwarnings("default") + def test_invalid_config_options( + self, + pytester: Pytester, + ini_file_text, + invalid_keys, + warning_output, + exception_text, + ) -> None: + pytester.makeconftest( + """ + def pytest_addoption(parser): + parser.addini("conftest_ini_key", "") + """ + ) + pytester.makepyfile("def test(): pass") + pytester.makeini(ini_file_text) + + config = pytester.parseconfig() + assert sorted(config._get_unknown_ini_keys()) == sorted(invalid_keys) + + result = pytester.runpytest() + result.stdout.fnmatch_lines(warning_output) + + result = pytester.runpytest("--strict-config") + if exception_text: + result.stderr.fnmatch_lines("ERROR: " + exception_text) + assert result.ret == pytest.ExitCode.USAGE_ERROR + else: + result.stderr.no_fnmatch_line(exception_text) + assert result.ret == pytest.ExitCode.OK + + @pytest.mark.filterwarnings("default") + def test_silence_unknown_key_warning(self, pytester: Pytester) -> None: + """Unknown config key warnings can be silenced using filterwarnings (#7620)""" + pytester.makeini( + """ + [pytest] + filterwarnings = + ignore:Unknown config option:pytest.PytestConfigWarning + foobar=1 + """ + ) + result = pytester.runpytest() + result.stdout.no_fnmatch_line("*PytestConfigWarning*") + + @pytest.mark.filterwarnings("default") + def test_disable_warnings_plugin_disables_config_warnings( + self, pytester: Pytester + ) -> None: + """Disabling 'warnings' plugin also disables config time warnings""" + pytester.makeconftest( + """ + import pytest + def pytest_configure(config): + config.issue_config_time_warning( + pytest.PytestConfigWarning("custom config warning"), + stacklevel=2, + ) + """ + ) + result = pytester.runpytest("-pno:warnings") + result.stdout.no_fnmatch_line("*PytestConfigWarning*") + + @pytest.mark.parametrize( + "ini_file_text, exception_text", + [ + pytest.param( + """ + [pytest] + required_plugins = a z + """, + "Missing required plugins: a, z", + id="2-missing", + ), + pytest.param( + """ + [pytest] + required_plugins = a z myplugin + """, + "Missing required plugins: a, z", + id="2-missing-1-ok", + ), + pytest.param( + """ + [pytest] + required_plugins = myplugin + """, + None, + id="1-ok", + ), + pytest.param( + """ + [pytest] + required_plugins = myplugin==1.5 + """, + None, + id="1-ok-pin-exact", + ), + pytest.param( + """ + [pytest] + required_plugins = myplugin>1.0,<2.0 + """, + None, + id="1-ok-pin-loose", + ), + pytest.param( + """ + [pytest] + required_plugins = pyplugin==1.6 + """, + "Missing required plugins: pyplugin==1.6", + id="missing-version", + ), + pytest.param( + """ + [pytest] + required_plugins = pyplugin==1.6 other==1.0 + """, + "Missing required plugins: other==1.0, pyplugin==1.6", + id="missing-versions", + ), + pytest.param( + """ + [some_other_header] + required_plugins = wont be triggered + [pytest] + """, + None, + id="invalid-header", + ), + ], + ) + def test_missing_required_plugins( + self, + pytester: Pytester, + monkeypatch: MonkeyPatch, + ini_file_text: str, + exception_text: str, + ) -> None: + """Check 'required_plugins' option with various settings. + + This test installs a mock "myplugin-1.5" which is used in the parametrized test cases. + """ + + @attr.s + class DummyEntryPoint: + name = attr.ib() + module = attr.ib() + group = "pytest11" + + def load(self): + __import__(self.module) + return sys.modules[self.module] + + entry_points = [ + DummyEntryPoint("myplugin1", "myplugin1_module"), + ] + + @attr.s + class DummyDist: + entry_points = attr.ib() + files = () + version = "1.5" + + @property + def metadata(self): + return {"name": "myplugin"} + + def my_dists(): + return [DummyDist(entry_points)] + + pytester.makepyfile(myplugin1_module="# my plugin module") + pytester.syspathinsert() + + monkeypatch.setattr(importlib_metadata, "distributions", my_dists) + monkeypatch.delenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD", raising=False) + + pytester.makeini(ini_file_text) + + if exception_text: + with pytest.raises(pytest.UsageError, match=exception_text): + pytester.parseconfig() + else: + pytester.parseconfig() + + def test_early_config_cmdline( + self, pytester: Pytester, monkeypatch: MonkeyPatch + ) -> None: + """early_config contains options registered by third-party plugins. + + This is a regression involving pytest-cov (and possibly others) introduced in #7700. + """ + pytester.makepyfile( + myplugin=""" + def pytest_addoption(parser): + parser.addoption('--foo', default=None, dest='foo') + + def pytest_load_initial_conftests(early_config, parser, args): + assert early_config.known_args_namespace.foo == "1" + """ + ) + monkeypatch.setenv("PYTEST_PLUGINS", "myplugin") + pytester.syspathinsert() + result = pytester.runpytest("--foo=1") + result.stdout.fnmatch_lines("* no tests ran in *") + class TestConfigCmdlineParsing: - def test_parsing_again_fails(self, testdir): - config = testdir.parseconfig() + def test_parsing_again_fails(self, pytester: Pytester) -> None: + config = pytester.parseconfig() pytest.raises(AssertionError, lambda: config.parse([])) - def test_explicitly_specified_config_file_is_loaded(self, testdir): - testdir.makeconftest( + def test_explicitly_specified_config_file_is_loaded( + self, pytester: Pytester + ) -> None: + pytester.makeconftest( """ def pytest_addoption(parser): parser.addini("custom", "") """ ) - testdir.makeini( + pytester.makeini( """ [pytest] custom = 0 """ ) - testdir.makefile( + pytester.makefile( ".ini", custom=""" [pytest] custom = 1 """, ) - config = testdir.parseconfig("-c", "custom.ini") + config = pytester.parseconfig("-c", "custom.ini") assert config.getini("custom") == "1" - testdir.makefile( + pytester.makefile( ".cfg", custom_tool_pytest_section=""" [tool:pytest] custom = 1 """, ) - config = testdir.parseconfig("-c", "custom_tool_pytest_section.cfg") + config = pytester.parseconfig("-c", "custom_tool_pytest_section.cfg") + assert config.getini("custom") == "1" + + pytester.makefile( + ".toml", + custom=""" + [tool.pytest.ini_options] + custom = 1 + value = [ + ] # this is here on purpose, as it makes this an invalid '.ini' file + """, + ) + config = pytester.parseconfig("-c", "custom.toml") assert config.getini("custom") == "1" - def test_absolute_win32_path(self, testdir): - temp_ini_file = testdir.makefile( + def test_absolute_win32_path(self, pytester: Pytester) -> None: + temp_ini_file = pytester.makefile( ".ini", custom=""" [pytest] @@ -193,153 +515,206 @@ def test_absolute_win32_path(self, testdir): ) from os.path import normpath - temp_ini_file = normpath(str(temp_ini_file)) - ret = pytest.main(["-c", temp_ini_file]) + temp_ini_file_norm = normpath(str(temp_ini_file)) + ret = pytest.main(["-c", temp_ini_file_norm]) assert ret == ExitCode.OK class TestConfigAPI: - def test_config_trace(self, testdir): - config = testdir.parseconfig() - values = [] + def test_config_trace(self, pytester: Pytester) -> None: + config = pytester.parseconfig() + values: List[str] = [] config.trace.root.setwriter(values.append) config.trace("hello") assert len(values) == 1 assert values[0] == "hello [config]\n" - def test_config_getoption(self, testdir): - testdir.makeconftest( + def test_config_getoption(self, pytester: Pytester) -> None: + pytester.makeconftest( """ def pytest_addoption(parser): parser.addoption("--hello", "-X", dest="hello") """ ) - config = testdir.parseconfig("--hello=this") + config = pytester.parseconfig("--hello=this") for x in ("hello", "--hello", "-X"): assert config.getoption(x) == "this" pytest.raises(ValueError, config.getoption, "qweqwe") - def test_config_getoption_unicode(self, testdir): - testdir.makeconftest( + def test_config_getoption_unicode(self, pytester: Pytester) -> None: + pytester.makeconftest( """ def pytest_addoption(parser): parser.addoption('--hello', type=str) """ ) - config = testdir.parseconfig("--hello=this") + config = pytester.parseconfig("--hello=this") assert config.getoption("hello") == "this" - def test_config_getvalueorskip(self, testdir): - config = testdir.parseconfig() + def test_config_getvalueorskip(self, pytester: Pytester) -> None: + config = pytester.parseconfig() pytest.raises(pytest.skip.Exception, config.getvalueorskip, "hello") verbose = config.getvalueorskip("verbose") assert verbose == config.option.verbose - def test_config_getvalueorskip_None(self, testdir): - testdir.makeconftest( + def test_config_getvalueorskip_None(self, pytester: Pytester) -> None: + pytester.makeconftest( """ def pytest_addoption(parser): parser.addoption("--hello") """ ) - config = testdir.parseconfig() + config = pytester.parseconfig() with pytest.raises(pytest.skip.Exception): config.getvalueorskip("hello") - def test_getoption(self, testdir): - config = testdir.parseconfig() + def test_getoption(self, pytester: Pytester) -> None: + config = pytester.parseconfig() with pytest.raises(ValueError): config.getvalue("x") assert config.getoption("x", 1) == 1 - def test_getconftest_pathlist(self, testdir, tmpdir): + def test_getconftest_pathlist(self, pytester: Pytester, tmpdir) -> None: somepath = tmpdir.join("x", "y", "z") p = tmpdir.join("conftest.py") p.write("pathlist = ['.', %r]" % str(somepath)) - config = testdir.parseconfigure(p) + config = pytester.parseconfigure(p) assert config._getconftest_pathlist("notexist", path=tmpdir) is None - pl = config._getconftest_pathlist("pathlist", path=tmpdir) + pl = config._getconftest_pathlist("pathlist", path=tmpdir) or [] print(pl) assert len(pl) == 2 assert pl[0] == tmpdir assert pl[1] == somepath - def test_addini(self, testdir): - testdir.makeconftest( - """ + @pytest.mark.parametrize("maybe_type", ["not passed", "None", '"string"']) + def test_addini(self, pytester: Pytester, maybe_type: str) -> None: + if maybe_type == "not passed": + type_string = "" + else: + type_string = f", {maybe_type}" + + pytester.makeconftest( + f""" def pytest_addoption(parser): - parser.addini("myname", "my new ini value") + parser.addini("myname", "my new ini value"{type_string}) """ ) - testdir.makeini( + pytester.makeini( """ [pytest] myname=hello """ ) - config = testdir.parseconfig() + config = pytester.parseconfig() val = config.getini("myname") assert val == "hello" pytest.raises(ValueError, config.getini, "other") - def test_addini_pathlist(self, testdir): - testdir.makeconftest( + def make_conftest_for_pathlist(self, pytester: Pytester) -> None: + pytester.makeconftest( """ def pytest_addoption(parser): parser.addini("paths", "my new ini value", type="pathlist") parser.addini("abc", "abc value") """ ) - p = testdir.makeini( + + def test_addini_pathlist_ini_files(self, pytester: Pytester) -> None: + self.make_conftest_for_pathlist(pytester) + p = pytester.makeini( """ [pytest] paths=hello world/sub.py """ ) - config = testdir.parseconfig() + self.check_config_pathlist(pytester, p) + + def test_addini_pathlist_pyproject_toml(self, pytester: Pytester) -> None: + self.make_conftest_for_pathlist(pytester) + p = pytester.makepyprojecttoml( + """ + [tool.pytest.ini_options] + paths=["hello", "world/sub.py"] + """ + ) + self.check_config_pathlist(pytester, p) + + def check_config_pathlist(self, pytester: Pytester, config_path: Path) -> None: + config = pytester.parseconfig() values = config.getini("paths") assert len(values) == 2 - assert values[0] == p.dirpath("hello") - assert values[1] == p.dirpath("world/sub.py") + assert values[0] == config_path.parent.joinpath("hello") + assert values[1] == config_path.parent.joinpath("world/sub.py") pytest.raises(ValueError, config.getini, "other") - def test_addini_args(self, testdir): - testdir.makeconftest( + def make_conftest_for_args(self, pytester: Pytester) -> None: + pytester.makeconftest( """ def pytest_addoption(parser): parser.addini("args", "new args", type="args") parser.addini("a2", "", "args", default="1 2 3".split()) """ ) - testdir.makeini( + + def test_addini_args_ini_files(self, pytester: Pytester) -> None: + self.make_conftest_for_args(pytester) + pytester.makeini( """ [pytest] args=123 "123 hello" "this" - """ + """ ) - config = testdir.parseconfig() + self.check_config_args(pytester) + + def test_addini_args_pyproject_toml(self, pytester: Pytester) -> None: + self.make_conftest_for_args(pytester) + pytester.makepyprojecttoml( + """ + [tool.pytest.ini_options] + args = ["123", "123 hello", "this"] + """ + ) + self.check_config_args(pytester) + + def check_config_args(self, pytester: Pytester) -> None: + config = pytester.parseconfig() values = config.getini("args") - assert len(values) == 3 assert values == ["123", "123 hello", "this"] values = config.getini("a2") assert values == list("123") - def test_addini_linelist(self, testdir): - testdir.makeconftest( + def make_conftest_for_linelist(self, pytester: Pytester) -> None: + pytester.makeconftest( """ def pytest_addoption(parser): parser.addini("xy", "", type="linelist") parser.addini("a2", "", "linelist") """ ) - testdir.makeini( + + def test_addini_linelist_ini_files(self, pytester: Pytester) -> None: + self.make_conftest_for_linelist(pytester) + pytester.makeini( """ [pytest] xy= 123 345 second line """ ) - config = testdir.parseconfig() + self.check_config_linelist(pytester) + + def test_addini_linelist_pprojecttoml(self, pytester: Pytester) -> None: + self.make_conftest_for_linelist(pytester) + pytester.makepyprojecttoml( + """ + [tool.pytest.ini_options] + xy = ["123 345", "second line"] + """ + ) + self.check_config_linelist(pytester) + + def check_config_linelist(self, pytester: Pytester) -> None: + config = pytester.parseconfig() values = config.getini("xy") assert len(values) == 2 assert values == ["123 345", "second line"] @@ -349,38 +724,40 @@ def pytest_addoption(parser): @pytest.mark.parametrize( "str_val, bool_val", [("True", True), ("no", False), ("no-ini", True)] ) - def test_addini_bool(self, testdir, str_val, bool_val): - testdir.makeconftest( + def test_addini_bool( + self, pytester: Pytester, str_val: str, bool_val: bool + ) -> None: + pytester.makeconftest( """ def pytest_addoption(parser): parser.addini("strip", "", type="bool", default=True) """ ) if str_val != "no-ini": - testdir.makeini( + pytester.makeini( """ [pytest] strip=%s """ % str_val ) - config = testdir.parseconfig() + config = pytester.parseconfig() assert config.getini("strip") is bool_val - def test_addinivalue_line_existing(self, testdir): - testdir.makeconftest( + def test_addinivalue_line_existing(self, pytester: Pytester) -> None: + pytester.makeconftest( """ def pytest_addoption(parser): parser.addini("xy", "", type="linelist") """ ) - testdir.makeini( + pytester.makeini( """ [pytest] xy= 123 """ ) - config = testdir.parseconfig() + config = pytester.parseconfig() values = config.getini("xy") assert len(values) == 1 assert values == ["123"] @@ -389,14 +766,14 @@ def pytest_addoption(parser): assert len(values) == 2 assert values == ["123", "456"] - def test_addinivalue_line_new(self, testdir): - testdir.makeconftest( + def test_addinivalue_line_new(self, pytester: Pytester) -> None: + pytester.makeconftest( """ def pytest_addoption(parser): parser.addini("xy", "", type="linelist") """ ) - config = testdir.parseconfig() + config = pytester.parseconfig() assert not config.getini("xy") config.addinivalue_line("xy", "456") values = config.getini("xy") @@ -407,19 +784,17 @@ def pytest_addoption(parser): assert len(values) == 2 assert values == ["456", "123"] - def test_confcutdir_check_isdir(self, testdir): + def test_confcutdir_check_isdir(self, pytester: Pytester) -> None: """Give an error if --confcutdir is not a valid directory (#2078)""" exp_match = r"^--confcutdir must be a directory, given: " with pytest.raises(pytest.UsageError, match=exp_match): - testdir.parseconfig( - "--confcutdir", testdir.tmpdir.join("file").ensure(file=1) - ) + pytester.parseconfig("--confcutdir", pytester.path.joinpath("file")) with pytest.raises(pytest.UsageError, match=exp_match): - testdir.parseconfig("--confcutdir", testdir.tmpdir.join("inexistant")) - config = testdir.parseconfig( - "--confcutdir", testdir.tmpdir.join("dir").ensure(dir=1) - ) - assert config.getoption("confcutdir") == str(testdir.tmpdir.join("dir")) + pytester.parseconfig("--confcutdir", pytester.path.joinpath("inexistant")) + + p = pytester.mkdir("dir") + config = pytester.parseconfig("--confcutdir", p) + assert config.getoption("confcutdir") == str(p) @pytest.mark.parametrize( "names, expected", @@ -437,12 +812,12 @@ def test_confcutdir_check_isdir(self, testdir): (["source/python/bar/__init__.py", "setup.py"], ["bar"]), ], ) - def test_iter_rewritable_modules(self, names, expected): + def test_iter_rewritable_modules(self, names, expected) -> None: assert list(_iter_rewritable_modules(names)) == expected class TestConfigFromdictargs: - def test_basic_behavior(self, _sys_snapshot): + def test_basic_behavior(self, _sys_snapshot) -> None: option_dict = {"verbose": 444, "foo": "bar", "capture": "no"} args = ["a", "b"] @@ -454,9 +829,9 @@ def test_basic_behavior(self, _sys_snapshot): assert config.option.capture == "no" assert config.args == args - def test_invocation_params_args(self, _sys_snapshot): + def test_invocation_params_args(self, _sys_snapshot) -> None: """Show that fromdictargs can handle args in their "orig" format""" - option_dict = {} + option_dict: Dict[str, object] = {} args = ["-vvvv", "-s", "a", "b"] config = Config.fromdictargs(option_dict, args) @@ -465,8 +840,12 @@ def test_invocation_params_args(self, _sys_snapshot): assert config.option.verbose == 4 assert config.option.capture == "no" - def test_inifilename(self, tmpdir): - tmpdir.join("foo/bar.ini").ensure().write( + def test_inifilename(self, tmp_path: Path) -> None: + d1 = tmp_path.joinpath("foo") + d1.mkdir() + p1 = d1.joinpath("bar.ini") + p1.touch() + p1.write_text( textwrap.dedent( """\ [pytest] @@ -478,8 +857,11 @@ def test_inifilename(self, tmpdir): inifile = "../../foo/bar.ini" option_dict = {"inifilename": inifile, "capture": "no"} - cwd = tmpdir.join("a/b") - cwd.join("pytest.ini").ensure().write( + cwd = tmp_path.joinpath("a/b") + cwd.mkdir(parents=True) + p2 = cwd.joinpath("pytest.ini") + p2.touch() + p2.write_text( textwrap.dedent( """\ [pytest] @@ -488,49 +870,52 @@ def test_inifilename(self, tmpdir): """ ) ) - with cwd.ensure(dir=True).as_cwd(): + with MonkeyPatch.context() as mp: + mp.chdir(cwd) config = Config.fromdictargs(option_dict, ()) + inipath = py.path.local(inifile) assert config.args == [str(cwd)] assert config.option.inifilename == inifile assert config.option.capture == "no" # this indicates this is the file used for getting configuration values - assert config.inifile == inifile + assert config.inifile == inipath assert config.inicfg.get("name") == "value" assert config.inicfg.get("should_not_be_set") is None -def test_options_on_small_file_do_not_blow_up(testdir): - def runfiletest(opts): - reprec = testdir.inline_run(*opts) +def test_options_on_small_file_do_not_blow_up(pytester: Pytester) -> None: + def runfiletest(opts: Sequence[str]) -> None: + reprec = pytester.inline_run(*opts) passed, skipped, failed = reprec.countoutcomes() assert failed == 2 assert skipped == passed == 0 - path = testdir.makepyfile( - """ + path = str( + pytester.makepyfile( + """ def test_f1(): assert 0 def test_f2(): assert 0 """ + ) ) - for opts in ( - [], - ["-l"], - ["-s"], - ["--tb=no"], - ["--tb=short"], - ["--tb=long"], - ["--fulltrace"], - ["--traceconfig"], - ["-v"], - ["-v", "-v"], - ): - runfiletest(opts + [path]) - - -def test_preparse_ordering_with_setuptools(testdir, monkeypatch): + runfiletest([path]) + runfiletest(["-l", path]) + runfiletest(["-s", path]) + runfiletest(["--tb=no", path]) + runfiletest(["--tb=short", path]) + runfiletest(["--tb=long", path]) + runfiletest(["--fulltrace", path]) + runfiletest(["--traceconfig", path]) + runfiletest(["-v", path]) + runfiletest(["-v", "-v", path]) + + +def test_preparse_ordering_with_setuptools( + pytester: Pytester, monkeypatch: MonkeyPatch +) -> None: monkeypatch.delenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD", raising=False) class EntryPoint: @@ -545,24 +930,27 @@ class PseudoPlugin: class Dist: files = () + metadata = {"name": "foo"} entry_points = (EntryPoint(),) def my_dists(): return (Dist,) monkeypatch.setattr(importlib_metadata, "distributions", my_dists) - testdir.makeconftest( + pytester.makeconftest( """ pytest_plugins = "mytestplugin", """ ) monkeypatch.setenv("PYTEST_PLUGINS", "mytestplugin") - config = testdir.parseconfig() + config = pytester.parseconfig() plugin = config.pluginmanager.getplugin("mytestplugin") assert plugin.x == 42 -def test_setuptools_importerror_issue1479(testdir, monkeypatch): +def test_setuptools_importerror_issue1479( + pytester: Pytester, monkeypatch: MonkeyPatch +) -> None: monkeypatch.delenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD", raising=False) class DummyEntryPoint: @@ -575,6 +963,7 @@ def load(self): class Distribution: version = "1.0" files = ("foo.txt",) + metadata = {"name": "foo"} entry_points = (DummyEntryPoint(),) def distributions(): @@ -582,10 +971,12 @@ def distributions(): monkeypatch.setattr(importlib_metadata, "distributions", distributions) with pytest.raises(ImportError): - testdir.parseconfig() + pytester.parseconfig() -def test_importlib_metadata_broken_distribution(testdir, monkeypatch): +def test_importlib_metadata_broken_distribution( + pytester: Pytester, monkeypatch: MonkeyPatch +) -> None: """Integration test for broken distributions with 'files' metadata being None (#5389)""" monkeypatch.delenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD", raising=False) @@ -599,17 +990,20 @@ def load(self): class Distribution: version = "1.0" files = None + metadata = {"name": "foo"} entry_points = (DummyEntryPoint(),) def distributions(): return (Distribution(),) monkeypatch.setattr(importlib_metadata, "distributions", distributions) - testdir.parseconfig() + pytester.parseconfig() @pytest.mark.parametrize("block_it", [True, False]) -def test_plugin_preparse_prevents_setuptools_loading(testdir, monkeypatch, block_it): +def test_plugin_preparse_prevents_setuptools_loading( + pytester: Pytester, monkeypatch: MonkeyPatch, block_it: bool +) -> None: monkeypatch.delenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD", raising=False) plugin_module_placeholder = object() @@ -624,6 +1018,7 @@ def load(self): class Distribution: version = "1.0" files = ("foo.txt",) + metadata = {"name": "foo"} entry_points = (DummyEntryPoint(),) def distributions(): @@ -631,7 +1026,7 @@ def distributions(): monkeypatch.setattr(importlib_metadata, "distributions", distributions) args = ("-p", "no:mytestplugin") if block_it else () - config = testdir.parseconfig(*args) + config = pytester.parseconfig(*args) config.pluginmanager.import_plugin("mytestplugin") if block_it: assert "mytestplugin" not in sys.modules @@ -645,7 +1040,12 @@ def distributions(): @pytest.mark.parametrize( "parse_args,should_load", [(("-p", "mytestplugin"), True), ((), False)] ) -def test_disable_plugin_autoload(testdir, monkeypatch, parse_args, should_load): +def test_disable_plugin_autoload( + pytester: Pytester, + monkeypatch: MonkeyPatch, + parse_args: Union[Tuple[str, str], Tuple[()]], + should_load: bool, +) -> None: class DummyEntryPoint: project_name = name = "mytestplugin" group = "pytest11" @@ -655,6 +1055,7 @@ def load(self): return sys.modules[self.name] class Distribution: + metadata = {"name": "foo"} entry_points = (DummyEntryPoint(),) files = () @@ -673,8 +1074,8 @@ def distributions(): monkeypatch.setenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD", "1") monkeypatch.setattr(importlib_metadata, "distributions", distributions) - monkeypatch.setitem(sys.modules, "mytestplugin", PseudoPlugin()) - config = testdir.parseconfig(*parse_args) + monkeypatch.setitem(sys.modules, "mytestplugin", PseudoPlugin()) # type: ignore[misc] + config = pytester.parseconfig(*parse_args) has_loaded = config.pluginmanager.get_plugin("mytestplugin") is not None assert has_loaded == should_load if should_load: @@ -683,9 +1084,9 @@ def distributions(): assert PseudoPlugin.attrs_used == [] -def test_plugin_loading_order(testdir): +def test_plugin_loading_order(pytester: Pytester) -> None: """Test order of plugin loading with `-p`.""" - p1 = testdir.makepyfile( + p1 = pytester.makepyfile( """ def test_terminal_plugin(request): import myplugin @@ -704,37 +1105,37 @@ def pytest_sessionstart(session): """ }, ) - testdir.syspathinsert() - result = testdir.runpytest("-p", "myplugin", str(p1)) + pytester.syspathinsert() + result = pytester.runpytest("-p", "myplugin", str(p1)) assert result.ret == 0 -def test_cmdline_processargs_simple(testdir): - testdir.makeconftest( +def test_cmdline_processargs_simple(pytester: Pytester) -> None: + pytester.makeconftest( """ def pytest_cmdline_preparse(args): args.append("-h") """ ) - result = testdir.runpytest() + result = pytester.runpytest() result.stdout.fnmatch_lines(["*pytest*", "*-h*"]) -def test_invalid_options_show_extra_information(testdir): - """display extra information when pytest exits due to unrecognized - options in the command-line""" - testdir.makeini( +def test_invalid_options_show_extra_information(pytester: Pytester) -> None: + """Display extra information when pytest exits due to unrecognized + options in the command-line.""" + pytester.makeini( """ [pytest] addopts = --invalid-option """ ) - result = testdir.runpytest() + result = pytester.runpytest() result.stderr.fnmatch_lines( [ "*error: unrecognized arguments: --invalid-option*", - "* inifile: %s*" % testdir.tmpdir.join("tox.ini"), - "* rootdir: %s*" % testdir.tmpdir, + "* inifile: %s*" % pytester.path.joinpath("tox.ini"), + "* rootdir: %s*" % pytester.path, ] ) @@ -748,43 +1149,49 @@ def test_invalid_options_show_extra_information(testdir): ["-v", "dir2", "dir1"], ], ) -def test_consider_args_after_options_for_rootdir(testdir, args): +def test_consider_args_after_options_for_rootdir( + pytester: Pytester, args: List[str] +) -> None: """ Consider all arguments in the command-line for rootdir discovery, even if they happen to occur after an option. #949 """ # replace "dir1" and "dir2" from "args" into their real directory - root = testdir.tmpdir.mkdir("myroot") - d1 = root.mkdir("dir1") - d2 = root.mkdir("dir2") + root = pytester.mkdir("myroot") + d1 = root.joinpath("dir1") + d1.mkdir() + d2 = root.joinpath("dir2") + d2.mkdir() for i, arg in enumerate(args): if arg == "dir1": - args[i] = d1 + args[i] = str(d1) elif arg == "dir2": - args[i] = d2 - with root.as_cwd(): - result = testdir.runpytest(*args) + args[i] = str(d2) + with MonkeyPatch.context() as mp: + mp.chdir(root) + result = pytester.runpytest(*args) result.stdout.fnmatch_lines(["*rootdir: *myroot"]) -@pytest.mark.skipif("sys.platform == 'win32'") -def test_toolongargs_issue224(testdir): - result = testdir.runpytest("-m", "hello" * 500) +def test_toolongargs_issue224(pytester: Pytester) -> None: + result = pytester.runpytest("-m", "hello" * 500) assert result.ret == ExitCode.NO_TESTS_COLLECTED -def test_config_in_subdirectory_colon_command_line_issue2148(testdir): +def test_config_in_subdirectory_colon_command_line_issue2148( + pytester: Pytester, +) -> None: conftest_source = """ def pytest_addoption(parser): parser.addini('foo', 'foo') """ - testdir.makefile( + pytester.makefile( ".ini", **{"pytest": "[pytest]\nfoo = root", "subdir/pytest": "[pytest]\nfoo = subdir"}, ) - testdir.makepyfile( + pytester.makepyfile( **{ "conftest": conftest_source, "subdir/conftest": conftest_source, @@ -795,12 +1202,12 @@ def test_foo(pytestconfig): } ) - result = testdir.runpytest("subdir/test_foo.py::test_foo") + result = pytester.runpytest("subdir/test_foo.py::test_foo") assert result.ret == 0 -def test_notify_exception(testdir, capfd): - config = testdir.parseconfig() +def test_notify_exception(pytester: Pytester, capfd) -> None: + config = pytester.parseconfig() with pytest.raises(ValueError) as excinfo: raise ValueError(1) config.notify_exception(excinfo, config.option) @@ -816,7 +1223,7 @@ def pytest_internalerror(self): _, err = capfd.readouterr() assert not err - config = testdir.parseconfig("-p", "no:terminal") + config = pytester.parseconfig("-p", "no:terminal") with pytest.raises(ValueError) as excinfo: raise ValueError(1) config.notify_exception(excinfo, config.option) @@ -824,9 +1231,9 @@ def pytest_internalerror(self): assert "ValueError" in err -def test_no_terminal_discovery_error(testdir): - testdir.makepyfile("raise TypeError('oops!')") - result = testdir.runpytest("-p", "no:terminal", "--collect-only") +def test_no_terminal_discovery_error(pytester: Pytester) -> None: + pytester.makepyfile("raise TypeError('oops!')") + result = pytester.runpytest("-p", "no:terminal", "--collect-only") assert result.ret == ExitCode.INTERRUPTED @@ -841,25 +1248,21 @@ def pytest_load_initial_conftests(self): pm.register(m) hc = pm.hook.pytest_load_initial_conftests values = hc._nonwrappers + hc._wrappers - expected = ["_pytest.config", m.__module__, "_pytest.capture"] + expected = ["_pytest.config", m.__module__, "_pytest.capture", "_pytest.warnings"] assert [x.function.__module__ for x in values] == expected -def test_get_plugin_specs_as_list(): - from _pytest.config import _get_plugin_specs_as_list - - def exp_match(val): +def test_get_plugin_specs_as_list() -> None: + def exp_match(val: object) -> str: return ( - "Plugin specs must be a ','-separated string" - " or a list/tuple of strings for plugin names. Given: {}".format( - re.escape(repr(val)) - ) + "Plugins may be specified as a sequence or a ','-separated string of plugin names. Got: %s" + % re.escape(repr(val)) ) with pytest.raises(pytest.UsageError, match=exp_match({"foo"})): - _get_plugin_specs_as_list({"foo"}) + _get_plugin_specs_as_list({"foo"}) # type: ignore[arg-type] with pytest.raises(pytest.UsageError, match=exp_match({})): - _get_plugin_specs_as_list(dict()) + _get_plugin_specs_as_list(dict()) # type: ignore[arg-type] assert _get_plugin_specs_as_list(None) == [] assert _get_plugin_specs_as_list("") == [] @@ -869,10 +1272,10 @@ def exp_match(val): assert _get_plugin_specs_as_list(("foo", "bar")) == ["foo", "bar"] -def test_collect_pytest_prefix_bug_integration(testdir): +def test_collect_pytest_prefix_bug_integration(pytester: Pytester) -> None: """Integration test for issue #3775""" - p = testdir.copy_example("config/collect_pytest_prefix") - result = testdir.runpytest(p) + p = pytester.copy_example("config/collect_pytest_prefix") + result = pytester.runpytest(p) result.stdout.fnmatch_lines(["* 1 passed *"]) @@ -888,99 +1291,160 @@ class pytest_something: class TestRootdir: - def test_simple_noini(self, tmpdir): - assert get_common_ancestor([tmpdir]) == tmpdir - a = tmpdir.mkdir("a") - assert get_common_ancestor([a, tmpdir]) == tmpdir - assert get_common_ancestor([tmpdir, a]) == tmpdir - with tmpdir.as_cwd(): - assert get_common_ancestor([]) == tmpdir - no_path = tmpdir.join("does-not-exist") - assert get_common_ancestor([no_path]) == tmpdir - assert get_common_ancestor([no_path.join("a")]) == tmpdir + def test_simple_noini(self, tmp_path: Path, monkeypatch: MonkeyPatch) -> None: + assert get_common_ancestor([tmp_path]) == tmp_path + a = tmp_path / "a" + a.mkdir() + assert get_common_ancestor([a, tmp_path]) == tmp_path + assert get_common_ancestor([tmp_path, a]) == tmp_path + monkeypatch.chdir(tmp_path) + assert get_common_ancestor([]) == tmp_path + no_path = tmp_path / "does-not-exist" + assert get_common_ancestor([no_path]) == tmp_path + assert get_common_ancestor([no_path / "a"]) == tmp_path - @pytest.mark.parametrize("name", "setup.cfg tox.ini pytest.ini".split()) - def test_with_ini(self, tmpdir, name) -> None: - inifile = tmpdir.join(name) - inifile.write("[pytest]\n" if name != "setup.cfg" else "[tool:pytest]\n") - - a = tmpdir.mkdir("a") - b = a.mkdir("b") - for args in ([tmpdir], [a], [b]): - rootdir, parsed_inifile, _ = determine_setup(None, args) - assert rootdir == tmpdir - assert parsed_inifile == inifile - rootdir, parsed_inifile, _ = determine_setup(None, [b, a]) - assert rootdir == tmpdir - assert parsed_inifile == inifile - - @pytest.mark.parametrize("name", "setup.cfg tox.ini".split()) - def test_pytestini_overrides_empty_other(self, tmpdir, name) -> None: - inifile = tmpdir.ensure("pytest.ini") - a = tmpdir.mkdir("a") - a.ensure(name) - rootdir, parsed_inifile, _ = determine_setup(None, [a]) - assert rootdir == tmpdir - assert parsed_inifile == inifile - - def test_setuppy_fallback(self, tmpdir) -> None: - a = tmpdir.mkdir("a") - a.ensure("setup.cfg") - tmpdir.ensure("setup.py") - rootdir, inifile, inicfg = determine_setup(None, [a]) - assert rootdir == tmpdir - assert inifile is None + @pytest.mark.parametrize( + "name, contents", + [ + pytest.param("pytest.ini", "[pytest]\nx=10", id="pytest.ini"), + pytest.param( + "pyproject.toml", "[tool.pytest.ini_options]\nx=10", id="pyproject.toml" + ), + pytest.param("tox.ini", "[pytest]\nx=10", id="tox.ini"), + pytest.param("setup.cfg", "[tool:pytest]\nx=10", id="setup.cfg"), + ], + ) + def test_with_ini(self, tmp_path: Path, name: str, contents: str) -> None: + inipath = tmp_path / name + inipath.write_text(contents, "utf-8") + + a = tmp_path / "a" + a.mkdir() + b = a / "b" + b.mkdir() + for args in ([str(tmp_path)], [str(a)], [str(b)]): + rootpath, parsed_inipath, _ = determine_setup(None, args) + assert rootpath == tmp_path + assert parsed_inipath == inipath + rootpath, parsed_inipath, ini_config = determine_setup(None, [str(b), str(a)]) + assert rootpath == tmp_path + assert parsed_inipath == inipath + assert ini_config == {"x": "10"} + + @pytest.mark.parametrize("name", ["setup.cfg", "tox.ini"]) + def test_pytestini_overrides_empty_other(self, tmp_path: Path, name: str) -> None: + inipath = tmp_path / "pytest.ini" + inipath.touch() + a = tmp_path / "a" + a.mkdir() + (a / name).touch() + rootpath, parsed_inipath, _ = determine_setup(None, [str(a)]) + assert rootpath == tmp_path + assert parsed_inipath == inipath + + def test_setuppy_fallback(self, tmp_path: Path) -> None: + a = tmp_path / "a" + a.mkdir() + (a / "setup.cfg").touch() + (tmp_path / "setup.py").touch() + rootpath, inipath, inicfg = determine_setup(None, [str(a)]) + assert rootpath == tmp_path + assert inipath is None assert inicfg == {} - def test_nothing(self, tmpdir, monkeypatch) -> None: - monkeypatch.chdir(str(tmpdir)) - rootdir, inifile, inicfg = determine_setup(None, [tmpdir]) - assert rootdir == tmpdir - assert inifile is None + def test_nothing(self, tmp_path: Path, monkeypatch: MonkeyPatch) -> None: + monkeypatch.chdir(tmp_path) + rootpath, inipath, inicfg = determine_setup(None, [str(tmp_path)]) + assert rootpath == tmp_path + assert inipath is None assert inicfg == {} - def test_with_specific_inifile(self, tmpdir) -> None: - inifile = tmpdir.ensure("pytest.ini") - rootdir, _, _ = determine_setup(inifile, [tmpdir]) - assert rootdir == tmpdir - - def test_with_arg_outside_cwd_without_inifile(self, tmpdir, monkeypatch) -> None: - monkeypatch.chdir(str(tmpdir)) - a = tmpdir.mkdir("a") - b = tmpdir.mkdir("b") - rootdir, inifile, _ = determine_setup(None, [a, b]) - assert rootdir == tmpdir + @pytest.mark.parametrize( + "name, contents", + [ + # pytest.param("pytest.ini", "[pytest]\nx=10", id="pytest.ini"), + pytest.param( + "pyproject.toml", "[tool.pytest.ini_options]\nx=10", id="pyproject.toml" + ), + # pytest.param("tox.ini", "[pytest]\nx=10", id="tox.ini"), + # pytest.param("setup.cfg", "[tool:pytest]\nx=10", id="setup.cfg"), + ], + ) + def test_with_specific_inifile( + self, tmp_path: Path, name: str, contents: str + ) -> None: + p = tmp_path / name + p.touch() + p.write_text(contents, "utf-8") + rootpath, inipath, ini_config = determine_setup(str(p), [str(tmp_path)]) + assert rootpath == tmp_path + assert inipath == p + assert ini_config == {"x": "10"} + + def test_with_arg_outside_cwd_without_inifile( + self, tmp_path: Path, monkeypatch: MonkeyPatch + ) -> None: + monkeypatch.chdir(tmp_path) + a = tmp_path / "a" + a.mkdir() + b = tmp_path / "b" + b.mkdir() + rootpath, inifile, _ = determine_setup(None, [str(a), str(b)]) + assert rootpath == tmp_path assert inifile is None - def test_with_arg_outside_cwd_with_inifile(self, tmpdir) -> None: - a = tmpdir.mkdir("a") - b = tmpdir.mkdir("b") - inifile = a.ensure("pytest.ini") - rootdir, parsed_inifile, _ = determine_setup(None, [a, b]) - assert rootdir == a - assert inifile == parsed_inifile + def test_with_arg_outside_cwd_with_inifile(self, tmp_path: Path) -> None: + a = tmp_path / "a" + a.mkdir() + b = tmp_path / "b" + b.mkdir() + inipath = a / "pytest.ini" + inipath.touch() + rootpath, parsed_inipath, _ = determine_setup(None, [str(a), str(b)]) + assert rootpath == a + assert inipath == parsed_inipath @pytest.mark.parametrize("dirs", ([], ["does-not-exist"], ["a/does-not-exist"])) - def test_with_non_dir_arg(self, dirs, tmpdir) -> None: - with tmpdir.ensure(dir=True).as_cwd(): - rootdir, inifile, _ = determine_setup(None, dirs) - assert rootdir == tmpdir - assert inifile is None - - def test_with_existing_file_in_subdir(self, tmpdir) -> None: - a = tmpdir.mkdir("a") - a.ensure("exist") - with tmpdir.as_cwd(): - rootdir, inifile, _ = determine_setup(None, ["a/exist"]) - assert rootdir == tmpdir - assert inifile is None + def test_with_non_dir_arg( + self, dirs: Sequence[str], tmp_path: Path, monkeypatch: MonkeyPatch + ) -> None: + monkeypatch.chdir(tmp_path) + rootpath, inipath, _ = determine_setup(None, dirs) + assert rootpath == tmp_path + assert inipath is None + + def test_with_existing_file_in_subdir( + self, tmp_path: Path, monkeypatch: MonkeyPatch + ) -> None: + a = tmp_path / "a" + a.mkdir() + (a / "exists").touch() + monkeypatch.chdir(tmp_path) + rootpath, inipath, _ = determine_setup(None, ["a/exist"]) + assert rootpath == tmp_path + assert inipath is None + + def test_with_config_also_in_parent_directory( + self, tmp_path: Path, monkeypatch: MonkeyPatch + ) -> None: + """Regression test for #7807.""" + (tmp_path / "setup.cfg").write_text("[tool:pytest]\n", "utf-8") + (tmp_path / "myproject").mkdir() + (tmp_path / "myproject" / "setup.cfg").write_text("[tool:pytest]\n", "utf-8") + (tmp_path / "myproject" / "tests").mkdir() + monkeypatch.chdir(tmp_path / "myproject") + + rootpath, inipath, _ = determine_setup(None, ["tests/"]) + + assert rootpath == tmp_path / "myproject" + assert inipath == tmp_path / "myproject" / "setup.cfg" class TestOverrideIniArgs: @pytest.mark.parametrize("name", "setup.cfg tox.ini pytest.ini".split()) - def test_override_ini_names(self, testdir, name): + def test_override_ini_names(self, pytester: Pytester, name: str) -> None: section = "[pytest]" if name != "setup.cfg" else "[tool:pytest]" - testdir.tmpdir.join(name).write( + pytester.path.joinpath(name).write_text( textwrap.dedent( """ {section} @@ -989,40 +1453,40 @@ def test_override_ini_names(self, testdir, name): ) ) ) - testdir.makeconftest( + pytester.makeconftest( """ def pytest_addoption(parser): parser.addini("custom", "")""" ) - testdir.makepyfile( + pytester.makepyfile( """ def test_pass(pytestconfig): ini_val = pytestconfig.getini("custom") print('\\ncustom_option:%s\\n' % ini_val)""" ) - result = testdir.runpytest("--override-ini", "custom=2.0", "-s") + result = pytester.runpytest("--override-ini", "custom=2.0", "-s") assert result.ret == 0 result.stdout.fnmatch_lines(["custom_option:2.0"]) - result = testdir.runpytest( + result = pytester.runpytest( "--override-ini", "custom=2.0", "--override-ini=custom=3.0", "-s" ) assert result.ret == 0 result.stdout.fnmatch_lines(["custom_option:3.0"]) - def test_override_ini_pathlist(self, testdir): - testdir.makeconftest( + def test_override_ini_pathlist(self, pytester: Pytester) -> None: + pytester.makeconftest( """ def pytest_addoption(parser): parser.addini("paths", "my new ini value", type="pathlist")""" ) - testdir.makeini( + pytester.makeini( """ [pytest] paths=blah.py""" ) - testdir.makepyfile( + pytester.makepyfile( """ import py.path def test_pathlist(pytestconfig): @@ -1031,13 +1495,13 @@ def test_pathlist(pytestconfig): for cpf in config_paths: print('\\nuser_path:%s' % cpf.basename)""" ) - result = testdir.runpytest( + result = pytester.runpytest( "--override-ini", "paths=foo/bar1.py foo/bar2.py", "-s" ) result.stdout.fnmatch_lines(["user_path:bar1.py", "user_path:bar2.py"]) - def test_override_multiple_and_default(self, testdir): - testdir.makeconftest( + def test_override_multiple_and_default(self, pytester: Pytester) -> None: + pytester.makeconftest( """ def pytest_addoption(parser): addini = parser.addini @@ -1046,14 +1510,14 @@ def pytest_addoption(parser): addini("custom_option_3", "", default=False, type="bool") addini("custom_option_4", "", default=True, type="bool")""" ) - testdir.makeini( + pytester.makeini( """ [pytest] custom_option_1=custom_option_1 custom_option_2=custom_option_2 """ ) - testdir.makepyfile( + pytester.makepyfile( """ def test_multiple_options(pytestconfig): prefix = "custom_option" @@ -1062,7 +1526,7 @@ def test_multiple_options(pytestconfig): print('\\nini%d:%s' % (x, ini_value)) """ ) - result = testdir.runpytest( + result = pytester.runpytest( "--override-ini", "custom_option_1=fulldir=/tmp/user1", "-o", @@ -1082,14 +1546,14 @@ def test_multiple_options(pytestconfig): ] ) - def test_override_ini_usage_error_bad_style(self, testdir): - testdir.makeini( + def test_override_ini_usage_error_bad_style(self, pytester: Pytester) -> None: + pytester.makeini( """ [pytest] xdist_strict=False """ ) - result = testdir.runpytest("--override-ini", "xdist_strict", "True") + result = pytester.runpytest("--override-ini", "xdist_strict", "True") result.stderr.fnmatch_lines( [ "ERROR: -o/--override-ini expects option=value style (got: 'xdist_strict').", @@ -1097,32 +1561,38 @@ def test_override_ini_usage_error_bad_style(self, testdir): ) @pytest.mark.parametrize("with_ini", [True, False]) - def test_override_ini_handled_asap(self, testdir, with_ini): + def test_override_ini_handled_asap( + self, pytester: Pytester, with_ini: bool + ) -> None: """-o should be handled as soon as possible and always override what's in ini files (#2238)""" if with_ini: - testdir.makeini( + pytester.makeini( """ [pytest] python_files=test_*.py """ ) - testdir.makepyfile( + pytester.makepyfile( unittest_ini_handle=""" def test(): pass """ ) - result = testdir.runpytest("--override-ini", "python_files=unittest_*.py") + result = pytester.runpytest("--override-ini", "python_files=unittest_*.py") result.stdout.fnmatch_lines(["*1 passed in*"]) - def test_addopts_before_initini(self, monkeypatch, _config_for_test, _sys_snapshot): + def test_addopts_before_initini( + self, monkeypatch: MonkeyPatch, _config_for_test, _sys_snapshot + ) -> None: cache_dir = ".custom_cache" monkeypatch.setenv("PYTEST_ADDOPTS", "-o cache_dir=%s" % cache_dir) config = _config_for_test config._preparse([], addopts=True) assert config._override_ini == ["cache_dir=%s" % cache_dir] - def test_addopts_from_env_not_concatenated(self, monkeypatch, _config_for_test): + def test_addopts_from_env_not_concatenated( + self, monkeypatch: MonkeyPatch, _config_for_test + ) -> None: """PYTEST_ADDOPTS should not take values from normal args (#4265).""" monkeypatch.setenv("PYTEST_ADDOPTS", "-o") config = _config_for_test @@ -1133,32 +1603,34 @@ def test_addopts_from_env_not_concatenated(self, monkeypatch, _config_for_test): in excinfo.value.args[0] ) - def test_addopts_from_ini_not_concatenated(self, testdir): - """addopts from ini should not take values from normal args (#4265).""" - testdir.makeini( + def test_addopts_from_ini_not_concatenated(self, pytester: Pytester) -> None: + """`addopts` from ini should not take values from normal args (#4265).""" + pytester.makeini( """ [pytest] addopts=-o """ ) - result = testdir.runpytest("cache_dir=ignored") + result = pytester.runpytest("cache_dir=ignored") result.stderr.fnmatch_lines( [ "%s: error: argument -o/--override-ini: expected one argument (via addopts config)" - % (testdir.request.config._parser.optparser.prog,) + % (pytester._request.config._parser.optparser.prog,) ] ) assert result.ret == _pytest.config.ExitCode.USAGE_ERROR - def test_override_ini_does_not_contain_paths(self, _config_for_test, _sys_snapshot): + def test_override_ini_does_not_contain_paths( + self, _config_for_test, _sys_snapshot + ) -> None: """Check that -o no longer swallows all options after it (#3103)""" config = _config_for_test config._preparse(["-o", "cache_dir=/cache", "/some/test/path"]) assert config._override_ini == ["cache_dir=/cache"] - def test_multiple_override_ini_options(self, testdir): + def test_multiple_override_ini_options(self, pytester: Pytester) -> None: """Ensure a file path following a '-o' option does not generate an error (#3103)""" - testdir.makepyfile( + pytester.makepyfile( **{ "conftest.py": """ def pytest_addoption(parser): @@ -1176,19 +1648,19 @@ def test(): """, } ) - result = testdir.runpytest("-o", "foo=1", "-o", "bar=0", "test_foo.py") + result = pytester.runpytest("-o", "foo=1", "-o", "bar=0", "test_foo.py") assert "ERROR:" not in result.stderr.str() result.stdout.fnmatch_lines(["collected 1 item", "*= 1 passed in *="]) -def test_help_via_addopts(testdir): - testdir.makeini( +def test_help_via_addopts(pytester: Pytester) -> None: + pytester.makeini( """ [pytest] addopts = --unknown-option-should-allow-for-help --help """ ) - result = testdir.runpytest() + result = pytester.runpytest() assert result.ret == 0 result.stdout.fnmatch_lines( [ @@ -1200,8 +1672,8 @@ def test_help_via_addopts(testdir): ) -def test_help_and_version_after_argument_error(testdir): - testdir.makeconftest( +def test_help_and_version_after_argument_error(pytester: Pytester) -> None: + pytester.makeconftest( """ def validate(arg): raise argparse.ArgumentTypeError("argerror") @@ -1214,13 +1686,13 @@ def pytest_addoption(parser): ) """ ) - testdir.makeini( + pytester.makeini( """ [pytest] addopts = --invalid-option-should-allow-for-help """ ) - result = testdir.runpytest("--help") + result = pytester.runpytest("--help") result.stdout.fnmatch_lines( [ "usage: *", @@ -1232,28 +1704,26 @@ def pytest_addoption(parser): [ "ERROR: usage: *", "%s: error: argument --invalid-option-should-allow-for-help: expected one argument" - % (testdir.request.config._parser.optparser.prog,), + % (pytester._request.config._parser.optparser.prog,), ] ) # Does not display full/default help. assert "to see available markers type: pytest --markers" not in result.stdout.lines assert result.ret == ExitCode.USAGE_ERROR - result = testdir.runpytest("--version") - result.stderr.fnmatch_lines( - ["*pytest*{}*imported from*".format(pytest.__version__)] - ) + result = pytester.runpytest("--version") + result.stderr.fnmatch_lines([f"pytest {pytest.__version__}"]) assert result.ret == ExitCode.USAGE_ERROR -def test_help_formatter_uses_py_get_terminal_width(monkeypatch): +def test_help_formatter_uses_py_get_terminal_width(monkeypatch: MonkeyPatch) -> None: from _pytest.config.argparsing import DropShorterLongHelpFormatter monkeypatch.setenv("COLUMNS", "90") formatter = DropShorterLongHelpFormatter("prog") assert formatter._width == 90 - monkeypatch.setattr("py.io.get_terminal_width", lambda: 160) + monkeypatch.setattr("_pytest._io.get_terminal_width", lambda: 160) formatter = DropShorterLongHelpFormatter("prog") assert formatter._width == 160 @@ -1261,43 +1731,43 @@ def test_help_formatter_uses_py_get_terminal_width(monkeypatch): assert formatter._width == 42 -def test_config_does_not_load_blocked_plugin_from_args(testdir): +def test_config_does_not_load_blocked_plugin_from_args(pytester: Pytester) -> None: """This tests that pytest's config setup handles "-p no:X".""" - p = testdir.makepyfile("def test(capfd): pass") - result = testdir.runpytest(str(p), "-pno:capture") + p = pytester.makepyfile("def test(capfd): pass") + result = pytester.runpytest(str(p), "-pno:capture") result.stdout.fnmatch_lines(["E fixture 'capfd' not found"]) assert result.ret == ExitCode.TESTS_FAILED - result = testdir.runpytest(str(p), "-pno:capture", "-s") + result = pytester.runpytest(str(p), "-pno:capture", "-s") result.stderr.fnmatch_lines(["*: error: unrecognized arguments: -s"]) assert result.ret == ExitCode.USAGE_ERROR -def test_invocation_args(testdir): +def test_invocation_args(pytester: Pytester) -> None: """Ensure that Config.invocation_* arguments are correctly defined""" class DummyPlugin: pass - p = testdir.makepyfile("def test(): pass") + p = pytester.makepyfile("def test(): pass") plugin = DummyPlugin() - rec = testdir.inline_run(p, "-v", plugins=[plugin]) + rec = pytester.inline_run(p, "-v", plugins=[plugin]) calls = rec.getcalls("pytest_runtest_protocol") assert len(calls) == 1 call = calls[0] config = call.item.config - assert config.invocation_params.args == (p, "-v") - assert config.invocation_params.dir == Path(str(testdir.tmpdir)) + assert config.invocation_params.args == (str(p), "-v") + assert config.invocation_params.dir == pytester.path plugins = config.invocation_params.plugins assert len(plugins) == 2 assert plugins[0] is plugin - assert type(plugins[1]).__name__ == "Collect" # installed by testdir.inline_run() + assert type(plugins[1]).__name__ == "Collect" # installed by pytester.inline_run() # args cannot be None with pytest.raises(TypeError): - Config.InvocationParams(args=None, plugins=None, dir=Path()) + Config.InvocationParams(args=None, plugins=None, dir=Path()) # type: ignore[arg-type] @pytest.mark.parametrize( @@ -1308,7 +1778,7 @@ class DummyPlugin: if x not in _pytest.config.essential_plugins ], ) -def test_config_blocked_default_plugins(testdir, plugin): +def test_config_blocked_default_plugins(pytester: Pytester, plugin: str) -> None: if plugin == "debugging": # Fixed in xdist master (after 1.27.0). # https://github.com/pytest-dev/pytest-xdist/pull/422 @@ -1319,8 +1789,8 @@ def test_config_blocked_default_plugins(testdir, plugin): else: pytest.skip("does not work with xdist currently") - p = testdir.makepyfile("def test(): pass") - result = testdir.runpytest(str(p), "-pno:%s" % plugin) + p = pytester.makepyfile("def test(): pass") + result = pytester.runpytest(str(p), "-pno:%s" % plugin) if plugin == "python": assert result.ret == ExitCode.USAGE_ERROR @@ -1336,8 +1806,8 @@ def test_config_blocked_default_plugins(testdir, plugin): if plugin != "terminal": result.stdout.fnmatch_lines(["* 1 passed in *"]) - p = testdir.makepyfile("def test(): assert 0") - result = testdir.runpytest(str(p), "-pno:%s" % plugin) + p = pytester.makepyfile("def test(): assert 0") + result = pytester.runpytest(str(p), "-pno:%s" % plugin) assert result.ret == ExitCode.TESTS_FAILED if plugin != "terminal": result.stdout.fnmatch_lines(["* 1 failed in *"]) @@ -1346,8 +1816,8 @@ def test_config_blocked_default_plugins(testdir, plugin): class TestSetupCfg: - def test_pytest_setup_cfg_unsupported(self, testdir): - testdir.makefile( + def test_pytest_setup_cfg_unsupported(self, pytester: Pytester) -> None: + pytester.makefile( ".cfg", setup=""" [pytest] @@ -1355,10 +1825,10 @@ def test_pytest_setup_cfg_unsupported(self, testdir): """, ) with pytest.raises(pytest.fail.Exception): - testdir.runpytest() + pytester.runpytest() - def test_pytest_custom_cfg_unsupported(self, testdir): - testdir.makefile( + def test_pytest_custom_cfg_unsupported(self, pytester: Pytester) -> None: + pytester.makefile( ".cfg", custom=""" [pytest] @@ -1366,38 +1836,35 @@ def test_pytest_custom_cfg_unsupported(self, testdir): """, ) with pytest.raises(pytest.fail.Exception): - testdir.runpytest("-c", "custom.cfg") + pytester.runpytest("-c", "custom.cfg") class TestPytestPluginsVariable: - def test_pytest_plugins_in_non_top_level_conftest_unsupported(self, testdir): - testdir.makepyfile( + def test_pytest_plugins_in_non_top_level_conftest_unsupported( + self, pytester: Pytester + ) -> None: + pytester.makepyfile( **{ "subdirectory/conftest.py": """ pytest_plugins=['capture'] """ } ) - testdir.makepyfile( + pytester.makepyfile( """ def test_func(): pass """ ) - res = testdir.runpytest() + res = pytester.runpytest() assert res.ret == 2 msg = "Defining 'pytest_plugins' in a non-top-level conftest is no longer supported" - res.stdout.fnmatch_lines( - [ - "*{msg}*".format(msg=msg), - "*subdirectory{sep}conftest.py*".format(sep=os.sep), - ] - ) + res.stdout.fnmatch_lines([f"*{msg}*", f"*subdirectory{os.sep}conftest.py*"]) @pytest.mark.parametrize("use_pyargs", [True, False]) def test_pytest_plugins_in_non_top_level_conftest_unsupported_pyargs( - self, testdir, use_pyargs - ): + self, pytester: Pytester, use_pyargs: bool + ) -> None: """When using --pyargs, do not emit the warning about non-top-level conftest warnings (#4039, #4044)""" files = { @@ -1408,11 +1875,11 @@ def test_pytest_plugins_in_non_top_level_conftest_unsupported_pyargs( "src/pkg/sub/conftest.py": "pytest_plugins=['capture']", "src/pkg/sub/test_bar.py": "def test(): pass", } - testdir.makepyfile(**files) - testdir.syspathinsert(testdir.tmpdir.join("src")) + pytester.makepyfile(**files) + pytester.syspathinsert(pytester.path.joinpath("src")) args = ("--pyargs", "pkg") if use_pyargs else () - res = testdir.runpytest(*args) + res = pytester.runpytest(*args) assert res.ret == (0 if use_pyargs else 2) msg = ( msg @@ -1420,63 +1887,114 @@ def test_pytest_plugins_in_non_top_level_conftest_unsupported_pyargs( if use_pyargs: assert msg not in res.stdout.str() else: - res.stdout.fnmatch_lines(["*{msg}*".format(msg=msg)]) + res.stdout.fnmatch_lines([f"*{msg}*"]) def test_pytest_plugins_in_non_top_level_conftest_unsupported_no_top_level_conftest( - self, testdir - ): - subdirectory = testdir.tmpdir.join("subdirectory") + self, pytester: Pytester + ) -> None: + subdirectory = pytester.path.joinpath("subdirectory") subdirectory.mkdir() - testdir.makeconftest( + pytester.makeconftest( """ pytest_plugins=['capture'] """ ) - testdir.tmpdir.join("conftest.py").move(subdirectory.join("conftest.py")) + pytester.path.joinpath("conftest.py").rename( + subdirectory.joinpath("conftest.py") + ) - testdir.makepyfile( + pytester.makepyfile( """ def test_func(): pass """ ) - res = testdir.runpytest_subprocess() + res = pytester.runpytest_subprocess() assert res.ret == 2 msg = "Defining 'pytest_plugins' in a non-top-level conftest is no longer supported" - res.stdout.fnmatch_lines( - [ - "*{msg}*".format(msg=msg), - "*subdirectory{sep}conftest.py*".format(sep=os.sep), - ] - ) + res.stdout.fnmatch_lines([f"*{msg}*", f"*subdirectory{os.sep}conftest.py*"]) def test_pytest_plugins_in_non_top_level_conftest_unsupported_no_false_positives( - self, testdir - ): - subdirectory = testdir.tmpdir.join("subdirectory") - subdirectory.mkdir() - testdir.makeconftest( - """ - pass - """ - ) - testdir.tmpdir.join("conftest.py").move(subdirectory.join("conftest.py")) - - testdir.makeconftest( - """ - import warnings - warnings.filterwarnings('always', category=DeprecationWarning) - pytest_plugins=['capture'] - """ - ) - testdir.makepyfile( - """ - def test_func(): - pass - """ - ) - res = testdir.runpytest_subprocess() + self, pytester: Pytester + ) -> None: + pytester.makepyfile( + "def test_func(): pass", + **{ + "subdirectory/conftest": "pass", + "conftest": """ + import warnings + warnings.filterwarnings('always', category=DeprecationWarning) + pytest_plugins=['capture'] + """, + }, + ) + res = pytester.runpytest_subprocess() assert res.ret == 0 msg = "Defining 'pytest_plugins' in a non-top-level conftest is no longer supported" assert msg not in res.stdout.str() + + +def test_conftest_import_error_repr(tmpdir: py.path.local) -> None: + """`ConftestImportFailure` should use a short error message and readable + path to the failed conftest.py file.""" + path = tmpdir.join("foo/conftest.py") + with pytest.raises( + ConftestImportFailure, + match=re.escape(f"RuntimeError: some error (from {path})"), + ): + try: + raise RuntimeError("some error") + except Exception as exc: + assert exc.__traceback__ is not None + exc_info = (type(exc), exc, exc.__traceback__) + raise ConftestImportFailure(path, exc_info) from exc + + +def test_strtobool() -> None: + assert _strtobool("YES") + assert not _strtobool("NO") + with pytest.raises(ValueError): + _strtobool("unknown") + + +@pytest.mark.parametrize( + "arg, escape, expected", + [ + ("ignore", False, ("ignore", "", Warning, "", 0)), + ( + "ignore::DeprecationWarning", + False, + ("ignore", "", DeprecationWarning, "", 0), + ), + ( + "ignore:some msg:DeprecationWarning", + False, + ("ignore", "some msg", DeprecationWarning, "", 0), + ), + ( + "ignore::DeprecationWarning:mod", + False, + ("ignore", "", DeprecationWarning, "mod", 0), + ), + ( + "ignore::DeprecationWarning:mod:42", + False, + ("ignore", "", DeprecationWarning, "mod", 42), + ), + ("error:some\\msg:::", True, ("error", "some\\\\msg", Warning, "", 0)), + ("error:::mod\\foo:", True, ("error", "", Warning, "mod\\\\foo\\Z", 0)), + ], +) +def test_parse_warning_filter( + arg: str, escape: bool, expected: Tuple[str, str, Type[Warning], str, int] +) -> None: + assert parse_warning_filter(arg, escape=escape) == expected + + +@pytest.mark.parametrize("arg", [":" * 5, "::::-1", "::::not-a-number"]) +def test_parse_warning_filter_failure(arg: str) -> None: + import warnings + + with pytest.raises(warnings._OptionError): + parse_warning_filter(arg, escape=True) diff --git a/testing/test_conftest.py b/testing/test_conftest.py index a07af60f6f9..638321728d7 100644 --- a/testing/test_conftest.py +++ b/testing/test_conftest.py @@ -1,29 +1,42 @@ +import argparse import os import textwrap +from pathlib import Path +from typing import cast +from typing import Dict +from typing import List +from typing import Optional import py import pytest from _pytest.config import ExitCode from _pytest.config import PytestPluginManager -from _pytest.pathlib import Path +from _pytest.monkeypatch import MonkeyPatch +from _pytest.pathlib import symlink_or_skip +from _pytest.pytester import Pytester +from _pytest.pytester import Testdir -def ConftestWithSetinitial(path): +def ConftestWithSetinitial(path) -> PytestPluginManager: conftest = PytestPluginManager() conftest_setinitial(conftest, [path]) return conftest -def conftest_setinitial(conftest, args, confcutdir=None): +def conftest_setinitial( + conftest: PytestPluginManager, args, confcutdir: Optional[py.path.local] = None +) -> None: class Namespace: - def __init__(self): + def __init__(self) -> None: self.file_or_dir = args self.confcutdir = str(confcutdir) self.noconftest = False self.pyargs = False + self.importmode = "prepend" - conftest._set_initial_conftests(Namespace()) + namespace = cast(argparse.Namespace, Namespace()) + conftest._set_initial_conftests(namespace) @pytest.mark.usefixtures("_sys_snapshot") @@ -42,63 +55,67 @@ def basedir(self, request, tmpdir_factory): def test_basic_init(self, basedir): conftest = PytestPluginManager() p = basedir.join("adir") - assert conftest._rget_with_confmod("a", p)[1] == 1 + assert conftest._rget_with_confmod("a", p, importmode="prepend")[1] == 1 def test_immediate_initialiation_and_incremental_are_the_same(self, basedir): conftest = PytestPluginManager() assert not len(conftest._dirpath2confmods) - conftest._getconftestmodules(basedir) + conftest._getconftestmodules(basedir, importmode="prepend") snap1 = len(conftest._dirpath2confmods) assert snap1 == 1 - conftest._getconftestmodules(basedir.join("adir")) + conftest._getconftestmodules(basedir.join("adir"), importmode="prepend") assert len(conftest._dirpath2confmods) == snap1 + 1 - conftest._getconftestmodules(basedir.join("b")) + conftest._getconftestmodules(basedir.join("b"), importmode="prepend") assert len(conftest._dirpath2confmods) == snap1 + 2 def test_value_access_not_existing(self, basedir): conftest = ConftestWithSetinitial(basedir) with pytest.raises(KeyError): - conftest._rget_with_confmod("a", basedir) + conftest._rget_with_confmod("a", basedir, importmode="prepend") def test_value_access_by_path(self, basedir): conftest = ConftestWithSetinitial(basedir) adir = basedir.join("adir") - assert conftest._rget_with_confmod("a", adir)[1] == 1 - assert conftest._rget_with_confmod("a", adir.join("b"))[1] == 1.5 + assert conftest._rget_with_confmod("a", adir, importmode="prepend")[1] == 1 + assert ( + conftest._rget_with_confmod("a", adir.join("b"), importmode="prepend")[1] + == 1.5 + ) def test_value_access_with_confmod(self, basedir): startdir = basedir.join("adir", "b") startdir.ensure("xx", dir=True) conftest = ConftestWithSetinitial(startdir) - mod, value = conftest._rget_with_confmod("a", startdir) + mod, value = conftest._rget_with_confmod("a", startdir, importmode="prepend") assert value == 1.5 path = py.path.local(mod.__file__) assert path.dirpath() == basedir.join("adir", "b") assert path.purebasename.startswith("conftest") -def test_conftest_in_nonpkg_with_init(tmpdir, _sys_snapshot): - tmpdir.ensure("adir-1.0/conftest.py").write("a=1 ; Directory = 3") - tmpdir.ensure("adir-1.0/b/conftest.py").write("b=2 ; a = 1.5") - tmpdir.ensure("adir-1.0/b/__init__.py") - tmpdir.ensure("adir-1.0/__init__.py") - ConftestWithSetinitial(tmpdir.join("adir-1.0", "b")) +def test_conftest_in_nonpkg_with_init(tmp_path: Path, _sys_snapshot) -> None: + tmp_path.joinpath("adir-1.0/b").mkdir(parents=True) + tmp_path.joinpath("adir-1.0/conftest.py").write_text("a=1 ; Directory = 3") + tmp_path.joinpath("adir-1.0/b/conftest.py").write_text("b=2 ; a = 1.5") + tmp_path.joinpath("adir-1.0/b/__init__.py").touch() + tmp_path.joinpath("adir-1.0/__init__.py").touch() + ConftestWithSetinitial(tmp_path.joinpath("adir-1.0", "b")) -def test_doubledash_considered(testdir): +def test_doubledash_considered(testdir: Testdir) -> None: conf = testdir.mkdir("--option") - conf.ensure("conftest.py") + conf.join("conftest.py").ensure() conftest = PytestPluginManager() conftest_setinitial(conftest, [conf.basename, conf.basename]) - values = conftest._getconftestmodules(conf) + values = conftest._getconftestmodules(py.path.local(conf), importmode="prepend") assert len(values) == 1 -def test_issue151_load_all_conftests(testdir): +def test_issue151_load_all_conftests(pytester: Pytester) -> None: names = "code proj src".split() for name in names: - p = testdir.mkdir(name) - p.ensure("conftest.py") + p = pytester.mkdir(name) + p.joinpath("conftest.py").touch() conftest = PytestPluginManager() conftest_setinitial(conftest, names) @@ -106,66 +123,69 @@ def test_issue151_load_all_conftests(testdir): assert len(d) == len(names) -def test_conftest_global_import(testdir): - testdir.makeconftest("x=3") - p = testdir.makepyfile( +def test_conftest_global_import(pytester: Pytester) -> None: + pytester.makeconftest("x=3") + p = pytester.makepyfile( """ import py, pytest from _pytest.config import PytestPluginManager conf = PytestPluginManager() - mod = conf._importconftest(py.path.local("conftest.py")) + mod = conf._importconftest(py.path.local("conftest.py"), importmode="prepend") assert mod.x == 3 import conftest assert conftest is mod, (conftest, mod) subconf = py.path.local().ensure("sub", "conftest.py") subconf.write("y=4") - mod2 = conf._importconftest(subconf) + mod2 = conf._importconftest(subconf, importmode="prepend") assert mod != mod2 assert mod2.y == 4 import conftest assert conftest is mod2, (conftest, mod) """ ) - res = testdir.runpython(p) + res = pytester.runpython(p) assert res.ret == 0 -def test_conftestcutdir(testdir): +def test_conftestcutdir(testdir: Testdir) -> None: conf = testdir.makeconftest("") p = testdir.mkdir("x") conftest = PytestPluginManager() conftest_setinitial(conftest, [testdir.tmpdir], confcutdir=p) - values = conftest._getconftestmodules(p) + values = conftest._getconftestmodules(p, importmode="prepend") assert len(values) == 0 - values = conftest._getconftestmodules(conf.dirpath()) + values = conftest._getconftestmodules(conf.dirpath(), importmode="prepend") assert len(values) == 0 - assert conf not in conftest._conftestpath2mod + assert Path(conf) not in conftest._conftestpath2mod # but we can still import a conftest directly - conftest._importconftest(conf) - values = conftest._getconftestmodules(conf.dirpath()) + conftest._importconftest(conf, importmode="prepend") + values = conftest._getconftestmodules(conf.dirpath(), importmode="prepend") assert values[0].__file__.startswith(str(conf)) # and all sub paths get updated properly - values = conftest._getconftestmodules(p) + values = conftest._getconftestmodules(p, importmode="prepend") assert len(values) == 1 assert values[0].__file__.startswith(str(conf)) -def test_conftestcutdir_inplace_considered(testdir): - conf = testdir.makeconftest("") +def test_conftestcutdir_inplace_considered(pytester: Pytester) -> None: + conf = pytester.makeconftest("") conftest = PytestPluginManager() - conftest_setinitial(conftest, [conf.dirpath()], confcutdir=conf.dirpath()) - values = conftest._getconftestmodules(conf.dirpath()) + conftest_setinitial(conftest, [conf.parent], confcutdir=py.path.local(conf.parent)) + values = conftest._getconftestmodules( + py.path.local(conf.parent), importmode="prepend" + ) assert len(values) == 1 assert values[0].__file__.startswith(str(conf)) @pytest.mark.parametrize("name", "test tests whatever .dotdir".split()) -def test_setinitial_conftest_subdirs(testdir, name): - sub = testdir.mkdir(name) - subconftest = sub.ensure("conftest.py") +def test_setinitial_conftest_subdirs(pytester: Pytester, name: str) -> None: + sub = pytester.mkdir(name) + subconftest = sub.joinpath("conftest.py") + subconftest.touch() conftest = PytestPluginManager() - conftest_setinitial(conftest, [sub.dirpath()], confcutdir=testdir.tmpdir) - key = Path(str(subconftest)).resolve() + conftest_setinitial(conftest, [sub.parent], confcutdir=py.path.local(pytester.path)) + key = subconftest.resolve() if name not in ("whatever", ".dotdir"): assert key in conftest._conftestpath2mod assert len(conftest._conftestpath2mod) == 1 @@ -174,10 +194,10 @@ def test_setinitial_conftest_subdirs(testdir, name): assert len(conftest._conftestpath2mod) == 0 -def test_conftest_confcutdir(testdir): - testdir.makeconftest("assert 0") - x = testdir.mkdir("x") - x.join("conftest.py").write( +def test_conftest_confcutdir(pytester: Pytester) -> None: + pytester.makeconftest("assert 0") + x = pytester.mkdir("x") + x.joinpath("conftest.py").write_text( textwrap.dedent( """\ def pytest_addoption(parser): @@ -185,22 +205,30 @@ def pytest_addoption(parser): """ ) ) - result = testdir.runpytest("-h", "--confcutdir=%s" % x, x) + result = pytester.runpytest("-h", "--confcutdir=%s" % x, x) result.stdout.fnmatch_lines(["*--xyz*"]) result.stdout.no_fnmatch_line("*warning: could not load initial*") -@pytest.mark.skipif( - not hasattr(py.path.local, "mksymlinkto"), - reason="symlink not available on this platform", -) -def test_conftest_symlink(testdir): - """Ensure that conftest.py is used for resolved symlinks.""" - real = testdir.tmpdir.mkdir("real") - realtests = real.mkdir("app").mkdir("tests") - testdir.tmpdir.join("symlinktests").mksymlinkto(realtests) - testdir.tmpdir.join("symlink").mksymlinkto(real) - testdir.makepyfile( +def test_conftest_symlink(pytester: Pytester) -> None: + """`conftest.py` discovery follows normal path resolution and does not resolve symlinks.""" + # Structure: + # /real + # /real/conftest.py + # /real/app + # /real/app/tests + # /real/app/tests/test_foo.py + + # Links: + # /symlinktests -> /real/app/tests (running at symlinktests should fail) + # /symlink -> /real (running at /symlink should work) + + real = pytester.mkdir("real") + realtests = real.joinpath("app/tests") + realtests.mkdir(parents=True) + symlink_or_skip(realtests, pytester.path.joinpath("symlinktests")) + symlink_or_skip(real, pytester.path.joinpath("symlink")) + pytester.makepyfile( **{ "real/app/tests/test_foo.py": "def test1(fixture): pass", "real/conftest.py": textwrap.dedent( @@ -216,39 +244,21 @@ def fixture(): ), } ) - result = testdir.runpytest("-vs", "symlinktests") - result.stdout.fnmatch_lines( - [ - "*conftest_loaded*", - "real/app/tests/test_foo.py::test1 fixture_used", - "PASSED", - ] - ) - assert result.ret == ExitCode.OK - # Should not cause "ValueError: Plugin already registered" (#4174). - result = testdir.runpytest("-vs", "symlink") - assert result.ret == ExitCode.OK + # Should fail because conftest cannot be found from the link structure. + result = pytester.runpytest("-vs", "symlinktests") + result.stdout.fnmatch_lines(["*fixture 'fixture' not found*"]) + assert result.ret == ExitCode.TESTS_FAILED - realtests.ensure("__init__.py") - result = testdir.runpytest("-vs", "symlinktests/test_foo.py::test1") - result.stdout.fnmatch_lines( - [ - "*conftest_loaded*", - "real/app/tests/test_foo.py::test1 fixture_used", - "PASSED", - ] - ) + # Should not cause "ValueError: Plugin already registered" (#4174). + result = pytester.runpytest("-vs", "symlink") assert result.ret == ExitCode.OK -@pytest.mark.skipif( - not hasattr(py.path.local, "mksymlinkto"), - reason="symlink not available on this platform", -) -def test_conftest_symlink_files(testdir): - """Check conftest.py loading when running in directory with symlinks.""" - real = testdir.tmpdir.mkdir("real") +def test_conftest_symlink_files(pytester: Pytester) -> None: + """Symlinked conftest.py are found when pytest is executed in a directory with symlinked + files.""" + real = pytester.mkdir("real") source = { "app/test_foo.py": "def test1(fixture): pass", "app/__init__.py": "", @@ -264,16 +274,16 @@ def fixture(): """ ), } - testdir.makepyfile(**{"real/%s" % k: v for k, v in source.items()}) + pytester.makepyfile(**{"real/%s" % k: v for k, v in source.items()}) # Create a build directory that contains symlinks to actual files # but doesn't symlink actual directories. - build = testdir.tmpdir.mkdir("build") - build.mkdir("app") + build = pytester.mkdir("build") + build.joinpath("app").mkdir() for f in source: - build.join(f).mksymlinkto(real.join(f)) - build.chdir() - result = testdir.runpytest("-vs", "app/test_foo.py") + symlink_or_skip(real.joinpath(f), build.joinpath(f)) + os.chdir(build) + result = pytester.runpytest("-vs", "app/test_foo.py") result.stdout.fnmatch_lines(["*conftest_loaded*", "PASSED"]) assert result.ret == ExitCode.OK @@ -282,39 +292,39 @@ def fixture(): os.path.normcase("x") != os.path.normcase("X"), reason="only relevant for case insensitive file systems", ) -def test_conftest_badcase(testdir): +def test_conftest_badcase(pytester: Pytester) -> None: """Check conftest.py loading when directory casing is wrong (#5792).""" - testdir.tmpdir.mkdir("JenkinsRoot").mkdir("test") + pytester.path.joinpath("JenkinsRoot/test").mkdir(parents=True) source = {"setup.py": "", "test/__init__.py": "", "test/conftest.py": ""} - testdir.makepyfile(**{"JenkinsRoot/%s" % k: v for k, v in source.items()}) + pytester.makepyfile(**{"JenkinsRoot/%s" % k: v for k, v in source.items()}) - testdir.tmpdir.join("jenkinsroot/test").chdir() - result = testdir.runpytest() + os.chdir(pytester.path.joinpath("jenkinsroot/test")) + result = pytester.runpytest() assert result.ret == ExitCode.NO_TESTS_COLLECTED -def test_conftest_uppercase(testdir): +def test_conftest_uppercase(pytester: Pytester) -> None: """Check conftest.py whose qualified name contains uppercase characters (#5819)""" source = {"__init__.py": "", "Foo/conftest.py": "", "Foo/__init__.py": ""} - testdir.makepyfile(**source) + pytester.makepyfile(**source) - testdir.tmpdir.chdir() - result = testdir.runpytest() + os.chdir(pytester.path) + result = pytester.runpytest() assert result.ret == ExitCode.NO_TESTS_COLLECTED -def test_no_conftest(testdir): - testdir.makeconftest("assert 0") - result = testdir.runpytest("--noconftest") +def test_no_conftest(pytester: Pytester) -> None: + pytester.makeconftest("assert 0") + result = pytester.runpytest("--noconftest") assert result.ret == ExitCode.NO_TESTS_COLLECTED - result = testdir.runpytest() + result = pytester.runpytest() assert result.ret == ExitCode.USAGE_ERROR -def test_conftest_existing_resultlog(testdir): - x = testdir.mkdir("tests") - x.join("conftest.py").write( +def test_conftest_existing_junitxml(pytester: Pytester) -> None: + x = pytester.mkdir("tests") + x.joinpath("conftest.py").write_text( textwrap.dedent( """\ def pytest_addoption(parser): @@ -322,48 +332,38 @@ def pytest_addoption(parser): """ ) ) - testdir.makefile(ext=".log", result="") # Writes result.log - result = testdir.runpytest("-h", "--resultlog", "result.log") + pytester.makefile(ext=".xml", junit="") # Writes junit.xml + result = pytester.runpytest("-h", "--junitxml", "junit.xml") result.stdout.fnmatch_lines(["*--xyz*"]) -def test_conftest_existing_junitxml(testdir): - x = testdir.mkdir("tests") - x.join("conftest.py").write( - textwrap.dedent( - """\ - def pytest_addoption(parser): - parser.addoption("--xyz", action="store_true") - """ - ) - ) - testdir.makefile(ext=".xml", junit="") # Writes junit.xml - result = testdir.runpytest("-h", "--junitxml", "junit.xml") - result.stdout.fnmatch_lines(["*--xyz*"]) - - -def test_conftest_import_order(testdir, monkeypatch): +def test_conftest_import_order(testdir: Testdir, monkeypatch: MonkeyPatch) -> None: ct1 = testdir.makeconftest("") sub = testdir.mkdir("sub") ct2 = sub.join("conftest.py") ct2.write("") - def impct(p): + def impct(p, importmode): return p conftest = PytestPluginManager() conftest._confcutdir = testdir.tmpdir monkeypatch.setattr(conftest, "_importconftest", impct) - assert conftest._getconftestmodules(sub) == [ct1, ct2] + mods = cast( + List[py.path.local], + conftest._getconftestmodules(py.path.local(sub), importmode="prepend"), + ) + expected = [ct1, ct2] + assert mods == expected -def test_fixture_dependency(testdir): - ct1 = testdir.makeconftest("") - ct1 = testdir.makepyfile("__init__.py") - ct1.write("") - sub = testdir.mkdir("sub") - sub.join("__init__.py").write("") - sub.join("conftest.py").write( +def test_fixture_dependency(pytester: Pytester) -> None: + ct1 = pytester.makeconftest("") + ct1 = pytester.makepyfile("__init__.py") + ct1.write_text("") + sub = pytester.mkdir("sub") + sub.joinpath("__init__.py").write_text("") + sub.joinpath("conftest.py").write_text( textwrap.dedent( """\ import pytest @@ -382,9 +382,10 @@ def bar(foo): """ ) ) - subsub = sub.mkdir("subsub") - subsub.join("__init__.py").write("") - subsub.join("test_bar.py").write( + subsub = sub.joinpath("subsub") + subsub.mkdir() + subsub.joinpath("__init__.py").write_text("") + subsub.joinpath("test_bar.py").write_text( textwrap.dedent( """\ import pytest @@ -398,13 +399,13 @@ def test_event_fixture(bar): """ ) ) - result = testdir.runpytest("sub") + result = pytester.runpytest("sub") result.stdout.fnmatch_lines(["*1 passed*"]) -def test_conftest_found_with_double_dash(testdir): - sub = testdir.mkdir("sub") - sub.join("conftest.py").write( +def test_conftest_found_with_double_dash(pytester: Pytester) -> None: + sub = pytester.mkdir("sub") + sub.joinpath("conftest.py").write_text( textwrap.dedent( """\ def pytest_addoption(parser): @@ -412,9 +413,9 @@ def pytest_addoption(parser): """ ) ) - p = sub.join("test_hello.py") - p.write("def test_hello(): pass") - result = testdir.runpytest(str(p) + "::test_hello", "-h") + p = sub.joinpath("test_hello.py") + p.write_text("def test_hello(): pass") + result = pytester.runpytest(str(p) + "::test_hello", "-h") result.stdout.fnmatch_lines( """ *--hello-world* @@ -423,13 +424,13 @@ def pytest_addoption(parser): class TestConftestVisibility: - def _setup_tree(self, testdir): # for issue616 + def _setup_tree(self, pytester: Pytester) -> Dict[str, Path]: # for issue616 # example mostly taken from: # https://mail.python.org/pipermail/pytest-dev/2014-September/002617.html - runner = testdir.mkdir("empty") - package = testdir.mkdir("package") + runner = pytester.mkdir("empty") + package = pytester.mkdir("package") - package.join("conftest.py").write( + package.joinpath("conftest.py").write_text( textwrap.dedent( """\ import pytest @@ -439,7 +440,7 @@ def fxtr(): """ ) ) - package.join("test_pkgroot.py").write( + package.joinpath("test_pkgroot.py").write_text( textwrap.dedent( """\ def test_pkgroot(fxtr): @@ -448,9 +449,10 @@ def test_pkgroot(fxtr): ) ) - swc = package.mkdir("swc") - swc.join("__init__.py").ensure() - swc.join("conftest.py").write( + swc = package.joinpath("swc") + swc.mkdir() + swc.joinpath("__init__.py").touch() + swc.joinpath("conftest.py").write_text( textwrap.dedent( """\ import pytest @@ -460,7 +462,7 @@ def fxtr(): """ ) ) - swc.join("test_with_conftest.py").write( + swc.joinpath("test_with_conftest.py").write_text( textwrap.dedent( """\ def test_with_conftest(fxtr): @@ -469,9 +471,10 @@ def test_with_conftest(fxtr): ) ) - snc = package.mkdir("snc") - snc.join("__init__.py").ensure() - snc.join("test_no_conftest.py").write( + snc = package.joinpath("snc") + snc.mkdir() + snc.joinpath("__init__.py").touch() + snc.joinpath("test_no_conftest.py").write_text( textwrap.dedent( """\ def test_no_conftest(fxtr): @@ -481,8 +484,8 @@ def test_no_conftest(fxtr): ) ) print("created directory structure:") - for x in testdir.tmpdir.visit(): - print(" " + x.relto(testdir.tmpdir)) + for x in pytester.path.rglob(""): + print(" " + str(x.relative_to(pytester.path))) return {"runner": runner, "package": package, "swc": swc, "snc": snc} @@ -514,29 +517,32 @@ def test_no_conftest(fxtr): ], ) def test_parsefactories_relative_node_ids( - self, testdir, chdir, testarg, expect_ntests_passed - ): + self, pytester: Pytester, chdir: str, testarg: str, expect_ntests_passed: int + ) -> None: """#616""" - dirs = self._setup_tree(testdir) - print("pytest run in cwd: %s" % (dirs[chdir].relto(testdir.tmpdir))) + dirs = self._setup_tree(pytester) + print("pytest run in cwd: %s" % (dirs[chdir].relative_to(pytester.path))) print("pytestarg : %s" % (testarg)) print("expected pass : %s" % (expect_ntests_passed)) - with dirs[chdir].as_cwd(): - reprec = testdir.inline_run(testarg, "-q", "--traceconfig") - reprec.assertoutcome(passed=expect_ntests_passed) + os.chdir(dirs[chdir]) + reprec = pytester.inline_run(testarg, "-q", "--traceconfig") + reprec.assertoutcome(passed=expect_ntests_passed) @pytest.mark.parametrize( "confcutdir,passed,error", [(".", 2, 0), ("src", 1, 1), (None, 1, 1)] ) -def test_search_conftest_up_to_inifile(testdir, confcutdir, passed, error): +def test_search_conftest_up_to_inifile( + pytester: Pytester, confcutdir: str, passed: int, error: int +) -> None: """Test that conftest files are detected only up to an ini file, unless an explicit --confcutdir option is given. """ - root = testdir.tmpdir - src = root.join("src").ensure(dir=1) - src.join("pytest.ini").write("[pytest]") - src.join("conftest.py").write( + root = pytester.path + src = root.joinpath("src") + src.mkdir() + src.joinpath("pytest.ini").write_text("[pytest]") + src.joinpath("conftest.py").write_text( textwrap.dedent( """\ import pytest @@ -545,7 +551,7 @@ def fix1(): pass """ ) ) - src.join("test_foo.py").write( + src.joinpath("test_foo.py").write_text( textwrap.dedent( """\ def test_1(fix1): @@ -555,7 +561,7 @@ def test_2(out_of_reach): """ ) ) - root.join("conftest.py").write( + root.joinpath("conftest.py").write_text( textwrap.dedent( """\ import pytest @@ -567,8 +573,8 @@ def out_of_reach(): pass args = [str(src)] if confcutdir: - args = ["--confcutdir=%s" % root.join(confcutdir)] - result = testdir.runpytest(*args) + args = ["--confcutdir=%s" % root.joinpath(confcutdir)] + result = pytester.runpytest(*args) match = "" if passed: match += "*%d passed*" % passed @@ -577,8 +583,8 @@ def out_of_reach(): pass result.stdout.fnmatch_lines(match) -def test_issue1073_conftest_special_objects(testdir): - testdir.makeconftest( +def test_issue1073_conftest_special_objects(pytester: Pytester) -> None: + pytester.makeconftest( """\ class DontTouchMe(object): def __getattr__(self, x): @@ -587,38 +593,38 @@ def __getattr__(self, x): x = DontTouchMe() """ ) - testdir.makepyfile( + pytester.makepyfile( """\ def test_some(): pass """ ) - res = testdir.runpytest() + res = pytester.runpytest() assert res.ret == 0 -def test_conftest_exception_handling(testdir): - testdir.makeconftest( +def test_conftest_exception_handling(pytester: Pytester) -> None: + pytester.makeconftest( """\ raise ValueError() """ ) - testdir.makepyfile( + pytester.makepyfile( """\ def test_some(): pass """ ) - res = testdir.runpytest() + res = pytester.runpytest() assert res.ret == 4 assert "raise ValueError()" in [line.strip() for line in res.errlines] -def test_hook_proxy(testdir): +def test_hook_proxy(pytester: Pytester) -> None: """Session's gethookproxy() would cache conftests incorrectly (#2016). It was decided to remove the cache altogether. """ - testdir.makepyfile( + pytester.makepyfile( **{ "root/demo-0/test_foo1.py": "def test1(): pass", "root/demo-a/test_foo2.py": "def test1(): pass", @@ -630,16 +636,16 @@ def pytest_ignore_collect(path, config): "root/demo-c/test_foo4.py": "def test1(): pass", } ) - result = testdir.runpytest() + result = pytester.runpytest() result.stdout.fnmatch_lines( ["*test_foo1.py*", "*test_foo3.py*", "*test_foo4.py*", "*3 passed*"] ) -def test_required_option_help(testdir): - testdir.makeconftest("assert 0") - x = testdir.mkdir("x") - x.join("conftest.py").write( +def test_required_option_help(pytester: Pytester) -> None: + pytester.makeconftest("assert 0") + x = pytester.mkdir("x") + x.joinpath("conftest.py").write_text( textwrap.dedent( """\ def pytest_addoption(parser): @@ -647,6 +653,6 @@ def pytest_addoption(parser): """ ) ) - result = testdir.runpytest("-h", x) + result = pytester.runpytest("-h", x) result.stdout.no_fnmatch_line("*argument --xyz is required*") assert "general:" in result.stdout.str() diff --git a/testing/test_debugging.py b/testing/test_debugging.py index 719d6477bff..ed96f7ec781 100644 --- a/testing/test_debugging.py +++ b/testing/test_debugging.py @@ -1,9 +1,12 @@ import os import sys +from typing import List import _pytest._code import pytest from _pytest.debugging import _validate_usepdb_cls +from _pytest.monkeypatch import MonkeyPatch +from _pytest.pytester import Pytester try: # Type ignored for Python <= 3.6. @@ -19,22 +22,22 @@ @pytest.fixture(autouse=True) def pdb_env(request): - if "testdir" in request.fixturenames: + if "pytester" in request.fixturenames: # Disable pdb++ with inner tests. - testdir = request.getfixturevalue("testdir") - testdir.monkeypatch.setenv("PDBPP_HIJACK_PDB", "0") + pytester = request.getfixturevalue("testdir") + pytester.monkeypatch.setenv("PDBPP_HIJACK_PDB", "0") -def runpdb_and_get_report(testdir, source): - p = testdir.makepyfile(source) - result = testdir.runpytest_inprocess("--pdb", p) - reports = result.reprec.getreports("pytest_runtest_logreport") +def runpdb_and_get_report(pytester: Pytester, source: str): + p = pytester.makepyfile(source) + result = pytester.runpytest_inprocess("--pdb", p) + reports = result.reprec.getreports("pytest_runtest_logreport") # type: ignore[attr-defined] assert len(reports) == 3, reports # setup/call/teardown return reports[1] @pytest.fixture -def custom_pdb_calls(): +def custom_pdb_calls() -> List[str]: called = [] # install dummy debugger class and track which methods were called on it @@ -50,7 +53,7 @@ def reset(self): def interaction(self, *args): called.append("interaction") - _pytest._CustomPdb = _CustomPdb + _pytest._CustomPdb = _CustomPdb # type: ignore return called @@ -73,9 +76,9 @@ def set_trace(self, frame): print("**CustomDebugger**") called.append("set_trace") - _pytest._CustomDebugger = _CustomDebugger + _pytest._CustomDebugger = _CustomDebugger # type: ignore yield called - del _pytest._CustomDebugger + del _pytest._CustomDebugger # type: ignore class TestPDB: @@ -91,9 +94,9 @@ def mypdb(*args): monkeypatch.setattr(plugin, "post_mortem", mypdb) return pdblist - def test_pdb_on_fail(self, testdir, pdblist): + def test_pdb_on_fail(self, pytester: Pytester, pdblist) -> None: rep = runpdb_and_get_report( - testdir, + pytester, """ def test_func(): assert 0 @@ -104,9 +107,9 @@ def test_func(): tb = _pytest._code.Traceback(pdblist[0][0]) assert tb[-1].name == "test_func" - def test_pdb_on_xfail(self, testdir, pdblist): + def test_pdb_on_xfail(self, pytester: Pytester, pdblist) -> None: rep = runpdb_and_get_report( - testdir, + pytester, """ import pytest @pytest.mark.xfail @@ -117,9 +120,9 @@ def test_func(): assert "xfail" in rep.keywords assert not pdblist - def test_pdb_on_skip(self, testdir, pdblist): + def test_pdb_on_skip(self, pytester, pdblist) -> None: rep = runpdb_and_get_report( - testdir, + pytester, """ import pytest def test_func(): @@ -129,9 +132,9 @@ def test_func(): assert rep.skipped assert len(pdblist) == 0 - def test_pdb_on_BdbQuit(self, testdir, pdblist): + def test_pdb_on_BdbQuit(self, pytester, pdblist) -> None: rep = runpdb_and_get_report( - testdir, + pytester, """ import bdb def test_func(): @@ -141,9 +144,9 @@ def test_func(): assert rep.failed assert len(pdblist) == 0 - def test_pdb_on_KeyboardInterrupt(self, testdir, pdblist): + def test_pdb_on_KeyboardInterrupt(self, pytester, pdblist) -> None: rep = runpdb_and_get_report( - testdir, + pytester, """ def test_func(): raise KeyboardInterrupt @@ -160,8 +163,8 @@ def flush(child): child.wait() assert not child.isalive() - def test_pdb_unittest_postmortem(self, testdir): - p1 = testdir.makepyfile( + def test_pdb_unittest_postmortem(self, pytester: Pytester) -> None: + p1 = pytester.makepyfile( """ import unittest class Blub(unittest.TestCase): @@ -172,7 +175,7 @@ def test_false(self): assert 0 """ ) - child = testdir.spawn_pytest("--pdb %s" % p1) + child = pytester.spawn_pytest(f"--pdb {p1}") child.expect("Pdb") child.sendline("p self.filename") child.sendeof() @@ -180,9 +183,9 @@ def test_false(self): assert "debug.me" in rest self.flush(child) - def test_pdb_unittest_skip(self, testdir): + def test_pdb_unittest_skip(self, pytester: Pytester) -> None: """Test for issue #2137""" - p1 = testdir.makepyfile( + p1 = pytester.makepyfile( """ import unittest @unittest.skipIf(True, 'Skipping also with pdb active') @@ -191,14 +194,14 @@ def test_one(self): assert 0 """ ) - child = testdir.spawn_pytest("-rs --pdb %s" % p1) + child = pytester.spawn_pytest(f"-rs --pdb {p1}") child.expect("Skipping also with pdb active") child.expect_exact("= 1 skipped in") child.sendeof() self.flush(child) - def test_pdb_print_captured_stdout_and_stderr(self, testdir): - p1 = testdir.makepyfile( + def test_pdb_print_captured_stdout_and_stderr(self, pytester: Pytester) -> None: + p1 = pytester.makepyfile( """ def test_1(): import sys @@ -210,7 +213,7 @@ def test_not_called_due_to_quit(): pass """ ) - child = testdir.spawn_pytest("--pdb %s" % p1) + child = pytester.spawn_pytest("--pdb %s" % p1) child.expect("captured stdout") child.expect("get rekt") child.expect("captured stderr") @@ -226,14 +229,16 @@ def test_not_called_due_to_quit(): assert "get rekt" not in rest self.flush(child) - def test_pdb_dont_print_empty_captured_stdout_and_stderr(self, testdir): - p1 = testdir.makepyfile( + def test_pdb_dont_print_empty_captured_stdout_and_stderr( + self, pytester: Pytester + ) -> None: + p1 = pytester.makepyfile( """ def test_1(): assert False """ ) - child = testdir.spawn_pytest("--pdb %s" % p1) + child = pytester.spawn_pytest("--pdb %s" % p1) child.expect("Pdb") output = child.before.decode("utf8") child.sendeof() @@ -242,8 +247,8 @@ def test_1(): self.flush(child) @pytest.mark.parametrize("showcapture", ["all", "no", "log"]) - def test_pdb_print_captured_logs(self, testdir, showcapture): - p1 = testdir.makepyfile( + def test_pdb_print_captured_logs(self, pytester, showcapture: str) -> None: + p1 = pytester.makepyfile( """ def test_1(): import logging @@ -251,9 +256,7 @@ def test_1(): assert False """ ) - child = testdir.spawn_pytest( - "--show-capture={} --pdb {}".format(showcapture, p1) - ) + child = pytester.spawn_pytest(f"--show-capture={showcapture} --pdb {p1}") if showcapture in ("all", "log"): child.expect("captured log") child.expect("get rekt") @@ -263,8 +266,8 @@ def test_1(): assert "1 failed" in rest self.flush(child) - def test_pdb_print_captured_logs_nologging(self, testdir): - p1 = testdir.makepyfile( + def test_pdb_print_captured_logs_nologging(self, pytester: Pytester) -> None: + p1 = pytester.makepyfile( """ def test_1(): import logging @@ -272,7 +275,7 @@ def test_1(): assert False """ ) - child = testdir.spawn_pytest("--show-capture=all --pdb -p no:logging %s" % p1) + child = pytester.spawn_pytest("--show-capture=all --pdb -p no:logging %s" % p1) child.expect("get rekt") output = child.before.decode("utf8") assert "captured log" not in output @@ -282,8 +285,8 @@ def test_1(): assert "1 failed" in rest self.flush(child) - def test_pdb_interaction_exception(self, testdir): - p1 = testdir.makepyfile( + def test_pdb_interaction_exception(self, pytester: Pytester) -> None: + p1 = pytester.makepyfile( """ import pytest def globalfunc(): @@ -292,7 +295,7 @@ def test_1(): pytest.raises(ValueError, globalfunc) """ ) - child = testdir.spawn_pytest("--pdb %s" % p1) + child = pytester.spawn_pytest("--pdb %s" % p1) child.expect(".*def test_1") child.expect(".*pytest.raises.*globalfunc") child.expect("Pdb") @@ -302,29 +305,29 @@ def test_1(): child.expect("1 failed") self.flush(child) - def test_pdb_interaction_on_collection_issue181(self, testdir): - p1 = testdir.makepyfile( + def test_pdb_interaction_on_collection_issue181(self, pytester: Pytester) -> None: + p1 = pytester.makepyfile( """ import pytest xxx """ ) - child = testdir.spawn_pytest("--pdb %s" % p1) + child = pytester.spawn_pytest("--pdb %s" % p1) # child.expect(".*import pytest.*") child.expect("Pdb") child.sendline("c") child.expect("1 error") self.flush(child) - def test_pdb_interaction_on_internal_error(self, testdir): - testdir.makeconftest( + def test_pdb_interaction_on_internal_error(self, pytester: Pytester) -> None: + pytester.makeconftest( """ def pytest_runtest_protocol(): 0/0 """ ) - p1 = testdir.makepyfile("def test_func(): pass") - child = testdir.spawn_pytest("--pdb %s" % p1) + p1 = pytester.makepyfile("def test_func(): pass") + child = pytester.spawn_pytest("--pdb %s" % p1) child.expect("Pdb") # INTERNALERROR is only displayed once via terminal reporter. @@ -342,8 +345,24 @@ def pytest_runtest_protocol(): child.sendeof() self.flush(child) - def test_pdb_interaction_capturing_simple(self, testdir): - p1 = testdir.makepyfile( + def test_pdb_prevent_ConftestImportFailure_hiding_exception( + self, pytester: Pytester + ) -> None: + pytester.makepyfile("def test_func(): pass") + sub_dir = pytester.path.joinpath("ns") + sub_dir.mkdir() + sub_dir.joinpath("conftest").with_suffix(".py").write_text( + "import unknown", "utf-8" + ) + sub_dir.joinpath("test_file").with_suffix(".py").write_text( + "def test_func(): pass", "utf-8" + ) + + result = pytester.runpytest_subprocess("--pdb", ".") + result.stdout.fnmatch_lines(["-> import unknown"]) + + def test_pdb_interaction_capturing_simple(self, pytester: Pytester) -> None: + p1 = pytester.makepyfile( """ import pytest def test_1(): @@ -354,7 +373,7 @@ def test_1(): assert 0 """ ) - child = testdir.spawn_pytest(str(p1)) + child = pytester.spawn_pytest(str(p1)) child.expect(r"test_1\(\)") child.expect("i == 1") child.expect("Pdb") @@ -366,8 +385,8 @@ def test_1(): assert "hello17" in rest # out is captured self.flush(child) - def test_pdb_set_trace_kwargs(self, testdir): - p1 = testdir.makepyfile( + def test_pdb_set_trace_kwargs(self, pytester: Pytester) -> None: + p1 = pytester.makepyfile( """ import pytest def test_1(): @@ -378,7 +397,7 @@ def test_1(): assert 0 """ ) - child = testdir.spawn_pytest(str(p1)) + child = pytester.spawn_pytest(str(p1)) child.expect("== my_header ==") assert "PDB set_trace" not in child.before.decode() child.expect("Pdb") @@ -389,15 +408,15 @@ def test_1(): assert "hello17" in rest # out is captured self.flush(child) - def test_pdb_set_trace_interception(self, testdir): - p1 = testdir.makepyfile( + def test_pdb_set_trace_interception(self, pytester: Pytester) -> None: + p1 = pytester.makepyfile( """ import pdb def test_1(): pdb.set_trace() """ ) - child = testdir.spawn_pytest(str(p1)) + child = pytester.spawn_pytest(str(p1)) child.expect("test_1") child.expect("Pdb") child.sendline("q") @@ -407,8 +426,8 @@ def test_1(): assert "BdbQuit" not in rest self.flush(child) - def test_pdb_and_capsys(self, testdir): - p1 = testdir.makepyfile( + def test_pdb_and_capsys(self, pytester: Pytester) -> None: + p1 = pytester.makepyfile( """ import pytest def test_1(capsys): @@ -416,7 +435,7 @@ def test_1(capsys): pytest.set_trace() """ ) - child = testdir.spawn_pytest(str(p1)) + child = pytester.spawn_pytest(str(p1)) child.expect("test_1") child.send("capsys.readouterr()\n") child.expect("hello1") @@ -424,8 +443,8 @@ def test_1(capsys): child.read() self.flush(child) - def test_pdb_with_caplog_on_pdb_invocation(self, testdir): - p1 = testdir.makepyfile( + def test_pdb_with_caplog_on_pdb_invocation(self, pytester: Pytester) -> None: + p1 = pytester.makepyfile( """ def test_1(capsys, caplog): import logging @@ -433,7 +452,7 @@ def test_1(capsys, caplog): assert 0 """ ) - child = testdir.spawn_pytest("--pdb %s" % str(p1)) + child = pytester.spawn_pytest("--pdb %s" % str(p1)) child.send("caplog.record_tuples\n") child.expect_exact( "[('test_pdb_with_caplog_on_pdb_invocation', 30, 'some_warning')]" @@ -442,8 +461,8 @@ def test_1(capsys, caplog): child.read() self.flush(child) - def test_set_trace_capturing_afterwards(self, testdir): - p1 = testdir.makepyfile( + def test_set_trace_capturing_afterwards(self, pytester: Pytester) -> None: + p1 = pytester.makepyfile( """ import pdb def test_1(): @@ -453,7 +472,7 @@ def test_2(): assert 0 """ ) - child = testdir.spawn_pytest(str(p1)) + child = pytester.spawn_pytest(str(p1)) child.expect("test_1") child.send("c\n") child.expect("test_2") @@ -463,8 +482,8 @@ def test_2(): child.read() self.flush(child) - def test_pdb_interaction_doctest(self, testdir): - p1 = testdir.makepyfile( + def test_pdb_interaction_doctest(self, pytester: Pytester) -> None: + p1 = pytester.makepyfile( """ def function_1(): ''' @@ -473,7 +492,7 @@ def function_1(): ''' """ ) - child = testdir.spawn_pytest("--doctest-modules --pdb %s" % p1) + child = pytester.spawn_pytest("--doctest-modules --pdb %s" % p1) child.expect("Pdb") assert "UNEXPECTED EXCEPTION: AssertionError()" in child.before.decode("utf8") @@ -489,8 +508,8 @@ def function_1(): assert "1 failed" in rest self.flush(child) - def test_doctest_set_trace_quit(self, testdir): - p1 = testdir.makepyfile( + def test_doctest_set_trace_quit(self, pytester: Pytester) -> None: + p1 = pytester.makepyfile( """ def function_1(): ''' @@ -500,7 +519,7 @@ def function_1(): ) # NOTE: does not use pytest.set_trace, but Python's patched pdb, # therefore "-s" is required. - child = testdir.spawn_pytest("--doctest-modules --pdb -s %s" % p1) + child = pytester.spawn_pytest("--doctest-modules --pdb -s %s" % p1) child.expect("Pdb") child.sendline("q") rest = child.read().decode("utf8") @@ -510,8 +529,8 @@ def function_1(): assert "BdbQuit" not in rest assert "UNEXPECTED EXCEPTION" not in rest - def test_pdb_interaction_capturing_twice(self, testdir): - p1 = testdir.makepyfile( + def test_pdb_interaction_capturing_twice(self, pytester: Pytester) -> None: + p1 = pytester.makepyfile( """ import pytest def test_1(): @@ -525,7 +544,7 @@ def test_1(): assert 0 """ ) - child = testdir.spawn_pytest(str(p1)) + child = pytester.spawn_pytest(str(p1)) child.expect(r"PDB set_trace \(IO-capturing turned off\)") child.expect("test_1") child.expect("x = 3") @@ -545,11 +564,11 @@ def test_1(): assert "1 failed" in rest self.flush(child) - def test_pdb_with_injected_do_debug(self, testdir): + def test_pdb_with_injected_do_debug(self, pytester: Pytester) -> None: """Simulates pdbpp, which injects Pdb into do_debug, and uses self.__class__ in do_continue. """ - p1 = testdir.makepyfile( + p1 = pytester.makepyfile( mytest=""" import pdb import pytest @@ -591,7 +610,7 @@ def test_1(): pytest.fail("expected_failure") """ ) - child = testdir.spawn_pytest("--pdbcls=mytest:CustomPdb %s" % str(p1)) + child = pytester.spawn_pytest("--pdbcls=mytest:CustomPdb %s" % str(p1)) child.expect(r"PDB set_trace \(IO-capturing turned off\)") child.expect(r"\n\(Pdb") child.sendline("debug foo()") @@ -620,15 +639,15 @@ def test_1(): assert "AssertionError: unexpected_failure" not in rest self.flush(child) - def test_pdb_without_capture(self, testdir): - p1 = testdir.makepyfile( + def test_pdb_without_capture(self, pytester: Pytester) -> None: + p1 = pytester.makepyfile( """ import pytest def test_1(): pytest.set_trace() """ ) - child = testdir.spawn_pytest("-s %s" % p1) + child = pytester.spawn_pytest("-s %s" % p1) child.expect(r">>> PDB set_trace >>>") child.expect("Pdb") child.sendline("c") @@ -637,13 +656,15 @@ def test_1(): self.flush(child) @pytest.mark.parametrize("capture_arg", ("", "-s", "-p no:capture")) - def test_pdb_continue_with_recursive_debug(self, capture_arg, testdir): + def test_pdb_continue_with_recursive_debug( + self, capture_arg, pytester: Pytester + ) -> None: """Full coverage for do_debug without capturing. This is very similar to test_pdb_interaction_continue_recursive in general, but mocks out ``pdb.set_trace`` for providing more coverage. """ - p1 = testdir.makepyfile( + p1 = pytester.makepyfile( """ try: input = raw_input @@ -697,7 +718,7 @@ def do_continue(self, arg): set_trace() """ ) - child = testdir.spawn_pytest("--tb=short {} {}".format(p1, capture_arg)) + child = pytester.spawn_pytest(f"--tb=short {p1} {capture_arg}") child.expect("=== SET_TRACE ===") before = child.before.decode("utf8") if not capture_arg: @@ -727,22 +748,22 @@ def do_continue(self, arg): assert "> PDB continue >" in rest assert "= 1 passed in" in rest - def test_pdb_used_outside_test(self, testdir): - p1 = testdir.makepyfile( + def test_pdb_used_outside_test(self, pytester: Pytester) -> None: + p1 = pytester.makepyfile( """ import pytest pytest.set_trace() x = 5 """ ) - child = testdir.spawn("{} {}".format(sys.executable, p1)) + child = pytester.spawn(f"{sys.executable} {p1}") child.expect("x = 5") child.expect("Pdb") child.sendeof() self.flush(child) - def test_pdb_used_in_generate_tests(self, testdir): - p1 = testdir.makepyfile( + def test_pdb_used_in_generate_tests(self, pytester: Pytester) -> None: + p1 = pytester.makepyfile( """ import pytest def pytest_generate_tests(metafunc): @@ -752,22 +773,24 @@ def test_foo(a): pass """ ) - child = testdir.spawn_pytest(str(p1)) + child = pytester.spawn_pytest(str(p1)) child.expect("x = 5") child.expect("Pdb") child.sendeof() self.flush(child) - def test_pdb_collection_failure_is_shown(self, testdir): - p1 = testdir.makepyfile("xxx") - result = testdir.runpytest_subprocess("--pdb", p1) + def test_pdb_collection_failure_is_shown(self, pytester: Pytester) -> None: + p1 = pytester.makepyfile("xxx") + result = pytester.runpytest_subprocess("--pdb", p1) result.stdout.fnmatch_lines( ["E NameError: *xxx*", "*! *Exit: Quitting debugger !*"] # due to EOF ) @pytest.mark.parametrize("post_mortem", (False, True)) - def test_enter_leave_pdb_hooks_are_called(self, post_mortem, testdir): - testdir.makeconftest( + def test_enter_leave_pdb_hooks_are_called( + self, post_mortem, pytester: Pytester + ) -> None: + pytester.makeconftest( """ mypdb = None @@ -791,7 +814,7 @@ def pytest_leave_pdb(config, pdb): assert mypdb.set_attribute == "bar" """ ) - p1 = testdir.makepyfile( + p1 = pytester.makepyfile( """ import pytest @@ -804,9 +827,9 @@ def test_post_mortem(): """ ) if post_mortem: - child = testdir.spawn_pytest(str(p1) + " --pdb -s -k test_post_mortem") + child = pytester.spawn_pytest(str(p1) + " --pdb -s -k test_post_mortem") else: - child = testdir.spawn_pytest(str(p1) + " -k test_set_trace") + child = pytester.spawn_pytest(str(p1) + " -k test_set_trace") child.expect("enter_pdb_hook") child.sendline("c") if post_mortem: @@ -819,14 +842,18 @@ def test_post_mortem(): assert "1 failed" in rest self.flush(child) - def test_pdb_custom_cls(self, testdir, custom_pdb_calls): - p1 = testdir.makepyfile("""xxx """) - result = testdir.runpytest_inprocess("--pdb", "--pdbcls=_pytest:_CustomPdb", p1) + def test_pdb_custom_cls( + self, pytester: Pytester, custom_pdb_calls: List[str] + ) -> None: + p1 = pytester.makepyfile("""xxx """) + result = pytester.runpytest_inprocess( + "--pdb", "--pdbcls=_pytest:_CustomPdb", p1 + ) result.stdout.fnmatch_lines(["*NameError*xxx*", "*1 error*"]) assert custom_pdb_calls == ["init", "reset", "interaction"] - def test_pdb_custom_cls_invalid(self, testdir): - result = testdir.runpytest_inprocess("--pdbcls=invalid") + def test_pdb_custom_cls_invalid(self, pytester: Pytester) -> None: + result = pytester.runpytest_inprocess("--pdbcls=invalid") result.stderr.fnmatch_lines( [ "*: error: argument --pdbcls: 'invalid' is not in the format 'modname:classname'" @@ -841,14 +868,18 @@ def test_pdb_validate_usepdb_cls(self): assert _validate_usepdb_cls("pdb:DoesNotExist") == ("pdb", "DoesNotExist") - def test_pdb_custom_cls_without_pdb(self, testdir, custom_pdb_calls): - p1 = testdir.makepyfile("""xxx """) - result = testdir.runpytest_inprocess("--pdbcls=_pytest:_CustomPdb", p1) + def test_pdb_custom_cls_without_pdb( + self, pytester: Pytester, custom_pdb_calls: List[str] + ) -> None: + p1 = pytester.makepyfile("""xxx """) + result = pytester.runpytest_inprocess("--pdbcls=_pytest:_CustomPdb", p1) result.stdout.fnmatch_lines(["*NameError*xxx*", "*1 error*"]) assert custom_pdb_calls == [] - def test_pdb_custom_cls_with_set_trace(self, testdir, monkeypatch): - testdir.makepyfile( + def test_pdb_custom_cls_with_set_trace( + self, pytester: Pytester, monkeypatch: MonkeyPatch, + ) -> None: + pytester.makepyfile( custom_pdb=""" class CustomPdb(object): def __init__(self, *args, **kwargs): @@ -861,7 +892,7 @@ def set_trace(*args, **kwargs): print('custom set_trace>') """ ) - p1 = testdir.makepyfile( + p1 = pytester.makepyfile( """ import pytest @@ -869,8 +900,8 @@ def test_foo(): pytest.set_trace(skip=['foo.*']) """ ) - monkeypatch.setenv("PYTHONPATH", str(testdir.tmpdir)) - child = testdir.spawn_pytest("--pdbcls=custom_pdb:CustomPdb %s" % str(p1)) + monkeypatch.setenv("PYTHONPATH", str(pytester.path)) + child = pytester.spawn_pytest("--pdbcls=custom_pdb:CustomPdb %s" % str(p1)) child.expect("__init__") child.expect("custom set_trace>") @@ -878,26 +909,23 @@ def test_foo(): class TestDebuggingBreakpoints: - def test_supports_breakpoint_module_global(self): - """ - Test that supports breakpoint global marks on Python 3.7+ and not on - CPython 3.5, 2.7 - """ + def test_supports_breakpoint_module_global(self) -> None: + """Test that supports breakpoint global marks on Python 3.7+.""" if sys.version_info >= (3, 7): assert SUPPORTS_BREAKPOINT_BUILTIN is True - if sys.version_info.major == 3 and sys.version_info.minor == 5: - assert SUPPORTS_BREAKPOINT_BUILTIN is False @pytest.mark.skipif( not SUPPORTS_BREAKPOINT_BUILTIN, reason="Requires breakpoint() builtin" ) @pytest.mark.parametrize("arg", ["--pdb", ""]) - def test_sys_breakpointhook_configure_and_unconfigure(self, testdir, arg): + def test_sys_breakpointhook_configure_and_unconfigure( + self, pytester: Pytester, arg: str + ) -> None: """ Test that sys.breakpointhook is set to the custom Pdb class once configured, test that hook is reset to system value once pytest has been unconfigured """ - testdir.makeconftest( + pytester.makeconftest( """ import sys from pytest import hookimpl @@ -913,26 +941,26 @@ def test_check(): assert sys.breakpointhook == pytestPDB.set_trace """ ) - testdir.makepyfile( + pytester.makepyfile( """ def test_nothing(): pass """ ) args = (arg,) if arg else () - result = testdir.runpytest_subprocess(*args) + result = pytester.runpytest_subprocess(*args) result.stdout.fnmatch_lines(["*1 passed in *"]) @pytest.mark.skipif( not SUPPORTS_BREAKPOINT_BUILTIN, reason="Requires breakpoint() builtin" ) - def test_pdb_custom_cls(self, testdir, custom_debugger_hook): - p1 = testdir.makepyfile( + def test_pdb_custom_cls(self, pytester: Pytester, custom_debugger_hook) -> None: + p1 = pytester.makepyfile( """ def test_nothing(): breakpoint() """ ) - result = testdir.runpytest_inprocess( + result = pytester.runpytest_inprocess( "--pdb", "--pdbcls=_pytest:_CustomDebugger", p1 ) result.stdout.fnmatch_lines(["*CustomDebugger*", "*1 passed*"]) @@ -942,8 +970,10 @@ def test_nothing(): @pytest.mark.skipif( not SUPPORTS_BREAKPOINT_BUILTIN, reason="Requires breakpoint() builtin" ) - def test_environ_custom_class(self, testdir, custom_debugger_hook, arg): - testdir.makeconftest( + def test_environ_custom_class( + self, pytester: Pytester, custom_debugger_hook, arg: str + ) -> None: + pytester.makeconftest( """ import os import sys @@ -961,13 +991,13 @@ def test_check(): assert sys.breakpointhook is _pytest._CustomDebugger.set_trace """ ) - testdir.makepyfile( + pytester.makepyfile( """ def test_nothing(): pass """ ) args = (arg,) if arg else () - result = testdir.runpytest_subprocess(*args) + result = pytester.runpytest_subprocess(*args) result.stdout.fnmatch_lines(["*1 passed in *"]) @pytest.mark.skipif( @@ -977,14 +1007,14 @@ def test_nothing(): pass not _ENVIRON_PYTHONBREAKPOINT == "", reason="Requires breakpoint() default value", ) - def test_sys_breakpoint_interception(self, testdir): - p1 = testdir.makepyfile( + def test_sys_breakpoint_interception(self, pytester: Pytester) -> None: + p1 = pytester.makepyfile( """ def test_1(): breakpoint() """ ) - child = testdir.spawn_pytest(str(p1)) + child = pytester.spawn_pytest(str(p1)) child.expect("test_1") child.expect("Pdb") child.sendline("quit") @@ -996,8 +1026,8 @@ def test_1(): @pytest.mark.skipif( not SUPPORTS_BREAKPOINT_BUILTIN, reason="Requires breakpoint() builtin" ) - def test_pdb_not_altered(self, testdir): - p1 = testdir.makepyfile( + def test_pdb_not_altered(self, pytester: Pytester) -> None: + p1 = pytester.makepyfile( """ import pdb def test_1(): @@ -1005,7 +1035,7 @@ def test_1(): assert 0 """ ) - child = testdir.spawn_pytest(str(p1)) + child = pytester.spawn_pytest(str(p1)) child.expect("test_1") child.expect("Pdb") child.sendline("c") @@ -1016,8 +1046,8 @@ def test_1(): class TestTraceOption: - def test_trace_sets_breakpoint(self, testdir): - p1 = testdir.makepyfile( + def test_trace_sets_breakpoint(self, pytester: Pytester) -> None: + p1 = pytester.makepyfile( """ def test_1(): assert True @@ -1029,7 +1059,7 @@ def test_3(): pass """ ) - child = testdir.spawn_pytest("--trace " + str(p1)) + child = pytester.spawn_pytest("--trace " + str(p1)) child.expect("test_1") child.expect("Pdb") child.sendline("c") @@ -1047,8 +1077,10 @@ def test_3(): assert "Exit: Quitting debugger" not in child.before.decode("utf8") TestPDB.flush(child) - def test_trace_with_parametrize_handles_shared_fixtureinfo(self, testdir): - p1 = testdir.makepyfile( + def test_trace_with_parametrize_handles_shared_fixtureinfo( + self, pytester: Pytester + ) -> None: + p1 = pytester.makepyfile( """ import pytest @pytest.mark.parametrize('myparam', [1,2]) @@ -1066,7 +1098,7 @@ def test_func_kw(myparam, request, func="func_kw"): assert request.function.__name__ == "test_func_kw" """ ) - child = testdir.spawn_pytest("--trace " + str(p1)) + child = pytester.spawn_pytest("--trace " + str(p1)) for func, argname in [ ("test_1", "myparam"), ("test_func", "func"), @@ -1076,12 +1108,12 @@ def test_func_kw(myparam, request, func="func_kw"): child.expect_exact(func) child.expect_exact("Pdb") child.sendline("args") - child.expect_exact("{} = 1\r\n".format(argname)) + child.expect_exact(f"{argname} = 1\r\n") child.expect_exact("Pdb") child.sendline("c") child.expect_exact("Pdb") child.sendline("args") - child.expect_exact("{} = 2\r\n".format(argname)) + child.expect_exact(f"{argname} = 2\r\n") child.expect_exact("Pdb") child.sendline("c") child.expect_exact("> PDB continue (IO-capturing resumed) >") @@ -1093,16 +1125,16 @@ def test_func_kw(myparam, request, func="func_kw"): TestPDB.flush(child) -def test_trace_after_runpytest(testdir): +def test_trace_after_runpytest(pytester: Pytester) -> None: """Test that debugging's pytest_configure is re-entrant.""" - p1 = testdir.makepyfile( + p1 = pytester.makepyfile( """ from _pytest.debugging import pytestPDB - def test_outer(testdir): + def test_outer(pytester) -> None: assert len(pytestPDB._saved) == 1 - testdir.makepyfile( + pytester.makepyfile( \""" from _pytest.debugging import pytestPDB @@ -1113,20 +1145,20 @@ def test_inner(): \""" ) - result = testdir.runpytest("-s", "-k", "test_inner") + result = pytester.runpytest("-s", "-k", "test_inner") assert result.ret == 0 assert len(pytestPDB._saved) == 1 """ ) - result = testdir.runpytest_subprocess("-s", "-p", "pytester", str(p1)) + result = pytester.runpytest_subprocess("-s", "-p", "pytester", str(p1)) result.stdout.fnmatch_lines(["test_inner_end"]) assert result.ret == 0 -def test_quit_with_swallowed_SystemExit(testdir): +def test_quit_with_swallowed_SystemExit(pytester: Pytester) -> None: """Test that debugging's pytest_configure is re-entrant.""" - p1 = testdir.makepyfile( + p1 = pytester.makepyfile( """ def call_pdb_set_trace(): __import__('pdb').set_trace() @@ -1143,7 +1175,7 @@ def test_2(): pass """ ) - child = testdir.spawn_pytest(str(p1)) + child = pytester.spawn_pytest(str(p1)) child.expect("Pdb") child.sendline("q") child.expect_exact("Exit: Quitting debugger") @@ -1153,9 +1185,9 @@ def test_2(): @pytest.mark.parametrize("fixture", ("capfd", "capsys")) -def test_pdb_suspends_fixture_capturing(testdir, fixture): +def test_pdb_suspends_fixture_capturing(pytester: Pytester, fixture: str) -> None: """Using "-s" with pytest should suspend/resume fixture capturing.""" - p1 = testdir.makepyfile( + p1 = pytester.makepyfile( """ def test_inner({fixture}): import sys @@ -1176,7 +1208,7 @@ def test_inner({fixture}): ) ) - child = testdir.spawn_pytest(str(p1) + " -s") + child = pytester.spawn_pytest(str(p1) + " -s") child.expect("Pdb") before = child.before.decode("utf8") @@ -1201,9 +1233,9 @@ def test_inner({fixture}): assert "> PDB continue (IO-capturing resumed for fixture %s) >" % (fixture) in rest -def test_pdbcls_via_local_module(testdir): +def test_pdbcls_via_local_module(pytester: Pytester) -> None: """It should be imported in pytest_configure or later only.""" - p1 = testdir.makepyfile( + p1 = pytester.makepyfile( """ def test(): print("before_set_trace") @@ -1219,7 +1251,7 @@ def runcall(self, *args, **kwds): print("runcall_called", args, kwds) """, ) - result = testdir.runpytest( + result = pytester.runpytest( str(p1), "--pdbcls=really.invalid:Value", syspathinsert=True ) result.stdout.fnmatch_lines( @@ -1230,24 +1262,24 @@ def runcall(self, *args, **kwds): ) assert result.ret == 1 - result = testdir.runpytest( + result = pytester.runpytest( str(p1), "--pdbcls=mypdb:Wrapped.MyPdb", syspathinsert=True ) assert result.ret == 0 result.stdout.fnmatch_lines(["*set_trace_called*", "* 1 passed in *"]) # Ensure that it also works with --trace. - result = testdir.runpytest( + result = pytester.runpytest( str(p1), "--pdbcls=mypdb:Wrapped.MyPdb", "--trace", syspathinsert=True ) assert result.ret == 0 result.stdout.fnmatch_lines(["*runcall_called*", "* 1 passed in *"]) -def test_raises_bdbquit_with_eoferror(testdir): +def test_raises_bdbquit_with_eoferror(pytester: Pytester) -> None: """It is not guaranteed that DontReadFromInput's read is called.""" - p1 = testdir.makepyfile( + p1 = pytester.makepyfile( """ def input_without_read(*args, **kwargs): raise EOFError() @@ -1258,13 +1290,13 @@ def test(monkeypatch): __import__('pdb').set_trace() """ ) - result = testdir.runpytest(str(p1)) + result = pytester.runpytest(str(p1)) result.stdout.fnmatch_lines(["E *BdbQuit", "*= 1 failed in*"]) assert result.ret == 1 -def test_pdb_wrapper_class_is_reused(testdir): - p1 = testdir.makepyfile( +def test_pdb_wrapper_class_is_reused(pytester: Pytester) -> None: + p1 = pytester.makepyfile( """ def test(): __import__("pdb").set_trace() @@ -1286,7 +1318,7 @@ def set_trace(self, *args): print("set_trace_called", args) """, ) - result = testdir.runpytest(str(p1), "--pdbcls=mypdb:MyPdb", syspathinsert=True) + result = pytester.runpytest(str(p1), "--pdbcls=mypdb:MyPdb", syspathinsert=True) assert result.ret == 0 result.stdout.fnmatch_lines( ["*set_trace_called*", "*set_trace_called*", "* 1 passed in *"] diff --git a/testing/test_doctest.py b/testing/test_doctest.py index c9defec5d5b..6e3880330a9 100644 --- a/testing/test_doctest.py +++ b/testing/test_doctest.py @@ -1,20 +1,25 @@ import inspect import textwrap +from typing import Callable +from typing import Optional + +import py import pytest -from _pytest.compat import MODULE_NOT_FOUND_ERROR from _pytest.doctest import _get_checker from _pytest.doctest import _is_mocked +from _pytest.doctest import _is_setup_py from _pytest.doctest import _patch_unwrap_mock_aware from _pytest.doctest import DoctestItem from _pytest.doctest import DoctestModule from _pytest.doctest import DoctestTextfile +from _pytest.pytester import Pytester class TestDoctests: - def test_collect_testtextfile(self, testdir): - w = testdir.maketxtfile(whatever="") - checkfile = testdir.maketxtfile( + def test_collect_testtextfile(self, pytester: Pytester): + w = pytester.maketxtfile(whatever="") + checkfile = pytester.maketxtfile( test_something=""" alskdjalsdk >>> i = 5 @@ -23,49 +28,53 @@ def test_collect_testtextfile(self, testdir): """ ) - for x in (testdir.tmpdir, checkfile): + for x in (pytester.path, checkfile): # print "checking that %s returns custom items" % (x,) - items, reprec = testdir.inline_genitems(x) + items, reprec = pytester.inline_genitems(x) assert len(items) == 1 assert isinstance(items[0], DoctestItem) assert isinstance(items[0].parent, DoctestTextfile) # Empty file has no items. - items, reprec = testdir.inline_genitems(w) + items, reprec = pytester.inline_genitems(w) assert len(items) == 0 - def test_collect_module_empty(self, testdir): - path = testdir.makepyfile(whatever="#") - for p in (path, testdir.tmpdir): - items, reprec = testdir.inline_genitems(p, "--doctest-modules") + def test_collect_module_empty(self, pytester: Pytester): + path = pytester.makepyfile(whatever="#") + for p in (path, pytester.path): + items, reprec = pytester.inline_genitems(p, "--doctest-modules") assert len(items) == 0 - def test_collect_module_single_modulelevel_doctest(self, testdir): - path = testdir.makepyfile(whatever='""">>> pass"""') - for p in (path, testdir.tmpdir): - items, reprec = testdir.inline_genitems(p, "--doctest-modules") + def test_collect_module_single_modulelevel_doctest(self, pytester: Pytester): + path = pytester.makepyfile(whatever='""">>> pass"""') + for p in (path, pytester.path): + items, reprec = pytester.inline_genitems(p, "--doctest-modules") assert len(items) == 1 assert isinstance(items[0], DoctestItem) assert isinstance(items[0].parent, DoctestModule) - def test_collect_module_two_doctest_one_modulelevel(self, testdir): - path = testdir.makepyfile( + def test_collect_module_two_doctest_one_modulelevel(self, pytester: Pytester): + path = pytester.makepyfile( whatever=""" '>>> x = None' def my_func(): ">>> magic = 42 " """ ) - for p in (path, testdir.tmpdir): - items, reprec = testdir.inline_genitems(p, "--doctest-modules") + for p in (path, pytester.path): + items, reprec = pytester.inline_genitems(p, "--doctest-modules") assert len(items) == 2 assert isinstance(items[0], DoctestItem) assert isinstance(items[1], DoctestItem) assert isinstance(items[0].parent, DoctestModule) assert items[0].parent is items[1].parent - def test_collect_module_two_doctest_no_modulelevel(self, testdir): - path = testdir.makepyfile( - whatever=""" + @pytest.mark.parametrize("filename", ["__init__", "whatever"]) + def test_collect_module_two_doctest_no_modulelevel( + self, pytester: Pytester, filename: str, + ) -> None: + path = pytester.makepyfile( + **{ + filename: """ '# Empty' def my_func(): ">>> magic = 42 " @@ -79,76 +88,75 @@ def another(): # This is another function >>> import os # this one does have a doctest ''' - """ + """, + }, ) - for p in (path, testdir.tmpdir): - items, reprec = testdir.inline_genitems(p, "--doctest-modules") + for p in (path, pytester.path): + items, reprec = pytester.inline_genitems(p, "--doctest-modules") assert len(items) == 2 assert isinstance(items[0], DoctestItem) assert isinstance(items[1], DoctestItem) assert isinstance(items[0].parent, DoctestModule) assert items[0].parent is items[1].parent - def test_simple_doctestfile(self, testdir): - p = testdir.maketxtfile( + def test_simple_doctestfile(self, pytester: Pytester): + p = pytester.maketxtfile( test_doc=""" >>> x = 1 >>> x == 1 False """ ) - reprec = testdir.inline_run(p) + reprec = pytester.inline_run(p) reprec.assertoutcome(failed=1) - def test_new_pattern(self, testdir): - p = testdir.maketxtfile( + def test_new_pattern(self, pytester: Pytester): + p = pytester.maketxtfile( xdoc=""" >>> x = 1 >>> x == 1 False """ ) - reprec = testdir.inline_run(p, "--doctest-glob=x*.txt") + reprec = pytester.inline_run(p, "--doctest-glob=x*.txt") reprec.assertoutcome(failed=1) - def test_multiple_patterns(self, testdir): - """Test support for multiple --doctest-glob arguments (#1255). - """ - testdir.maketxtfile( + def test_multiple_patterns(self, pytester: Pytester): + """Test support for multiple --doctest-glob arguments (#1255).""" + pytester.maketxtfile( xdoc=""" >>> 1 1 """ ) - testdir.makefile( + pytester.makefile( ".foo", test=""" >>> 1 1 """, ) - testdir.maketxtfile( + pytester.maketxtfile( test_normal=""" >>> 1 1 """ ) expected = {"xdoc.txt", "test.foo", "test_normal.txt"} - assert {x.basename for x in testdir.tmpdir.listdir()} == expected + assert {x.name for x in pytester.path.iterdir()} == expected args = ["--doctest-glob=xdoc*.txt", "--doctest-glob=*.foo"] - result = testdir.runpytest(*args) + result = pytester.runpytest(*args) result.stdout.fnmatch_lines(["*test.foo *", "*xdoc.txt *", "*2 passed*"]) - result = testdir.runpytest() + result = pytester.runpytest() result.stdout.fnmatch_lines(["*test_normal.txt *", "*1 passed*"]) @pytest.mark.parametrize( " test_string, encoding", [("foo", "ascii"), ("öäü", "latin1"), ("öäü", "utf-8")], ) - def test_encoding(self, testdir, test_string, encoding): - """Test support for doctest_encoding ini option. - """ - testdir.makeini( + def test_encoding(self, pytester, test_string, encoding): + """Test support for doctest_encoding ini option.""" + pytester.makeini( """ [pytest] doctest_encoding={} @@ -162,21 +170,22 @@ def test_encoding(self, testdir, test_string, encoding): """.format( test_string, repr(test_string) ) - testdir._makefile(".txt", [doctest], {}, encoding=encoding) + fn = pytester.path / "test_encoding.txt" + fn.write_text(doctest, encoding=encoding) - result = testdir.runpytest() + result = pytester.runpytest() result.stdout.fnmatch_lines(["*1 passed*"]) - def test_doctest_unexpected_exception(self, testdir): - testdir.maketxtfile( + def test_doctest_unexpected_exception(self, pytester: Pytester): + pytester.maketxtfile( """ >>> i = 0 >>> 0 / i 2 """ ) - result = testdir.runpytest("--doctest-modules") + result = pytester.runpytest("--doctest-modules") result.stdout.fnmatch_lines( [ "test_doctest_unexpected_exception.txt F *", @@ -196,8 +205,8 @@ def test_doctest_unexpected_exception(self, testdir): consecutive=True, ) - def test_doctest_outcomes(self, testdir): - testdir.maketxtfile( + def test_doctest_outcomes(self, pytester: Pytester): + pytester.maketxtfile( test_skip=""" >>> 1 1 @@ -219,7 +228,7 @@ def test_doctest_outcomes(self, testdir): bar """, ) - result = testdir.runpytest("--doctest-modules") + result = pytester.runpytest("--doctest-modules") result.stdout.fnmatch_lines( [ "collected 3 items", @@ -232,11 +241,11 @@ def test_doctest_outcomes(self, testdir): ] ) - def test_docstring_partial_context_around_error(self, testdir): + def test_docstring_partial_context_around_error(self, pytester: Pytester): """Test that we show some context before the actual line of a failing doctest. """ - testdir.makepyfile( + pytester.makepyfile( ''' def foo(): """ @@ -258,7 +267,7 @@ def foo(): """ ''' ) - result = testdir.runpytest("--doctest-modules") + result = pytester.runpytest("--doctest-modules") result.stdout.fnmatch_lines( [ "*docstring_partial_context_around_error*", @@ -276,11 +285,11 @@ def foo(): result.stdout.no_fnmatch_line("*text-line-2*") result.stdout.no_fnmatch_line("*text-line-after*") - def test_docstring_full_context_around_error(self, testdir): + def test_docstring_full_context_around_error(self, pytester: Pytester): """Test that we show the whole context before the actual line of a failing doctest, provided that the context is up to 10 lines long. """ - testdir.makepyfile( + pytester.makepyfile( ''' def foo(): """ @@ -292,7 +301,7 @@ def foo(): """ ''' ) - result = testdir.runpytest("--doctest-modules") + result = pytester.runpytest("--doctest-modules") result.stdout.fnmatch_lines( [ "*docstring_full_context_around_error*", @@ -306,8 +315,8 @@ def foo(): ] ) - def test_doctest_linedata_missing(self, testdir): - testdir.tmpdir.join("hello.py").write( + def test_doctest_linedata_missing(self, pytester: Pytester): + pytester.path.joinpath("hello.py").write_text( textwrap.dedent( """\ class Fun(object): @@ -320,13 +329,13 @@ def test(self): """ ) ) - result = testdir.runpytest("--doctest-modules") + result = pytester.runpytest("--doctest-modules") result.stdout.fnmatch_lines( ["*hello*", "006*>>> 1/0*", "*UNEXPECTED*ZeroDivision*", "*1 failed*"] ) - def test_doctest_linedata_on_property(self, testdir): - testdir.makepyfile( + def test_doctest_linedata_on_property(self, pytester: Pytester): + pytester.makepyfile( """ class Sample(object): @property @@ -338,7 +347,7 @@ def some_property(self): return 'something' """ ) - result = testdir.runpytest("--doctest-modules") + result = pytester.runpytest("--doctest-modules") result.stdout.fnmatch_lines( [ "*= FAILURES =*", @@ -355,8 +364,8 @@ def some_property(self): ] ) - def test_doctest_no_linedata_on_overriden_property(self, testdir): - testdir.makepyfile( + def test_doctest_no_linedata_on_overriden_property(self, pytester: Pytester): + pytester.makepyfile( """ class Sample(object): @property @@ -369,7 +378,7 @@ def some_property(self): some_property = property(some_property.__get__, None, None, some_property.__doc__) """ ) - result = testdir.runpytest("--doctest-modules") + result = pytester.runpytest("--doctest-modules") result.stdout.fnmatch_lines( [ "*= FAILURES =*", @@ -386,49 +395,49 @@ def some_property(self): ] ) - def test_doctest_unex_importerror_only_txt(self, testdir): - testdir.maketxtfile( + def test_doctest_unex_importerror_only_txt(self, pytester: Pytester): + pytester.maketxtfile( """ >>> import asdalsdkjaslkdjasd >>> """ ) - result = testdir.runpytest() + result = pytester.runpytest() # doctest is never executed because of error during hello.py collection result.stdout.fnmatch_lines( [ "*>>> import asdals*", - "*UNEXPECTED*{e}*".format(e=MODULE_NOT_FOUND_ERROR), - "{e}: No module named *asdal*".format(e=MODULE_NOT_FOUND_ERROR), + "*UNEXPECTED*ModuleNotFoundError*", + "ModuleNotFoundError: No module named *asdal*", ] ) - def test_doctest_unex_importerror_with_module(self, testdir): - testdir.tmpdir.join("hello.py").write( + def test_doctest_unex_importerror_with_module(self, pytester: Pytester): + pytester.path.joinpath("hello.py").write_text( textwrap.dedent( """\ import asdalsdkjaslkdjasd """ ) ) - testdir.maketxtfile( + pytester.maketxtfile( """ >>> import hello >>> """ ) - result = testdir.runpytest("--doctest-modules") + result = pytester.runpytest("--doctest-modules") # doctest is never executed because of error during hello.py collection result.stdout.fnmatch_lines( [ "*ERROR collecting hello.py*", - "*{e}: No module named *asdals*".format(e=MODULE_NOT_FOUND_ERROR), + "*ModuleNotFoundError: No module named *asdals*", "*Interrupted: 1 error during collection*", ] ) - def test_doctestmodule(self, testdir): - p = testdir.makepyfile( + def test_doctestmodule(self, pytester: Pytester): + p = pytester.makepyfile( """ ''' >>> x = 1 @@ -438,12 +447,12 @@ def test_doctestmodule(self, testdir): ''' """ ) - reprec = testdir.inline_run(p, "--doctest-modules") + reprec = pytester.inline_run(p, "--doctest-modules") reprec.assertoutcome(failed=1) - def test_doctestmodule_external_and_issue116(self, testdir): - p = testdir.mkpydir("hello") - p.join("__init__.py").write( + def test_doctestmodule_external_and_issue116(self, pytester: Pytester): + p = pytester.mkpydir("hello") + p.joinpath("__init__.py").write_text( textwrap.dedent( """\ def somefunc(): @@ -455,7 +464,7 @@ def somefunc(): """ ) ) - result = testdir.runpytest(p, "--doctest-modules") + result = pytester.runpytest(p, "--doctest-modules") result.stdout.fnmatch_lines( [ "003 *>>> i = 0", @@ -468,15 +477,15 @@ def somefunc(): ] ) - def test_txtfile_failing(self, testdir): - p = testdir.maketxtfile( + def test_txtfile_failing(self, pytester: Pytester): + p = pytester.maketxtfile( """ >>> i = 0 >>> i + 1 2 """ ) - result = testdir.runpytest(p, "-s") + result = pytester.runpytest(p, "-s") result.stdout.fnmatch_lines( [ "001 >>> i = 0", @@ -489,25 +498,25 @@ def test_txtfile_failing(self, testdir): ] ) - def test_txtfile_with_fixtures(self, testdir): - p = testdir.maketxtfile( + def test_txtfile_with_fixtures(self, pytester: Pytester): + p = pytester.maketxtfile( """ - >>> dir = getfixture('tmpdir') - >>> type(dir).__name__ - 'LocalPath' + >>> p = getfixture('tmp_path') + >>> p.is_dir() + True """ ) - reprec = testdir.inline_run(p) + reprec = pytester.inline_run(p) reprec.assertoutcome(passed=1) - def test_txtfile_with_usefixtures_in_ini(self, testdir): - testdir.makeini( + def test_txtfile_with_usefixtures_in_ini(self, pytester: Pytester): + pytester.makeini( """ [pytest] usefixtures = myfixture """ ) - testdir.makeconftest( + pytester.makeconftest( """ import pytest @pytest.fixture @@ -516,36 +525,36 @@ def myfixture(monkeypatch): """ ) - p = testdir.maketxtfile( + p = pytester.maketxtfile( """ >>> import os >>> os.environ["HELLO"] 'WORLD' """ ) - reprec = testdir.inline_run(p) + reprec = pytester.inline_run(p) reprec.assertoutcome(passed=1) - def test_doctestmodule_with_fixtures(self, testdir): - p = testdir.makepyfile( + def test_doctestmodule_with_fixtures(self, pytester: Pytester): + p = pytester.makepyfile( """ ''' - >>> dir = getfixture('tmpdir') - >>> type(dir).__name__ - 'LocalPath' + >>> p = getfixture('tmp_path') + >>> p.is_dir() + True ''' """ ) - reprec = testdir.inline_run(p, "--doctest-modules") + reprec = pytester.inline_run(p, "--doctest-modules") reprec.assertoutcome(passed=1) - def test_doctestmodule_three_tests(self, testdir): - p = testdir.makepyfile( + def test_doctestmodule_three_tests(self, pytester: Pytester): + p = pytester.makepyfile( """ ''' - >>> dir = getfixture('tmpdir') - >>> type(dir).__name__ - 'LocalPath' + >>> p = getfixture('tmp_path') + >>> p.is_dir() + True ''' def my_func(): ''' @@ -563,11 +572,11 @@ def another(): ''' """ ) - reprec = testdir.inline_run(p, "--doctest-modules") + reprec = pytester.inline_run(p, "--doctest-modules") reprec.assertoutcome(passed=3) - def test_doctestmodule_two_tests_one_fail(self, testdir): - p = testdir.makepyfile( + def test_doctestmodule_two_tests_one_fail(self, pytester: Pytester): + p = pytester.makepyfile( """ class MyClass(object): def bad_meth(self): @@ -584,17 +593,17 @@ def nice_meth(self): ''' """ ) - reprec = testdir.inline_run(p, "--doctest-modules") + reprec = pytester.inline_run(p, "--doctest-modules") reprec.assertoutcome(failed=1, passed=1) - def test_ignored_whitespace(self, testdir): - testdir.makeini( + def test_ignored_whitespace(self, pytester: Pytester): + pytester.makeini( """ [pytest] doctest_optionflags = ELLIPSIS NORMALIZE_WHITESPACE """ ) - p = testdir.makepyfile( + p = pytester.makepyfile( """ class MyClass(object): ''' @@ -605,17 +614,17 @@ class MyClass(object): pass """ ) - reprec = testdir.inline_run(p, "--doctest-modules") + reprec = pytester.inline_run(p, "--doctest-modules") reprec.assertoutcome(passed=1) - def test_non_ignored_whitespace(self, testdir): - testdir.makeini( + def test_non_ignored_whitespace(self, pytester: Pytester): + pytester.makeini( """ [pytest] doctest_optionflags = ELLIPSIS """ ) - p = testdir.makepyfile( + p = pytester.makepyfile( """ class MyClass(object): ''' @@ -626,47 +635,46 @@ class MyClass(object): pass """ ) - reprec = testdir.inline_run(p, "--doctest-modules") + reprec = pytester.inline_run(p, "--doctest-modules") reprec.assertoutcome(failed=1, passed=0) - def test_ignored_whitespace_glob(self, testdir): - testdir.makeini( + def test_ignored_whitespace_glob(self, pytester: Pytester): + pytester.makeini( """ [pytest] doctest_optionflags = ELLIPSIS NORMALIZE_WHITESPACE """ ) - p = testdir.maketxtfile( + p = pytester.maketxtfile( xdoc=""" >>> a = "foo " >>> print(a) foo """ ) - reprec = testdir.inline_run(p, "--doctest-glob=x*.txt") + reprec = pytester.inline_run(p, "--doctest-glob=x*.txt") reprec.assertoutcome(passed=1) - def test_non_ignored_whitespace_glob(self, testdir): - testdir.makeini( + def test_non_ignored_whitespace_glob(self, pytester: Pytester): + pytester.makeini( """ [pytest] doctest_optionflags = ELLIPSIS """ ) - p = testdir.maketxtfile( + p = pytester.maketxtfile( xdoc=""" >>> a = "foo " >>> print(a) foo """ ) - reprec = testdir.inline_run(p, "--doctest-glob=x*.txt") + reprec = pytester.inline_run(p, "--doctest-glob=x*.txt") reprec.assertoutcome(failed=1, passed=0) - def test_contains_unicode(self, testdir): - """Fix internal error with docstrings containing non-ascii characters. - """ - testdir.makepyfile( + def test_contains_unicode(self, pytester: Pytester): + """Fix internal error with docstrings containing non-ascii characters.""" + pytester.makepyfile( '''\ def foo(): """ @@ -675,11 +683,11 @@ def foo(): """ ''' ) - result = testdir.runpytest("--doctest-modules") + result = pytester.runpytest("--doctest-modules") result.stdout.fnmatch_lines(["Got nothing", "* 1 failed in*"]) - def test_ignore_import_errors_on_doctest(self, testdir): - p = testdir.makepyfile( + def test_ignore_import_errors_on_doctest(self, pytester: Pytester): + p = pytester.makepyfile( """ import asdf @@ -692,16 +700,14 @@ def add_one(x): """ ) - reprec = testdir.inline_run( + reprec = pytester.inline_run( p, "--doctest-modules", "--doctest-ignore-import-errors" ) reprec.assertoutcome(skipped=1, failed=1, passed=0) - def test_junit_report_for_doctest(self, testdir): - """ - #713: Fix --junit-xml option when used with --doctest-modules. - """ - p = testdir.makepyfile( + def test_junit_report_for_doctest(self, pytester: Pytester): + """#713: Fix --junit-xml option when used with --doctest-modules.""" + p = pytester.makepyfile( """ def foo(): ''' @@ -711,15 +717,15 @@ def foo(): pass """ ) - reprec = testdir.inline_run(p, "--doctest-modules", "--junit-xml=junit.xml") + reprec = pytester.inline_run(p, "--doctest-modules", "--junit-xml=junit.xml") reprec.assertoutcome(failed=1) - def test_unicode_doctest(self, testdir): + def test_unicode_doctest(self, pytester: Pytester): """ Test case for issue 2434: DecodeError on Python 2 when doctest contains non-ascii characters. """ - p = testdir.maketxtfile( + p = pytester.maketxtfile( test_unicode_doctest=""" .. doctest:: @@ -732,17 +738,17 @@ def test_unicode_doctest(self, testdir): 1 """ ) - result = testdir.runpytest(p) + result = pytester.runpytest(p) result.stdout.fnmatch_lines( ["*UNEXPECTED EXCEPTION: ZeroDivisionError*", "*1 failed*"] ) - def test_unicode_doctest_module(self, testdir): + def test_unicode_doctest_module(self, pytester: Pytester): """ Test case for issue 2434: DecodeError on Python 2 when doctest docstring contains non-ascii characters. """ - p = testdir.makepyfile( + p = pytester.makepyfile( test_unicode_doctest_module=""" def fix_bad_unicode(text): ''' @@ -752,15 +758,15 @@ def fix_bad_unicode(text): return "único" """ ) - result = testdir.runpytest(p, "--doctest-modules") + result = pytester.runpytest(p, "--doctest-modules") result.stdout.fnmatch_lines(["* 1 passed *"]) - def test_print_unicode_value(self, testdir): + def test_print_unicode_value(self, pytester: Pytester): """ Test case for issue 3583: Printing Unicode in doctest under Python 2.7 doesn't work """ - p = testdir.maketxtfile( + p = pytester.maketxtfile( test_print_unicode_value=r""" Here is a doctest:: @@ -768,14 +774,12 @@ def test_print_unicode_value(self, testdir): åéîøü """ ) - result = testdir.runpytest(p) + result = pytester.runpytest(p) result.stdout.fnmatch_lines(["* 1 passed *"]) - def test_reportinfo(self, testdir): - """ - Test case to make sure that DoctestItem.reportinfo() returns lineno. - """ - p = testdir.makepyfile( + def test_reportinfo(self, pytester: Pytester): + """Make sure that DoctestItem.reportinfo() returns lineno.""" + p = pytester.makepyfile( test_reportinfo=""" def foo(x): ''' @@ -785,16 +789,16 @@ def foo(x): return 'c' """ ) - items, reprec = testdir.inline_genitems(p, "--doctest-modules") + items, reprec = pytester.inline_genitems(p, "--doctest-modules") reportinfo = items[0].reportinfo() assert reportinfo[1] == 1 - def test_valid_setup_py(self, testdir): + def test_valid_setup_py(self, pytester: Pytester): """ Test to make sure that pytest ignores valid setup.py files when ran with --doctest-modules """ - p = testdir.makepyfile( + p = pytester.makepyfile( setup=""" from setuptools import setup, find_packages setup(name='sample', @@ -804,33 +808,33 @@ def test_valid_setup_py(self, testdir): ) """ ) - result = testdir.runpytest(p, "--doctest-modules") + result = pytester.runpytest(p, "--doctest-modules") result.stdout.fnmatch_lines(["*collected 0 items*"]) - def test_invalid_setup_py(self, testdir): + def test_invalid_setup_py(self, pytester: Pytester): """ Test to make sure that pytest reads setup.py files that are not used for python packages when ran with --doctest-modules """ - p = testdir.makepyfile( + p = pytester.makepyfile( setup=""" def test_foo(): return 'bar' """ ) - result = testdir.runpytest(p, "--doctest-modules") + result = pytester.runpytest(p, "--doctest-modules") result.stdout.fnmatch_lines(["*collected 1 item*"]) class TestLiterals: @pytest.mark.parametrize("config_mode", ["ini", "comment"]) - def test_allow_unicode(self, testdir, config_mode): + def test_allow_unicode(self, pytester, config_mode): """Test that doctests which output unicode work in all python versions tested by pytest when the ALLOW_UNICODE option is used (either in the ini file or by an inline comment). """ if config_mode == "ini": - testdir.makeini( + pytester.makeini( """ [pytest] doctest_optionflags = ALLOW_UNICODE @@ -840,7 +844,7 @@ def test_allow_unicode(self, testdir, config_mode): else: comment = "#doctest: +ALLOW_UNICODE" - testdir.maketxtfile( + pytester.maketxtfile( test_doc=""" >>> b'12'.decode('ascii') {comment} '12' @@ -848,7 +852,7 @@ def test_allow_unicode(self, testdir, config_mode): comment=comment ) ) - testdir.makepyfile( + pytester.makepyfile( foo=""" def foo(): ''' @@ -859,17 +863,17 @@ def foo(): comment=comment ) ) - reprec = testdir.inline_run("--doctest-modules") + reprec = pytester.inline_run("--doctest-modules") reprec.assertoutcome(passed=2) @pytest.mark.parametrize("config_mode", ["ini", "comment"]) - def test_allow_bytes(self, testdir, config_mode): + def test_allow_bytes(self, pytester, config_mode): """Test that doctests which output bytes work in all python versions tested by pytest when the ALLOW_BYTES option is used (either in the ini file or by an inline comment)(#1287). """ if config_mode == "ini": - testdir.makeini( + pytester.makeini( """ [pytest] doctest_optionflags = ALLOW_BYTES @@ -879,7 +883,7 @@ def test_allow_bytes(self, testdir, config_mode): else: comment = "#doctest: +ALLOW_BYTES" - testdir.maketxtfile( + pytester.maketxtfile( test_doc=""" >>> b'foo' {comment} 'foo' @@ -887,7 +891,7 @@ def test_allow_bytes(self, testdir, config_mode): comment=comment ) ) - testdir.makepyfile( + pytester.makepyfile( foo=""" def foo(): ''' @@ -898,34 +902,34 @@ def foo(): comment=comment ) ) - reprec = testdir.inline_run("--doctest-modules") + reprec = pytester.inline_run("--doctest-modules") reprec.assertoutcome(passed=2) - def test_unicode_string(self, testdir): + def test_unicode_string(self, pytester: Pytester): """Test that doctests which output unicode fail in Python 2 when the ALLOW_UNICODE option is not used. The same test should pass in Python 3. """ - testdir.maketxtfile( + pytester.maketxtfile( test_doc=""" >>> b'12'.decode('ascii') '12' """ ) - reprec = testdir.inline_run() + reprec = pytester.inline_run() reprec.assertoutcome(passed=1) - def test_bytes_literal(self, testdir): + def test_bytes_literal(self, pytester: Pytester): """Test that doctests which output bytes fail in Python 3 when the ALLOW_BYTES option is not used. (#1287). """ - testdir.maketxtfile( + pytester.maketxtfile( test_doc=""" >>> b'foo' 'foo' """ ) - reprec = testdir.inline_run() + reprec = pytester.inline_run() reprec.assertoutcome(failed=1) def test_number_re(self) -> None: @@ -959,10 +963,10 @@ def test_number_re(self) -> None: assert _number_re.match(s) is None @pytest.mark.parametrize("config_mode", ["ini", "comment"]) - def test_number_precision(self, testdir, config_mode): + def test_number_precision(self, pytester, config_mode): """Test the NUMBER option.""" if config_mode == "ini": - testdir.makeini( + pytester.makeini( """ [pytest] doctest_optionflags = NUMBER @@ -972,7 +976,7 @@ def test_number_precision(self, testdir, config_mode): else: comment = "#doctest: +NUMBER" - testdir.maketxtfile( + pytester.maketxtfile( test_doc=""" Scalars: @@ -1029,7 +1033,7 @@ def test_number_precision(self, testdir, config_mode): comment=comment ) ) - reprec = testdir.inline_run() + reprec = pytester.inline_run() reprec.assertoutcome(passed=1) @pytest.mark.parametrize( @@ -1053,8 +1057,8 @@ def test_number_precision(self, testdir, config_mode): pytest.param("'3.1416'", "'3.14'", marks=pytest.mark.xfail), ], ) - def test_number_non_matches(self, testdir, expression, output): - testdir.maketxtfile( + def test_number_non_matches(self, pytester, expression, output): + pytester.maketxtfile( test_doc=""" >>> {expression} #doctest: +NUMBER {output} @@ -1062,11 +1066,11 @@ def test_number_non_matches(self, testdir, expression, output): expression=expression, output=output ) ) - reprec = testdir.inline_run() + reprec = pytester.inline_run() reprec.assertoutcome(passed=0, failed=1) - def test_number_and_allow_unicode(self, testdir): - testdir.maketxtfile( + def test_number_and_allow_unicode(self, pytester: Pytester): + pytester.maketxtfile( test_doc=""" >>> from collections import namedtuple >>> T = namedtuple('T', 'a b c') @@ -1074,7 +1078,7 @@ def test_number_and_allow_unicode(self, testdir): T(a=0.233, b=u'str', c='bytes') """ ) - reprec = testdir.inline_run() + reprec = pytester.inline_run() reprec.assertoutcome(passed=1) @@ -1085,18 +1089,18 @@ class TestDoctestSkips: """ @pytest.fixture(params=["text", "module"]) - def makedoctest(self, testdir, request): + def makedoctest(self, pytester, request): def makeit(doctest): mode = request.param if mode == "text": - testdir.maketxtfile(doctest) + pytester.maketxtfile(doctest) else: assert mode == "module" - testdir.makepyfile('"""\n%s"""' % doctest) + pytester.makepyfile('"""\n%s"""' % doctest) return makeit - def test_one_skipped(self, testdir, makedoctest): + def test_one_skipped(self, pytester, makedoctest): makedoctest( """ >>> 1 + 1 # doctest: +SKIP @@ -1105,10 +1109,10 @@ def test_one_skipped(self, testdir, makedoctest): 4 """ ) - reprec = testdir.inline_run("--doctest-modules") + reprec = pytester.inline_run("--doctest-modules") reprec.assertoutcome(passed=1) - def test_one_skipped_failed(self, testdir, makedoctest): + def test_one_skipped_failed(self, pytester, makedoctest): makedoctest( """ >>> 1 + 1 # doctest: +SKIP @@ -1117,10 +1121,10 @@ def test_one_skipped_failed(self, testdir, makedoctest): 200 """ ) - reprec = testdir.inline_run("--doctest-modules") + reprec = pytester.inline_run("--doctest-modules") reprec.assertoutcome(failed=1) - def test_all_skipped(self, testdir, makedoctest): + def test_all_skipped(self, pytester, makedoctest): makedoctest( """ >>> 1 + 1 # doctest: +SKIP @@ -1129,16 +1133,16 @@ def test_all_skipped(self, testdir, makedoctest): 200 """ ) - reprec = testdir.inline_run("--doctest-modules") + reprec = pytester.inline_run("--doctest-modules") reprec.assertoutcome(skipped=1) - def test_vacuous_all_skipped(self, testdir, makedoctest): + def test_vacuous_all_skipped(self, pytester, makedoctest): makedoctest("") - reprec = testdir.inline_run("--doctest-modules") + reprec = pytester.inline_run("--doctest-modules") reprec.assertoutcome(passed=0, skipped=0) - def test_continue_on_failure(self, testdir): - testdir.maketxtfile( + def test_continue_on_failure(self, pytester: Pytester): + pytester.maketxtfile( test_something=""" >>> i = 5 >>> def foo(): @@ -1150,7 +1154,9 @@ def test_continue_on_failure(self, testdir): >>> i + 1 """ ) - result = testdir.runpytest("--doctest-modules", "--doctest-continue-on-failure") + result = pytester.runpytest( + "--doctest-modules", "--doctest-continue-on-failure" + ) result.assert_outcomes(passed=0, failed=1) # The lines that contains the failure are 4, 5, and 8. The first one # is a stack trace and the other two are mismatches. @@ -1163,17 +1169,16 @@ class TestDoctestAutoUseFixtures: SCOPES = ["module", "session", "class", "function"] - def test_doctest_module_session_fixture(self, testdir): - """Test that session fixtures are initialized for doctest modules (#768) - """ + def test_doctest_module_session_fixture(self, pytester: Pytester): + """Test that session fixtures are initialized for doctest modules (#768).""" # session fixture which changes some global data, which will # be accessed by doctests in a module - testdir.makeconftest( + pytester.makeconftest( """ import pytest import sys - @pytest.yield_fixture(autouse=True, scope='session') + @pytest.fixture(autouse=True, scope='session') def myfixture(): assert not hasattr(sys, 'pytest_session_data') sys.pytest_session_data = 1 @@ -1181,7 +1186,7 @@ def myfixture(): del sys.pytest_session_data """ ) - testdir.makepyfile( + pytester.makepyfile( foo=""" import sys @@ -1196,16 +1201,16 @@ def bar(): ''' """ ) - result = testdir.runpytest("--doctest-modules") + result = pytester.runpytest("--doctest-modules") result.stdout.fnmatch_lines(["*2 passed*"]) @pytest.mark.parametrize("scope", SCOPES) @pytest.mark.parametrize("enable_doctest", [True, False]) - def test_fixture_scopes(self, testdir, scope, enable_doctest): + def test_fixture_scopes(self, pytester, scope, enable_doctest): """Test that auto-use fixtures work properly with doctest modules. See #1057 and #1100. """ - testdir.makeconftest( + pytester.makeconftest( """ import pytest @@ -1216,7 +1221,7 @@ def auto(request): scope=scope ) ) - testdir.makepyfile( + pytester.makepyfile( test_1=''' def test_foo(): """ @@ -1229,19 +1234,19 @@ def test_bar(): ) params = ("--doctest-modules",) if enable_doctest else () passes = 3 if enable_doctest else 2 - result = testdir.runpytest(*params) + result = pytester.runpytest(*params) result.stdout.fnmatch_lines(["*=== %d passed in *" % passes]) @pytest.mark.parametrize("scope", SCOPES) @pytest.mark.parametrize("autouse", [True, False]) @pytest.mark.parametrize("use_fixture_in_doctest", [True, False]) def test_fixture_module_doctest_scopes( - self, testdir, scope, autouse, use_fixture_in_doctest + self, pytester, scope, autouse, use_fixture_in_doctest ): """Test that auto-use fixtures work properly with doctest files. See #1057 and #1100. """ - testdir.makeconftest( + pytester.makeconftest( """ import pytest @@ -1253,29 +1258,29 @@ def auto(request): ) ) if use_fixture_in_doctest: - testdir.maketxtfile( + pytester.maketxtfile( test_doc=""" >>> getfixture('auto') 99 """ ) else: - testdir.maketxtfile( + pytester.maketxtfile( test_doc=""" >>> 1 + 1 2 """ ) - result = testdir.runpytest("--doctest-modules") + result = pytester.runpytest("--doctest-modules") result.stdout.no_fnmatch_line("*FAILURES*") result.stdout.fnmatch_lines(["*=== 1 passed in *"]) @pytest.mark.parametrize("scope", SCOPES) - def test_auto_use_request_attributes(self, testdir, scope): + def test_auto_use_request_attributes(self, pytester, scope): """Check that all attributes of a request in an autouse fixture behave as expected when requested for a doctest item. """ - testdir.makeconftest( + pytester.makeconftest( """ import pytest @@ -1292,13 +1297,13 @@ def auto(request): scope=scope ) ) - testdir.maketxtfile( + pytester.maketxtfile( test_doc=""" >>> 1 + 1 2 """ ) - result = testdir.runpytest("--doctest-modules") + result = pytester.runpytest("--doctest-modules") str(result.stdout.no_fnmatch_line("*FAILURES*")) result.stdout.fnmatch_lines(["*=== 1 passed in *"]) @@ -1308,12 +1313,12 @@ class TestDoctestNamespaceFixture: SCOPES = ["module", "session", "class", "function"] @pytest.mark.parametrize("scope", SCOPES) - def test_namespace_doctestfile(self, testdir, scope): + def test_namespace_doctestfile(self, pytester, scope): """ Check that inserting something into the namespace works in a simple text file doctest """ - testdir.makeconftest( + pytester.makeconftest( """ import pytest import contextlib @@ -1325,22 +1330,22 @@ def add_contextlib(doctest_namespace): scope=scope ) ) - p = testdir.maketxtfile( + p = pytester.maketxtfile( """ >>> print(cl.__name__) contextlib """ ) - reprec = testdir.inline_run(p) + reprec = pytester.inline_run(p) reprec.assertoutcome(passed=1) @pytest.mark.parametrize("scope", SCOPES) - def test_namespace_pyfile(self, testdir, scope): + def test_namespace_pyfile(self, pytester, scope): """ Check that inserting something into the namespace works in a simple Python file docstring doctest """ - testdir.makeconftest( + pytester.makeconftest( """ import pytest import contextlib @@ -1352,7 +1357,7 @@ def add_contextlib(doctest_namespace): scope=scope ) ) - p = testdir.makepyfile( + p = pytester.makepyfile( """ def foo(): ''' @@ -1361,13 +1366,13 @@ def foo(): ''' """ ) - reprec = testdir.inline_run(p, "--doctest-modules") + reprec = pytester.inline_run(p, "--doctest-modules") reprec.assertoutcome(passed=1) class TestDoctestReportingOption: - def _run_doctest_report(self, testdir, format): - testdir.makepyfile( + def _run_doctest_report(self, pytester, format): + pytester.makepyfile( """ def foo(): ''' @@ -1383,17 +1388,17 @@ def foo(): '2 3 6') """ ) - return testdir.runpytest("--doctest-modules", "--doctest-report", format) + return pytester.runpytest("--doctest-modules", "--doctest-report", format) @pytest.mark.parametrize("format", ["udiff", "UDIFF", "uDiFf"]) - def test_doctest_report_udiff(self, testdir, format): - result = self._run_doctest_report(testdir, format) + def test_doctest_report_udiff(self, pytester, format): + result = self._run_doctest_report(pytester, format) result.stdout.fnmatch_lines( [" 0 1 4", " -1 2 4", " +1 2 5", " 2 3 6"] ) - def test_doctest_report_cdiff(self, testdir): - result = self._run_doctest_report(testdir, "cdiff") + def test_doctest_report_cdiff(self, pytester: Pytester): + result = self._run_doctest_report(pytester, "cdiff") result.stdout.fnmatch_lines( [ " a b", @@ -1408,8 +1413,8 @@ def test_doctest_report_cdiff(self, testdir): ] ) - def test_doctest_report_ndiff(self, testdir): - result = self._run_doctest_report(testdir, "ndiff") + def test_doctest_report_ndiff(self, pytester: Pytester): + result = self._run_doctest_report(pytester, "ndiff") result.stdout.fnmatch_lines( [ " a b", @@ -1423,8 +1428,8 @@ def test_doctest_report_ndiff(self, testdir): ) @pytest.mark.parametrize("format", ["none", "only_first_failure"]) - def test_doctest_report_none_or_only_first_failure(self, testdir, format): - result = self._run_doctest_report(testdir, format) + def test_doctest_report_none_or_only_first_failure(self, pytester, format): + result = self._run_doctest_report(pytester, format) result.stdout.fnmatch_lines( [ "Expected:", @@ -1440,8 +1445,8 @@ def test_doctest_report_none_or_only_first_failure(self, testdir, format): ] ) - def test_doctest_report_invalid(self, testdir): - result = self._run_doctest_report(testdir, "obviously_invalid_format") + def test_doctest_report_invalid(self, pytester: Pytester): + result = self._run_doctest_report(pytester, "obviously_invalid_format") result.stderr.fnmatch_lines( [ "*error: argument --doctest-report: invalid choice: 'obviously_invalid_format' (choose from*" @@ -1450,9 +1455,9 @@ def test_doctest_report_invalid(self, testdir): @pytest.mark.parametrize("mock_module", ["mock", "unittest.mock"]) -def test_doctest_mock_objects_dont_recurse_missbehaved(mock_module, testdir): +def test_doctest_mock_objects_dont_recurse_missbehaved(mock_module, pytester: Pytester): pytest.importorskip(mock_module) - testdir.makepyfile( + pytester.makepyfile( """ from {mock_module} import call class Example(object): @@ -1464,7 +1469,7 @@ class Example(object): mock_module=mock_module ) ) - result = testdir.runpytest("--doctest-modules") + result = pytester.runpytest("--doctest-modules") result.stdout.fnmatch_lines(["* 1 passed *"]) @@ -1476,7 +1481,9 @@ def __getattr__(self, _): @pytest.mark.parametrize( # pragma: no branch (lambdas are not called) "stop", [None, _is_mocked, lambda f: None, lambda f: False, lambda f: True] ) -def test_warning_on_unwrap_of_broken_object(stop): +def test_warning_on_unwrap_of_broken_object( + stop: Optional[Callable[[object], object]] +) -> None: bad_instance = Broken() assert inspect.unwrap.__module__ == "inspect" with _patch_unwrap_mock_aware(): @@ -1485,5 +1492,29 @@ def test_warning_on_unwrap_of_broken_object(stop): pytest.PytestWarning, match="^Got KeyError.* when unwrapping" ): with pytest.raises(KeyError): - inspect.unwrap(bad_instance, stop=stop) + inspect.unwrap(bad_instance, stop=stop) # type: ignore[arg-type] assert inspect.unwrap.__module__ == "inspect" + + +def test_is_setup_py_not_named_setup_py(tmp_path): + not_setup_py = tmp_path.joinpath("not_setup.py") + not_setup_py.write_text('from setuptools import setup; setup(name="foo")') + assert not _is_setup_py(py.path.local(str(not_setup_py))) + + +@pytest.mark.parametrize("mod", ("setuptools", "distutils.core")) +def test_is_setup_py_is_a_setup_py(tmpdir, mod): + setup_py = tmpdir.join("setup.py") + setup_py.write(f'from {mod} import setup; setup(name="foo")') + assert _is_setup_py(setup_py) + + +@pytest.mark.parametrize("mod", ("setuptools", "distutils.core")) +def test_is_setup_py_different_encoding(tmp_path, mod): + setup_py = tmp_path.joinpath("setup.py") + contents = ( + "# -*- coding: cp1252 -*-\n" + 'from {} import setup; setup(name="foo", description="€")\n'.format(mod) + ) + setup_py.write_bytes(contents.encode("cp1252")) + assert _is_setup_py(py.path.local(str(setup_py))) diff --git a/testing/test_error_diffs.py b/testing/test_error_diffs.py index c7198bde00e..1668e929ab4 100644 --- a/testing/test_error_diffs.py +++ b/testing/test_error_diffs.py @@ -7,6 +7,7 @@ import sys import pytest +from _pytest.pytester import Pytester TESTCASES = [ @@ -233,7 +234,11 @@ def test_this(): E Matching attributes: E ['b'] E Differing attributes: - E a: 1 != 2 + E ['a'] + E Drill down into differing attribute a: + E a: 1 != 2 + E +1 + E -2 """, id="Compare data classes", ), @@ -257,7 +262,11 @@ def test_this(): E Matching attributes: E ['a'] E Differing attributes: - E b: 'spam' != 'eggs' + E ['b'] + E Drill down into differing attribute b: + E b: 'spam' != 'eggs' + E - eggs + E + spam """, id="Compare attrs classes", ), @@ -266,9 +275,9 @@ def test_this(): @pytest.mark.parametrize("code, expected", TESTCASES) -def test_error_diff(code, expected, testdir): - expected = [l.lstrip() for l in expected.splitlines()] - p = testdir.makepyfile(code) - result = testdir.runpytest(p, "-vv") - result.stdout.fnmatch_lines(expected) +def test_error_diff(code: str, expected: str, pytester: Pytester) -> None: + expected_lines = [line.lstrip() for line in expected.splitlines()] + p = pytester.makepyfile(code) + result = pytester.runpytest(p, "-vv") + result.stdout.fnmatch_lines(expected_lines) assert result.ret == 1 diff --git a/testing/test_faulthandler.py b/testing/test_faulthandler.py index 7580f6f2fc1..caf39813cf4 100644 --- a/testing/test_faulthandler.py +++ b/testing/test_faulthandler.py @@ -1,27 +1,27 @@ import sys import pytest +from _pytest.pytester import Pytester -def test_enabled(testdir): +def test_enabled(pytester: Pytester) -> None: """Test single crashing test displays a traceback.""" - testdir.makepyfile( + pytester.makepyfile( """ import faulthandler def test_crash(): faulthandler._sigabrt() """ ) - result = testdir.runpytest_subprocess() + result = pytester.runpytest_subprocess() result.stderr.fnmatch_lines(["*Fatal Python error*"]) assert result.ret != 0 -def test_crash_near_exit(testdir): +def test_crash_near_exit(pytester: Pytester) -> None: """Test that fault handler displays crashes that happen even after - pytest is exiting (for example, when the interpreter is shutting down). - """ - testdir.makepyfile( + pytest is exiting (for example, when the interpreter is shutting down).""" + pytester.makepyfile( """ import faulthandler import atexit @@ -29,39 +29,47 @@ def test_ok(): atexit.register(faulthandler._sigabrt) """ ) - result = testdir.runpytest_subprocess() + result = pytester.runpytest_subprocess() result.stderr.fnmatch_lines(["*Fatal Python error*"]) assert result.ret != 0 -def test_disabled(testdir): - """Test option to disable fault handler in the command line. - """ - testdir.makepyfile( +def test_disabled(pytester: Pytester) -> None: + """Test option to disable fault handler in the command line.""" + pytester.makepyfile( """ import faulthandler def test_disabled(): assert not faulthandler.is_enabled() """ ) - result = testdir.runpytest_subprocess("-p", "no:faulthandler") + result = pytester.runpytest_subprocess("-p", "no:faulthandler") result.stdout.fnmatch_lines(["*1 passed*"]) assert result.ret == 0 -@pytest.mark.parametrize("enabled", [True, False]) -def test_timeout(testdir, enabled): +@pytest.mark.parametrize( + "enabled", + [ + pytest.param( + True, marks=pytest.mark.skip(reason="sometimes crashes on CI (#7022)") + ), + False, + ], +) +def test_timeout(pytester: Pytester, enabled: bool) -> None: """Test option to dump tracebacks after a certain timeout. + If faulthandler is disabled, no traceback will be dumped. """ - testdir.makepyfile( + pytester.makepyfile( """ import os, time def test_timeout(): time.sleep(1 if "CI" in os.environ else 0.1) """ ) - testdir.makeini( + pytester.makeini( """ [pytest] faulthandler_timeout = 0.01 @@ -69,10 +77,8 @@ def test_timeout(): ) args = ["-p", "no:faulthandler"] if not enabled else [] - result = testdir.runpytest_subprocess(*args) + result = pytester.runpytest_subprocess(*args) tb_output = "most recent call first" - if sys.version_info[:2] == (3, 3): - tb_output = "Thread" if enabled: result.stderr.fnmatch_lines(["*%s*" % tb_output]) else: @@ -82,11 +88,10 @@ def test_timeout(): @pytest.mark.parametrize("hook_name", ["pytest_enter_pdb", "pytest_exception_interact"]) -def test_cancel_timeout_on_hook(monkeypatch, hook_name): +def test_cancel_timeout_on_hook(monkeypatch, hook_name) -> None: """Make sure that we are cancelling any scheduled traceback dumping due - to timeout before entering pdb (pytest-dev/pytest-faulthandler#12) or any other interactive - exception (pytest-dev/pytest-faulthandler#14). - """ + to timeout before entering pdb (pytest-dev/pytest-faulthandler#12) or any + other interactive exception (pytest-dev/pytest-faulthandler#14).""" import faulthandler from _pytest.faulthandler import FaultHandlerHooks @@ -104,23 +109,23 @@ def test_cancel_timeout_on_hook(monkeypatch, hook_name): @pytest.mark.parametrize("faulthandler_timeout", [0, 2]) -def test_already_initialized(faulthandler_timeout, testdir): - """Test for faulthandler being initialized earlier than pytest (#6575)""" - testdir.makepyfile( +def test_already_initialized(faulthandler_timeout: int, pytester: Pytester) -> None: + """Test for faulthandler being initialized earlier than pytest (#6575).""" + pytester.makepyfile( """ def test(): import faulthandler assert faulthandler.is_enabled() """ ) - result = testdir.run( + result = pytester.run( sys.executable, "-X", "faulthandler", "-mpytest", - testdir.tmpdir, + pytester.path, "-o", - "faulthandler_timeout={}".format(faulthandler_timeout), + f"faulthandler_timeout={faulthandler_timeout}", ) # ensure warning is emitted if faulthandler_timeout is configured warning_line = "*faulthandler.py*faulthandler module enabled before*" diff --git a/testing/test_findpaths.py b/testing/test_findpaths.py new file mode 100644 index 00000000000..af6aeb3a56d --- /dev/null +++ b/testing/test_findpaths.py @@ -0,0 +1,125 @@ +from pathlib import Path +from textwrap import dedent + +import pytest +from _pytest.config.findpaths import get_common_ancestor +from _pytest.config.findpaths import get_dirs_from_args +from _pytest.config.findpaths import load_config_dict_from_file + + +class TestLoadConfigDictFromFile: + def test_empty_pytest_ini(self, tmp_path: Path) -> None: + """pytest.ini files are always considered for configuration, even if empty""" + fn = tmp_path / "pytest.ini" + fn.write_text("", encoding="utf-8") + assert load_config_dict_from_file(fn) == {} + + def test_pytest_ini(self, tmp_path: Path) -> None: + """[pytest] section in pytest.ini files is read correctly""" + fn = tmp_path / "pytest.ini" + fn.write_text("[pytest]\nx=1", encoding="utf-8") + assert load_config_dict_from_file(fn) == {"x": "1"} + + def test_custom_ini(self, tmp_path: Path) -> None: + """[pytest] section in any .ini file is read correctly""" + fn = tmp_path / "custom.ini" + fn.write_text("[pytest]\nx=1", encoding="utf-8") + assert load_config_dict_from_file(fn) == {"x": "1"} + + def test_custom_ini_without_section(self, tmp_path: Path) -> None: + """Custom .ini files without [pytest] section are not considered for configuration""" + fn = tmp_path / "custom.ini" + fn.write_text("[custom]", encoding="utf-8") + assert load_config_dict_from_file(fn) is None + + def test_custom_cfg_file(self, tmp_path: Path) -> None: + """Custom .cfg files without [tool:pytest] section are not considered for configuration""" + fn = tmp_path / "custom.cfg" + fn.write_text("[custom]", encoding="utf-8") + assert load_config_dict_from_file(fn) is None + + def test_valid_cfg_file(self, tmp_path: Path) -> None: + """Custom .cfg files with [tool:pytest] section are read correctly""" + fn = tmp_path / "custom.cfg" + fn.write_text("[tool:pytest]\nx=1", encoding="utf-8") + assert load_config_dict_from_file(fn) == {"x": "1"} + + def test_unsupported_pytest_section_in_cfg_file(self, tmp_path: Path) -> None: + """.cfg files with [pytest] section are no longer supported and should fail to alert users""" + fn = tmp_path / "custom.cfg" + fn.write_text("[pytest]", encoding="utf-8") + with pytest.raises(pytest.fail.Exception): + load_config_dict_from_file(fn) + + def test_invalid_toml_file(self, tmp_path: Path) -> None: + """.toml files without [tool.pytest.ini_options] are not considered for configuration.""" + fn = tmp_path / "myconfig.toml" + fn.write_text( + dedent( + """ + [build_system] + x = 1 + """ + ), + encoding="utf-8", + ) + assert load_config_dict_from_file(fn) is None + + def test_valid_toml_file(self, tmp_path: Path) -> None: + """.toml files with [tool.pytest.ini_options] are read correctly, including changing + data types to str/list for compatibility with other configuration options.""" + fn = tmp_path / "myconfig.toml" + fn.write_text( + dedent( + """ + [tool.pytest.ini_options] + x = 1 + y = 20.0 + values = ["tests", "integration"] + name = "foo" + """ + ), + encoding="utf-8", + ) + assert load_config_dict_from_file(fn) == { + "x": "1", + "y": "20.0", + "values": ["tests", "integration"], + "name": "foo", + } + + +class TestCommonAncestor: + def test_has_ancestor(self, tmp_path: Path) -> None: + fn1 = tmp_path / "foo" / "bar" / "test_1.py" + fn1.parent.mkdir(parents=True) + fn1.touch() + fn2 = tmp_path / "foo" / "zaz" / "test_2.py" + fn2.parent.mkdir(parents=True) + fn2.touch() + assert get_common_ancestor([fn1, fn2]) == tmp_path / "foo" + assert get_common_ancestor([fn1.parent, fn2]) == tmp_path / "foo" + assert get_common_ancestor([fn1.parent, fn2.parent]) == tmp_path / "foo" + assert get_common_ancestor([fn1, fn2.parent]) == tmp_path / "foo" + + def test_single_dir(self, tmp_path: Path) -> None: + assert get_common_ancestor([tmp_path]) == tmp_path + + def test_single_file(self, tmp_path: Path) -> None: + fn = tmp_path / "foo.py" + fn.touch() + assert get_common_ancestor([fn]) == tmp_path + + +def test_get_dirs_from_args(tmp_path): + """get_dirs_from_args() skips over non-existing directories and files""" + fn = tmp_path / "foo.py" + fn.touch() + d = tmp_path / "tests" + d.mkdir() + option = "--foobar=/foo.txt" + # xdist uses options in this format for its rsync feature (#7638) + xdist_rsync_option = "popen=c:/dest" + assert get_dirs_from_args( + [str(fn), str(tmp_path / "does_not_exist"), str(d), option, xdist_rsync_option] + ) == [fn.parent, d] diff --git a/testing/test_helpconfig.py b/testing/test_helpconfig.py index aaf9a2e2807..c2533ef304a 100644 --- a/testing/test_helpconfig.py +++ b/testing/test_helpconfig.py @@ -1,25 +1,34 @@ import pytest from _pytest.config import ExitCode +from _pytest.pytester import Pytester -def test_version(testdir, pytestconfig): - testdir.monkeypatch.delenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD") - result = testdir.runpytest("--version") +def test_version_verbose(pytester: Pytester, pytestconfig, monkeypatch) -> None: + monkeypatch.delenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD") + result = pytester.runpytest("--version", "--version") assert result.ret == 0 - # p = py.path.local(py.__file__).dirpath() - result.stderr.fnmatch_lines( - ["*pytest*{}*imported from*".format(pytest.__version__)] - ) + result.stderr.fnmatch_lines([f"*pytest*{pytest.__version__}*imported from*"]) if pytestconfig.pluginmanager.list_plugin_distinfo(): result.stderr.fnmatch_lines(["*setuptools registered plugins:", "*at*"]) -def test_help(testdir): - result = testdir.runpytest("--help") +def test_version_less_verbose(pytester: Pytester, pytestconfig, monkeypatch) -> None: + monkeypatch.delenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD") + result = pytester.runpytest("--version") + assert result.ret == 0 + # p = py.path.local(py.__file__).dirpath() + result.stderr.fnmatch_lines([f"pytest {pytest.__version__}"]) + + +def test_help(pytester: Pytester) -> None: + result = pytester.runpytest("--help") assert result.ret == 0 result.stdout.fnmatch_lines( """ - *-v*verbose* + -m MARKEXPR only run tests matching given mark expression. + For example: -m 'mark1 and not mark2'. + reporting: + --durations=N * *setup.cfg* *minversion* *to see*markers*pytest --markers* @@ -28,20 +37,53 @@ def test_help(testdir): ) -def test_hookvalidation_unknown(testdir): - testdir.makeconftest( +def test_none_help_param_raises_exception(pytester: Pytester) -> None: + """Test that a None help param raises a TypeError.""" + pytester.makeconftest( + """ + def pytest_addoption(parser): + parser.addini("test_ini", None, default=True, type="bool") + """ + ) + result = pytester.runpytest("--help") + result.stderr.fnmatch_lines( + ["*TypeError: help argument cannot be None for test_ini*"] + ) + + +def test_empty_help_param(pytester: Pytester) -> None: + """Test that an empty help param is displayed correctly.""" + pytester.makeconftest( + """ + def pytest_addoption(parser): + parser.addini("test_ini", "", default=True, type="bool") + """ + ) + result = pytester.runpytest("--help") + assert result.ret == 0 + lines = [ + " required_plugins (args):", + " plugins that must be present for pytest to run*", + " test_ini (bool):*", + "environment variables:", + ] + result.stdout.fnmatch_lines(lines, consecutive=True) + + +def test_hookvalidation_unknown(pytester: Pytester) -> None: + pytester.makeconftest( """ def pytest_hello(xyz): pass """ ) - result = testdir.runpytest() + result = pytester.runpytest() assert result.ret != 0 result.stdout.fnmatch_lines(["*unknown hook*pytest_hello*"]) -def test_hookvalidation_optional(testdir): - testdir.makeconftest( +def test_hookvalidation_optional(pytester: Pytester) -> None: + pytester.makeconftest( """ import pytest @pytest.hookimpl(optionalhook=True) @@ -49,25 +91,25 @@ def pytest_hello(xyz): pass """ ) - result = testdir.runpytest() + result = pytester.runpytest() assert result.ret == ExitCode.NO_TESTS_COLLECTED -def test_traceconfig(testdir): - result = testdir.runpytest("--traceconfig") +def test_traceconfig(pytester: Pytester) -> None: + result = pytester.runpytest("--traceconfig") result.stdout.fnmatch_lines(["*using*pytest*py*", "*active plugins*"]) -def test_debug(testdir): - result = testdir.runpytest_subprocess("--debug") +def test_debug(pytester: Pytester) -> None: + result = pytester.runpytest_subprocess("--debug") assert result.ret == ExitCode.NO_TESTS_COLLECTED - p = testdir.tmpdir.join("pytestdebug.log") - assert "pytest_sessionstart" in p.read() + p = pytester.path.joinpath("pytestdebug.log") + assert "pytest_sessionstart" in p.read_text("utf-8") -def test_PYTEST_DEBUG(testdir, monkeypatch): +def test_PYTEST_DEBUG(pytester: Pytester, monkeypatch) -> None: monkeypatch.setenv("PYTEST_DEBUG", "1") - result = testdir.runpytest_subprocess() + result = pytester.runpytest_subprocess() assert result.ret == ExitCode.NO_TESTS_COLLECTED result.stderr.fnmatch_lines( ["*pytest_plugin_registered*", "*manager*PluginManager*"] diff --git a/testing/test_junitxml.py b/testing/test_junitxml.py index 0d6adb3a063..006bea96280 100644 --- a/testing/test_junitxml.py +++ b/testing/test_junitxml.py @@ -1,21 +1,28 @@ import os import platform from datetime import datetime +from pathlib import Path +from typing import cast +from typing import List +from typing import Tuple +from typing import TYPE_CHECKING from xml.dom import minidom import py import xmlschema import pytest +from _pytest.config import Config +from _pytest.junitxml import bin_xml_escape from _pytest.junitxml import LogXML -from _pytest.pathlib import Path from _pytest.reports import BaseReport +from _pytest.reports import TestReport from _pytest.store import Store @pytest.fixture(scope="session") def schema(): - """Returns a xmlschema.XMLSchema object for the junit-10.xsd file""" + """Return an xmlschema.XMLSchema object for the junit-10.xsd file.""" fn = Path(__file__).parent / "example_scripts/junit-10.xsd" with fn.open() as f: return xmlschema.XMLSchema(f) @@ -23,9 +30,8 @@ def schema(): @pytest.fixture def run_and_parse(testdir, schema): - """ - Fixture that returns a function that can be used to execute pytest and return - the parsed ``DomNode`` of the root xml node. + """Fixture that returns a function that can be used to execute pytest and + return the parsed ``DomNode`` of the root xml node. The ``family`` parameter is used to configure the ``junit_family`` of the written report. "xunit2" is also automatically validated against the schema. @@ -200,23 +206,23 @@ def test_pass(): timestamp = datetime.strptime(node["timestamp"], "%Y-%m-%dT%H:%M:%S.%f") assert start_time <= timestamp < datetime.now() - def test_timing_function(self, testdir, run_and_parse): + def test_timing_function(self, testdir, run_and_parse, mock_timing): testdir.makepyfile( """ - import time, pytest + from _pytest import timing def setup_module(): - time.sleep(0.01) + timing.sleep(1) def teardown_module(): - time.sleep(0.01) + timing.sleep(2) def test_sleep(): - time.sleep(0.01) + timing.sleep(4) """ ) result, dom = run_and_parse() node = dom.find_first_by_tag("testsuite") tnode = node.find_first_by_tag("testcase") val = tnode["time"] - assert round(float(val), 2) >= 0.03 + assert float(val) == 7.0 @pytest.mark.parametrize("duration_report", ["call", "total"]) def test_junit_duration_report( @@ -239,9 +245,7 @@ def test_foo(): pass """ ) - result, dom = run_and_parse( - "-o", "junit_duration_report={}".format(duration_report) - ) + result, dom = run_and_parse("-o", f"junit_duration_report={duration_report}") node = dom.find_first_by_tag("testsuite") tnode = node.find_first_by_tag("testcase") val = float(tnode["time"]) @@ -259,7 +263,7 @@ def test_setup_error(self, testdir, run_and_parse, xunit_family): @pytest.fixture def arg(request): - raise ValueError() + raise ValueError("Error reason") def test_function(arg): pass """ @@ -271,7 +275,7 @@ def test_function(arg): tnode = node.find_first_by_tag("testcase") tnode.assert_attr(classname="test_setup_error", name="test_function") fnode = tnode.find_first_by_tag("error") - fnode.assert_attr(message="test setup failure") + fnode.assert_attr(message='failed on setup with "ValueError: Error reason"') assert "ValueError" in fnode.toxml() @parametrize_families @@ -283,7 +287,7 @@ def test_teardown_error(self, testdir, run_and_parse, xunit_family): @pytest.fixture def arg(): yield - raise ValueError() + raise ValueError('Error reason') def test_function(arg): pass """ @@ -294,7 +298,7 @@ def test_function(arg): tnode = node.find_first_by_tag("testcase") tnode.assert_attr(classname="test_teardown_error", name="test_function") fnode = tnode.find_first_by_tag("error") - fnode.assert_attr(message="test teardown failure") + fnode.assert_attr(message='failed on teardown with "ValueError: Error reason"') assert "ValueError" in fnode.toxml() @parametrize_families @@ -316,12 +320,15 @@ def test_function(arg): node = dom.find_first_by_tag("testsuite") node.assert_attr(errors=1, failures=1, tests=1) first, second = dom.find_by_tag("testcase") - if not first or not second or first == second: - assert 0 + assert first + assert second + assert first != second fnode = first.find_first_by_tag("failure") fnode.assert_attr(message="Exception: Call Exception") snode = second.find_first_by_tag("error") - snode.assert_attr(message="test teardown failure") + snode.assert_attr( + message='failed on teardown with "Exception: Teardown Exception"' + ) @parametrize_families def test_skip_contains_name_reason(self, testdir, run_and_parse, xunit_family): @@ -526,7 +533,7 @@ def test_fail(): node = dom.find_first_by_tag("testsuite") tnode = node.find_first_by_tag("testcase") fnode = tnode.find_first_by_tag("failure") - fnode.assert_attr(message="AssertionError: An error assert 0") + fnode.assert_attr(message="AssertionError: An error\nassert 0") @parametrize_families def test_failure_escape(self, testdir, run_and_parse, xunit_family): @@ -710,7 +717,7 @@ def test_hello(): assert "hx" in fnode.toxml() def test_assertion_binchars(self, testdir, run_and_parse): - """this test did fail when the escaping wasnt strict""" + """This test did fail when the escaping wasn't strict.""" testdir.makepyfile( """ @@ -859,10 +866,13 @@ def test_mangle_test_address(): assert newnames == ["a.my.py.thing", "Class", "method", "[a-1-::]"] -def test_dont_configure_on_slaves(tmpdir): - gotten = [] +def test_dont_configure_on_workers(tmpdir) -> None: + gotten: List[object] = [] class FakeConfig: + if TYPE_CHECKING: + workerinput = None + def __init__(self): self.pluginmanager = self self.option = self @@ -876,12 +886,12 @@ def getini(self, name): xmlpath = str(tmpdir.join("junix.xml")) register = gotten.append - fake_config = FakeConfig() + fake_config = cast(Config, FakeConfig()) from _pytest import junitxml junitxml.pytest_configure(fake_config) assert len(gotten) == 1 - FakeConfig.slaveinput = None + FakeConfig.workerinput = None junitxml.pytest_configure(fake_config) assert len(gotten) == 1 @@ -894,11 +904,8 @@ def test_summing_simple(self, testdir, run_and_parse, xunit_family): import pytest def pytest_collect_file(path, parent): if path.ext == ".xyz": - return MyItem(path, parent) + return MyItem.from_parent(name=path.basename, parent=parent) class MyItem(pytest.Item): - def __init__(self, path, parent): - super(MyItem, self).__init__(path.basename, parent) - self.fspath = path def runtest(self): raise ValueError(42) def repr_failure(self, excinfo): @@ -969,11 +976,6 @@ def test_invalid_xml_escape(): # the higher ones. # XXX Testing 0xD (\r) is tricky as it overwrites the just written # line in the output, so we skip it too. - global unichr - try: - unichr(65) - except NameError: - unichr = chr invalid = ( 0x00, 0x1, @@ -990,17 +992,15 @@ def test_invalid_xml_escape(): valid = (0x9, 0xA, 0x20) # 0xD, 0xD7FF, 0xE000, 0xFFFD, 0x10000, 0x10FFFF) - from _pytest.junitxml import bin_xml_escape - for i in invalid: - got = bin_xml_escape(unichr(i)).uniobj + got = bin_xml_escape(chr(i)) if i <= 0xFF: expected = "#x%02X" % i else: expected = "#x%04X" % i assert got == expected for i in valid: - assert chr(i) == bin_xml_escape(unichr(i)).uniobj + assert chr(i) == bin_xml_escape(chr(i)) def test_logxml_path_expansion(tmpdir, monkeypatch): @@ -1095,18 +1095,19 @@ def test_func(self, param): node.assert_attr(name="test_func[double::colon]") -def test_unicode_issue368(testdir): +def test_unicode_issue368(testdir) -> None: path = testdir.tmpdir.join("test.xml") log = LogXML(str(path), None) ustr = "ВНИ!" class Report(BaseReport): longrepr = ustr - sections = [] + sections: List[Tuple[str, str]] = [] nodeid = "something" location = "tests/filename.py", 42, "TestClass.method" + when = "teardown" - test_report = Report() + test_report = cast(TestReport, Report()) # hopefully this is not too brittle ... log.pytest_sessionstart() @@ -1119,7 +1120,7 @@ class Report(BaseReport): node_reporter.append_skipped(test_report) test_report.longrepr = "filename", 1, "Skipped: 卡嘣嘣" node_reporter.append_skipped(test_report) - test_report.wasxfail = ustr + test_report.wasxfail = ustr # type: ignore[attr-defined] node_reporter.append_skipped(test_report) log.pytest_sessionfinish() @@ -1209,8 +1210,7 @@ def test_record(record_xml_attribute, other): @pytest.mark.filterwarnings("default") @pytest.mark.parametrize("fixture_name", ["record_xml_attribute", "record_property"]) def test_record_fixtures_xunit2(testdir, fixture_name, run_and_parse): - """Ensure record_xml_attribute and record_property drop values when outside of legacy family - """ + """Ensure record_xml_attribute and record_property drop values when outside of legacy family.""" testdir.makeini( """ [pytest] @@ -1247,10 +1247,9 @@ def test_record({fixture_name}, other): def test_random_report_log_xdist(testdir, monkeypatch, run_and_parse): - """xdist calls pytest_runtest_logreport as they are executed by the slaves, + """`xdist` calls pytest_runtest_logreport as they are executed by the workers, with nodes from several nodes overlapping, so junitxml must cope with that - to produce correct reports. #1064 - """ + to produce correct reports (#1064).""" pytest.importorskip("xdist") monkeypatch.delenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD", raising=False) testdir.makepyfile( @@ -1331,14 +1330,14 @@ def runtest(self): class FunCollector(pytest.File): def collect(self): return [ - FunItem('a', self), - NoFunItem('a', self), - NoFunItem('b', self), + FunItem.from_parent(name='a', parent=self), + NoFunItem.from_parent(name='a', parent=self), + NoFunItem.from_parent(name='b', parent=self), ] def pytest_collect_file(path, parent): if path.check(ext='.py'): - return FunCollector(path, parent) + return FunCollector.from_parent(fspath=path, parent=parent) """ ) @@ -1369,17 +1368,17 @@ def test_pass(): @parametrize_families -def test_global_properties(testdir, xunit_family): +def test_global_properties(testdir, xunit_family) -> None: path = testdir.tmpdir.join("test_global_properties.xml") log = LogXML(str(path), None, family=xunit_family) class Report(BaseReport): - sections = [] + sections: List[Tuple[str, str]] = [] nodeid = "test_node_id" log.pytest_sessionstart() - log.add_global_property("foo", 1) - log.add_global_property("bar", 2) + log.add_global_property("foo", "1") + log.add_global_property("bar", "2") log.pytest_sessionfinish() dom = minidom.parse(str(path)) @@ -1403,19 +1402,19 @@ class Report(BaseReport): assert actual == expected -def test_url_property(testdir): +def test_url_property(testdir) -> None: test_url = "http://www.github.com/pytest-dev" path = testdir.tmpdir.join("test_url_property.xml") log = LogXML(str(path), None) class Report(BaseReport): longrepr = "FooBarBaz" - sections = [] + sections: List[Tuple[str, str]] = [] nodeid = "something" location = "tests/filename.py", 42, "TestClass.method" url = test_url - test_report = Report() + test_report = cast(TestReport, Report()) log.pytest_sessionstart() node_reporter = log._opentestcase(test_report) diff --git a/testing/test_link_resolve.py b/testing/test_link_resolve.py new file mode 100644 index 00000000000..7eaf4124796 --- /dev/null +++ b/testing/test_link_resolve.py @@ -0,0 +1,81 @@ +import os.path +import subprocess +import sys +import textwrap +from contextlib import contextmanager +from string import ascii_lowercase + +import py.path + +from _pytest import pytester + + +@contextmanager +def subst_path_windows(filename): + for c in ascii_lowercase[7:]: # Create a subst drive from H-Z. + c += ":" + if not os.path.exists(c): + drive = c + break + else: + raise AssertionError("Unable to find suitable drive letter for subst.") + + directory = filename.dirpath() + basename = filename.basename + + args = ["subst", drive, str(directory)] + subprocess.check_call(args) + assert os.path.exists(drive) + try: + filename = py.path.local(drive) / basename + yield filename + finally: + args = ["subst", "/D", drive] + subprocess.check_call(args) + + +@contextmanager +def subst_path_linux(filename): + directory = filename.dirpath() + basename = filename.basename + + target = directory / ".." / "sub2" + os.symlink(str(directory), str(target), target_is_directory=True) + try: + filename = target / basename + yield filename + finally: + # We don't need to unlink (it's all in the tempdir). + pass + + +def test_link_resolve(testdir: pytester.Testdir) -> None: + """See: https://github.com/pytest-dev/pytest/issues/5965.""" + sub1 = testdir.mkpydir("sub1") + p = sub1.join("test_foo.py") + p.write( + textwrap.dedent( + """ + import pytest + def test_foo(): + raise AssertionError() + """ + ) + ) + + subst = subst_path_linux + if sys.platform == "win32": + subst = subst_path_windows + + with subst(p) as subst_p: + result = testdir.runpytest(str(subst_p), "-v") + # i.e.: Make sure that the error is reported as a relative path, not as a + # resolved path. + # See: https://github.com/pytest-dev/pytest/issues/5965 + stdout = result.stdout.str() + assert "sub1/test_foo.py" not in stdout + + # i.e.: Expect drive on windows because we just have drive:filename, whereas + # we expect a relative path on Linux. + expect = f"*{subst_p}*" if sys.platform == "win32" else "*sub2/test_foo.py*" + result.stdout.fnmatch_lines([expect]) diff --git a/testing/test_main.py b/testing/test_main.py index 07aca3a1e24..3e94668e82f 100644 --- a/testing/test_main.py +++ b/testing/test_main.py @@ -1,7 +1,16 @@ +import argparse +import os +import re +from pathlib import Path from typing import Optional +import py.path + import pytest from _pytest.config import ExitCode +from _pytest.config import UsageError +from _pytest.main import resolve_collection_argument +from _pytest.main import validate_basetemp from _pytest.pytester import Testdir @@ -39,20 +48,20 @@ def pytest_internalerror(excrepr, excinfo): if exc == SystemExit: assert result.stdout.lines[-3:] == [ - 'INTERNALERROR> File "{}", line 4, in pytest_sessionstart'.format(c1), + f'INTERNALERROR> File "{c1}", line 4, in pytest_sessionstart', 'INTERNALERROR> raise SystemExit("boom")', "INTERNALERROR> SystemExit: boom", ] else: assert result.stdout.lines[-3:] == [ - 'INTERNALERROR> File "{}", line 4, in pytest_sessionstart'.format(c1), + f'INTERNALERROR> File "{c1}", line 4, in pytest_sessionstart', 'INTERNALERROR> raise ValueError("boom")', "INTERNALERROR> ValueError: boom", ] if returncode is False: assert result.stderr.lines == ["mainloop: caught unexpected SystemExit!"] else: - assert result.stderr.lines == ["Exit: exiting after {}...".format(exc.__name__)] + assert result.stderr.lines == [f"Exit: exiting after {exc.__name__}..."] @pytest.mark.parametrize("returncode", (None, 42)) @@ -75,3 +84,165 @@ def pytest_sessionfinish(): assert result.ret == ExitCode.NO_TESTS_COLLECTED assert result.stdout.lines[-1] == "collected 0 items" assert result.stderr.lines == ["Exit: exit_pytest_sessionfinish"] + + +@pytest.mark.parametrize("basetemp", ["foo", "foo/bar"]) +def test_validate_basetemp_ok(tmp_path, basetemp, monkeypatch): + monkeypatch.chdir(str(tmp_path)) + validate_basetemp(tmp_path / basetemp) + + +@pytest.mark.parametrize("basetemp", ["", ".", ".."]) +def test_validate_basetemp_fails(tmp_path, basetemp, monkeypatch): + monkeypatch.chdir(str(tmp_path)) + msg = "basetemp must not be empty, the current working directory or any parent directory of it" + with pytest.raises(argparse.ArgumentTypeError, match=msg): + if basetemp: + basetemp = tmp_path / basetemp + validate_basetemp(basetemp) + + +def test_validate_basetemp_integration(testdir): + result = testdir.runpytest("--basetemp=.") + result.stderr.fnmatch_lines("*basetemp must not be*") + + +class TestResolveCollectionArgument: + @pytest.fixture + def invocation_dir(self, testdir: Testdir) -> py.path.local: + testdir.syspathinsert(str(testdir.tmpdir / "src")) + testdir.chdir() + + pkg = testdir.tmpdir.join("src/pkg").ensure_dir() + pkg.join("__init__.py").ensure() + pkg.join("test.py").ensure() + return testdir.tmpdir + + @pytest.fixture + def invocation_path(self, invocation_dir: py.path.local) -> Path: + return Path(str(invocation_dir)) + + def test_file(self, invocation_dir: py.path.local, invocation_path: Path) -> None: + """File and parts.""" + assert resolve_collection_argument(invocation_path, "src/pkg/test.py") == ( + invocation_dir / "src/pkg/test.py", + [], + ) + assert resolve_collection_argument(invocation_path, "src/pkg/test.py::") == ( + invocation_dir / "src/pkg/test.py", + [""], + ) + assert resolve_collection_argument( + invocation_path, "src/pkg/test.py::foo::bar" + ) == (invocation_dir / "src/pkg/test.py", ["foo", "bar"]) + assert resolve_collection_argument( + invocation_path, "src/pkg/test.py::foo::bar::" + ) == (invocation_dir / "src/pkg/test.py", ["foo", "bar", ""]) + + def test_dir(self, invocation_dir: py.path.local, invocation_path: Path) -> None: + """Directory and parts.""" + assert resolve_collection_argument(invocation_path, "src/pkg") == ( + invocation_dir / "src/pkg", + [], + ) + + with pytest.raises( + UsageError, match=r"directory argument cannot contain :: selection parts" + ): + resolve_collection_argument(invocation_path, "src/pkg::") + + with pytest.raises( + UsageError, match=r"directory argument cannot contain :: selection parts" + ): + resolve_collection_argument(invocation_path, "src/pkg::foo::bar") + + def test_pypath(self, invocation_dir: py.path.local, invocation_path: Path) -> None: + """Dotted name and parts.""" + assert resolve_collection_argument( + invocation_path, "pkg.test", as_pypath=True + ) == (invocation_dir / "src/pkg/test.py", []) + assert resolve_collection_argument( + invocation_path, "pkg.test::foo::bar", as_pypath=True + ) == (invocation_dir / "src/pkg/test.py", ["foo", "bar"]) + assert resolve_collection_argument(invocation_path, "pkg", as_pypath=True) == ( + invocation_dir / "src/pkg", + [], + ) + + with pytest.raises( + UsageError, match=r"package argument cannot contain :: selection parts" + ): + resolve_collection_argument( + invocation_path, "pkg::foo::bar", as_pypath=True + ) + + def test_does_not_exist(self, invocation_path: Path) -> None: + """Given a file/module that does not exist raises UsageError.""" + with pytest.raises( + UsageError, match=re.escape("file or directory not found: foobar") + ): + resolve_collection_argument(invocation_path, "foobar") + + with pytest.raises( + UsageError, + match=re.escape( + "module or package not found: foobar (missing __init__.py?)" + ), + ): + resolve_collection_argument(invocation_path, "foobar", as_pypath=True) + + def test_absolute_paths_are_resolved_correctly( + self, invocation_dir: py.path.local, invocation_path: Path + ) -> None: + """Absolute paths resolve back to absolute paths.""" + full_path = str(invocation_dir / "src") + assert resolve_collection_argument(invocation_path, full_path) == ( + py.path.local(os.path.abspath("src")), + [], + ) + + # ensure full paths given in the command-line without the drive letter resolve + # to the full path correctly (#7628) + drive, full_path_without_drive = os.path.splitdrive(full_path) + assert resolve_collection_argument( + invocation_path, full_path_without_drive + ) == (py.path.local(os.path.abspath("src")), []) + + +def test_module_full_path_without_drive(testdir): + """Collect and run test using full path except for the drive letter (#7628). + + Passing a full path without a drive letter would trigger a bug in py.path.local + where it would keep the full path without the drive letter around, instead of resolving + to the full path, resulting in fixtures node ids not matching against test node ids correctly. + """ + testdir.makepyfile( + **{ + "project/conftest.py": """ + import pytest + @pytest.fixture + def fix(): return 1 + """, + } + ) + + testdir.makepyfile( + **{ + "project/tests/dummy_test.py": """ + def test(fix): + assert fix == 1 + """ + } + ) + fn = testdir.tmpdir.join("project/tests/dummy_test.py") + assert fn.isfile() + + drive, path = os.path.splitdrive(str(fn)) + + result = testdir.runpytest(path, "-v") + result.stdout.fnmatch_lines( + [ + os.path.join("project", "tests", "dummy_test.py") + "::test PASSED *", + "* 1 passed in *", + ] + ) diff --git a/testing/test_mark.py b/testing/test_mark.py index 76ee289b674..e0b91f0cef4 100644 --- a/testing/test_mark.py +++ b/testing/test_mark.py @@ -1,26 +1,29 @@ import os import sys +from typing import List +from typing import Optional from unittest import mock import pytest from _pytest.config import ExitCode -from _pytest.mark import EMPTY_PARAMETERSET_OPTION -from _pytest.mark import MarkGenerator as Mark +from _pytest.mark import MarkGenerator +from _pytest.mark.structures import EMPTY_PARAMETERSET_OPTION from _pytest.nodes import Collector from _pytest.nodes import Node +from _pytest.pytester import Pytester class TestMark: @pytest.mark.parametrize("attr", ["mark", "param"]) @pytest.mark.parametrize("modulename", ["py.test", "pytest"]) - def test_pytest_exists_in_namespace_all(self, attr, modulename): + def test_pytest_exists_in_namespace_all(self, attr: str, modulename: str) -> None: module = sys.modules[modulename] - assert attr in module.__all__ + assert attr in module.__all__ # type: ignore - def test_pytest_mark_notcallable(self): - mark = Mark() + def test_pytest_mark_notcallable(self) -> None: + mark = MarkGenerator() with pytest.raises(TypeError): - mark() + mark() # type: ignore[operator] def test_mark_with_param(self): def some_function(abc): @@ -30,22 +33,23 @@ class SomeClass: pass assert pytest.mark.foo(some_function) is some_function - assert pytest.mark.foo.with_args(some_function) is not some_function + marked_with_args = pytest.mark.foo.with_args(some_function) + assert marked_with_args is not some_function # type: ignore[comparison-overlap] assert pytest.mark.foo(SomeClass) is SomeClass - assert pytest.mark.foo.with_args(SomeClass) is not SomeClass + assert pytest.mark.foo.with_args(SomeClass) is not SomeClass # type: ignore[comparison-overlap] - def test_pytest_mark_name_starts_with_underscore(self): - mark = Mark() + def test_pytest_mark_name_starts_with_underscore(self) -> None: + mark = MarkGenerator() with pytest.raises(AttributeError): mark._some_name -def test_marked_class_run_twice(testdir): +def test_marked_class_run_twice(pytester: Pytester) -> None: """Test fails file is run twice that contains marked class. See issue#683. """ - py_file = testdir.makepyfile( + py_file = pytester.makepyfile( """ import pytest @pytest.mark.parametrize('abc', [1, 2, 3]) @@ -54,13 +58,13 @@ def test_1(self, abc): assert abc in [1, 2, 3] """ ) - file_name = os.path.basename(py_file.strpath) - rec = testdir.inline_run(file_name, file_name) + file_name = os.path.basename(py_file) + rec = pytester.inline_run(file_name, file_name) rec.assertoutcome(passed=6) -def test_ini_markers(testdir): - testdir.makeini( +def test_ini_markers(pytester: Pytester) -> None: + pytester.makeini( """ [pytest] markers = @@ -68,7 +72,7 @@ def test_ini_markers(testdir): a2: this is a smoke marker """ ) - testdir.makepyfile( + pytester.makepyfile( """ def test_markers(pytestconfig): markers = pytestconfig.getini("markers") @@ -78,12 +82,12 @@ def test_markers(pytestconfig): assert markers[1].startswith("a2:") """ ) - rec = testdir.inline_run() + rec = pytester.inline_run() rec.assertoutcome(passed=1) -def test_markers_option(testdir): - testdir.makeini( +def test_markers_option(pytester: Pytester) -> None: + pytester.makeini( """ [pytest] markers = @@ -92,21 +96,21 @@ def test_markers_option(testdir): nodescription """ ) - result = testdir.runpytest("--markers") + result = pytester.runpytest("--markers") result.stdout.fnmatch_lines( ["*a1*this is a webtest*", "*a1some*another marker", "*nodescription*"] ) -def test_ini_markers_whitespace(testdir): - testdir.makeini( +def test_ini_markers_whitespace(pytester: Pytester) -> None: + pytester.makeini( """ [pytest] markers = a1 : this is a whitespace marker """ ) - testdir.makepyfile( + pytester.makepyfile( """ import pytest @@ -115,33 +119,33 @@ def test_markers(): assert True """ ) - rec = testdir.inline_run("--strict-markers", "-m", "a1") + rec = pytester.inline_run("--strict-markers", "-m", "a1") rec.assertoutcome(passed=1) -def test_marker_without_description(testdir): - testdir.makefile( +def test_marker_without_description(pytester: Pytester) -> None: + pytester.makefile( ".cfg", setup=""" [tool:pytest] markers=slow """, ) - testdir.makeconftest( + pytester.makeconftest( """ import pytest pytest.mark.xfail('FAIL') """ ) - ftdir = testdir.mkdir("ft1_dummy") - testdir.tmpdir.join("conftest.py").move(ftdir.join("conftest.py")) - rec = testdir.runpytest("--strict-markers") + ftdir = pytester.mkdir("ft1_dummy") + pytester.path.joinpath("conftest.py").replace(ftdir.joinpath("conftest.py")) + rec = pytester.runpytest("--strict-markers") rec.assert_outcomes() -def test_markers_option_with_plugin_in_current_dir(testdir): - testdir.makeconftest('pytest_plugins = "flip_flop"') - testdir.makepyfile( +def test_markers_option_with_plugin_in_current_dir(pytester: Pytester) -> None: + pytester.makeconftest('pytest_plugins = "flip_flop"') + pytester.makepyfile( flip_flop="""\ def pytest_configure(config): config.addinivalue_line("markers", "flip:flop") @@ -153,7 +157,7 @@ def pytest_generate_tests(metafunc): return metafunc.parametrize("x", (10, 20))""" ) - testdir.makepyfile( + pytester.makepyfile( """\ import pytest @pytest.mark.flipper @@ -161,12 +165,12 @@ def test_example(x): assert x""" ) - result = testdir.runpytest("--markers") + result = pytester.runpytest("--markers") result.stdout.fnmatch_lines(["*flip*flop*"]) -def test_mark_on_pseudo_function(testdir): - testdir.makepyfile( +def test_mark_on_pseudo_function(pytester: Pytester) -> None: + pytester.makepyfile( """ import pytest @@ -175,13 +179,15 @@ def test_hello(): pass """ ) - reprec = testdir.inline_run() + reprec = pytester.inline_run() reprec.assertoutcome(passed=1) @pytest.mark.parametrize("option_name", ["--strict-markers", "--strict"]) -def test_strict_prohibits_unregistered_markers(testdir, option_name): - testdir.makepyfile( +def test_strict_prohibits_unregistered_markers( + pytester: Pytester, option_name: str +) -> None: + pytester.makepyfile( """ import pytest @pytest.mark.unregisteredmark @@ -189,7 +195,7 @@ def test_hello(): pass """ ) - result = testdir.runpytest(option_name) + result = pytester.runpytest(option_name) assert result.ret != 0 result.stdout.fnmatch_lines( ["'unregisteredmark' not found in `markers` configuration option"] @@ -197,16 +203,20 @@ def test_hello(): @pytest.mark.parametrize( - "spec", + ("expr", "expected_passed"), [ - ("xyz", ("test_one",)), - ("xyz and xyz2", ()), - ("xyz2", ("test_two",)), - ("xyz or xyz2", ("test_one", "test_two")), + ("xyz", ["test_one"]), + ("((( xyz)) )", ["test_one"]), + ("not not xyz", ["test_one"]), + ("xyz and xyz2", []), + ("xyz2", ["test_two"]), + ("xyz or xyz2", ["test_one", "test_two"]), ], ) -def test_mark_option(spec, testdir): - testdir.makepyfile( +def test_mark_option( + expr: str, expected_passed: List[Optional[str]], pytester: Pytester +) -> None: + pytester.makepyfile( """ import pytest @pytest.mark.xyz @@ -217,19 +227,20 @@ def test_two(): pass """ ) - opt, passed_result = spec - rec = testdir.inline_run("-m", opt) + rec = pytester.inline_run("-m", expr) passed, skipped, fail = rec.listoutcomes() - passed = [x.nodeid.split("::")[-1] for x in passed] - assert len(passed) == len(passed_result) - assert list(passed) == list(passed_result) + passed_str = [x.nodeid.split("::")[-1] for x in passed] + assert passed_str == expected_passed @pytest.mark.parametrize( - "spec", [("interface", ("test_interface",)), ("not interface", ("test_nointer",))] + ("expr", "expected_passed"), + [("interface", ["test_interface"]), ("not interface", ["test_nointer"])], ) -def test_mark_option_custom(spec, testdir): - testdir.makeconftest( +def test_mark_option_custom( + expr: str, expected_passed: List[str], pytester: Pytester +) -> None: + pytester.makeconftest( """ import pytest def pytest_collection_modifyitems(items): @@ -238,7 +249,7 @@ def pytest_collection_modifyitems(items): item.add_marker(pytest.mark.interface) """ ) - testdir.makepyfile( + pytester.makepyfile( """ def test_interface(): pass @@ -246,25 +257,28 @@ def test_nointer(): pass """ ) - opt, passed_result = spec - rec = testdir.inline_run("-m", opt) + rec = pytester.inline_run("-m", expr) passed, skipped, fail = rec.listoutcomes() - passed = [x.nodeid.split("::")[-1] for x in passed] - assert len(passed) == len(passed_result) - assert list(passed) == list(passed_result) + passed_str = [x.nodeid.split("::")[-1] for x in passed] + assert passed_str == expected_passed @pytest.mark.parametrize( - "spec", + ("expr", "expected_passed"), [ - ("interface", ("test_interface",)), - ("not interface", ("test_nointer", "test_pass")), - ("pass", ("test_pass",)), - ("not pass", ("test_interface", "test_nointer")), + ("interface", ["test_interface"]), + ("not interface", ["test_nointer", "test_pass", "test_1", "test_2"]), + ("pass", ["test_pass"]), + ("not pass", ["test_interface", "test_nointer", "test_1", "test_2"]), + ("not not not (pass)", ["test_interface", "test_nointer", "test_1", "test_2"]), + ("1 or 2", ["test_1", "test_2"]), + ("not (1 or 2)", ["test_interface", "test_nointer", "test_pass"]), ], ) -def test_keyword_option_custom(spec, testdir): - testdir.makepyfile( +def test_keyword_option_custom( + expr: str, expected_passed: List[str], pytester: Pytester +) -> None: + pytester.makepyfile( """ def test_interface(): pass @@ -272,33 +286,37 @@ def test_nointer(): pass def test_pass(): pass + def test_1(): + pass + def test_2(): + pass """ ) - opt, passed_result = spec - rec = testdir.inline_run("-k", opt) + rec = pytester.inline_run("-k", expr) passed, skipped, fail = rec.listoutcomes() - passed = [x.nodeid.split("::")[-1] for x in passed] - assert len(passed) == len(passed_result) - assert list(passed) == list(passed_result) + passed_str = [x.nodeid.split("::")[-1] for x in passed] + assert passed_str == expected_passed -def test_keyword_option_considers_mark(testdir): - testdir.copy_example("marks/marks_considered_keywords") - rec = testdir.inline_run("-k", "foo") +def test_keyword_option_considers_mark(pytester: Pytester) -> None: + pytester.copy_example("marks/marks_considered_keywords") + rec = pytester.inline_run("-k", "foo") passed = rec.listoutcomes()[0] assert len(passed) == 1 @pytest.mark.parametrize( - "spec", + ("expr", "expected_passed"), [ - ("None", ("test_func[None]",)), - ("1.3", ("test_func[1.3]",)), - ("2-3", ("test_func[2-3]",)), + ("None", ["test_func[None]"]), + ("[1.3]", ["test_func[1.3]"]), + ("2-3", ["test_func[2-3]"]), ], ) -def test_keyword_option_parametrize(spec, testdir): - testdir.makepyfile( +def test_keyword_option_parametrize( + expr: str, expected_passed: List[str], pytester: Pytester +) -> None: + pytester.makepyfile( """ import pytest @pytest.mark.parametrize("arg", [None, 1.3, "2-3"]) @@ -306,16 +324,14 @@ def test_func(arg): pass """ ) - opt, passed_result = spec - rec = testdir.inline_run("-k", opt) + rec = pytester.inline_run("-k", expr) passed, skipped, fail = rec.listoutcomes() - passed = [x.nodeid.split("::")[-1] for x in passed] - assert len(passed) == len(passed_result) - assert list(passed) == list(passed_result) + passed_str = [x.nodeid.split("::")[-1] for x in passed] + assert passed_str == expected_passed -def test_parametrize_with_module(testdir): - testdir.makepyfile( +def test_parametrize_with_module(pytester: Pytester) -> None: + pytester.makepyfile( """ import pytest @pytest.mark.parametrize("arg", [pytest,]) @@ -323,40 +339,53 @@ def test_func(arg): pass """ ) - rec = testdir.inline_run() + rec = pytester.inline_run() passed, skipped, fail = rec.listoutcomes() expected_id = "test_func[" + pytest.__name__ + "]" assert passed[0].nodeid.split("::")[-1] == expected_id @pytest.mark.parametrize( - "spec", + ("expr", "expected_error"), [ ( - "foo or import", - "ERROR: Python keyword 'import' not accepted in expressions passed to '-k'", + "foo or", + "at column 7: expected not OR left parenthesis OR identifier; got end of input", + ), + ( + "foo or or", + "at column 8: expected not OR left parenthesis OR identifier; got or", + ), + ("(foo", "at column 5: expected right parenthesis; got end of input",), + ("foo bar", "at column 5: expected end of input; got identifier",), + ( + "or or", + "at column 1: expected not OR left parenthesis OR identifier; got or", + ), + ( + "not or", + "at column 5: expected not OR left parenthesis OR identifier; got or", ), - ("foo or", "ERROR: Wrong expression passed to '-k': foo or"), ], ) -def test_keyword_option_wrong_arguments(spec, testdir, capsys): - testdir.makepyfile( +def test_keyword_option_wrong_arguments( + expr: str, expected_error: str, pytester: Pytester, capsys +) -> None: + pytester.makepyfile( """ def test_func(arg): pass """ ) - opt, expected_result = spec - testdir.inline_run("-k", opt) - out = capsys.readouterr().err - assert expected_result in out + pytester.inline_run("-k", expr) + err = capsys.readouterr().err + assert expected_error in err -def test_parametrized_collected_from_command_line(testdir): - """Parametrized test not collected if test named specified - in command line issue#649. - """ - py_file = testdir.makepyfile( +def test_parametrized_collected_from_command_line(pytester: Pytester) -> None: + """Parametrized test not collected if test named specified in command + line issue#649.""" + py_file = pytester.makepyfile( """ import pytest @pytest.mark.parametrize("arg", [None, 1.3, "2-3"]) @@ -364,14 +393,14 @@ def test_func(arg): pass """ ) - file_name = os.path.basename(py_file.strpath) - rec = testdir.inline_run(file_name + "::" + "test_func") + file_name = os.path.basename(py_file) + rec = pytester.inline_run(file_name + "::" + "test_func") rec.assertoutcome(passed=3) -def test_parametrized_collect_with_wrong_args(testdir): +def test_parametrized_collect_with_wrong_args(pytester: Pytester) -> None: """Test collect parametrized func with wrong number of args.""" - py_file = testdir.makepyfile( + py_file = pytester.makepyfile( """ import pytest @@ -381,7 +410,7 @@ def test_func(foo, bar): """ ) - result = testdir.runpytest(py_file) + result = pytester.runpytest(py_file) result.stdout.fnmatch_lines( [ 'test_parametrized_collect_with_wrong_args.py::test_func: in "parametrize" the number of names (2):', @@ -392,9 +421,9 @@ def test_func(foo, bar): ) -def test_parametrized_with_kwargs(testdir): +def test_parametrized_with_kwargs(pytester: Pytester) -> None: """Test collect parametrized func with wrong number of args.""" - py_file = testdir.makepyfile( + py_file = pytester.makepyfile( """ import pytest @@ -408,13 +437,13 @@ def test_func(a, b): """ ) - result = testdir.runpytest(py_file) + result = pytester.runpytest(py_file) assert result.ret == 0 -def test_parametrize_iterator(testdir): - """parametrize should work with generators (#5354).""" - py_file = testdir.makepyfile( +def test_parametrize_iterator(pytester: Pytester) -> None: + """`parametrize` should work with generators (#5354).""" + py_file = pytester.makepyfile( """\ import pytest @@ -428,16 +457,16 @@ def test(a): assert a >= 1 """ ) - result = testdir.runpytest(py_file) + result = pytester.runpytest(py_file) assert result.ret == 0 # should not skip any tests result.stdout.fnmatch_lines(["*3 passed*"]) class TestFunctional: - def test_merging_markers_deep(self, testdir): + def test_merging_markers_deep(self, pytester: Pytester) -> None: # issue 199 - propagate markers into nested classes - p = testdir.makepyfile( + p = pytester.makepyfile( """ import pytest class TestA(object): @@ -450,13 +479,15 @@ def test_d(self): assert True """ ) - items, rec = testdir.inline_genitems(p) + items, rec = pytester.inline_genitems(p) for item in items: print(item, item.keywords) assert [x for x in item.iter_markers() if x.name == "a"] - def test_mark_decorator_subclass_does_not_propagate_to_base(self, testdir): - p = testdir.makepyfile( + def test_mark_decorator_subclass_does_not_propagate_to_base( + self, pytester: Pytester + ) -> None: + p = pytester.makepyfile( """ import pytest @@ -471,12 +502,12 @@ class Test2(Base): def test_bar(self): pass """ ) - items, rec = testdir.inline_genitems(p) + items, rec = pytester.inline_genitems(p) self.assert_markers(items, test_foo=("a", "b"), test_bar=("a",)) - def test_mark_should_not_pass_to_siebling_class(self, testdir): + def test_mark_should_not_pass_to_siebling_class(self, pytester: Pytester) -> None: """#568""" - p = testdir.makepyfile( + p = pytester.makepyfile( """ import pytest @@ -494,7 +525,7 @@ class TestOtherSub(TestBase): """ ) - items, rec = testdir.inline_genitems(p) + items, rec = pytester.inline_genitems(p) base_item, sub_item, sub_item_other = items print(items, [x.nodeid for x in items]) # new api segregates @@ -502,8 +533,8 @@ class TestOtherSub(TestBase): assert not list(sub_item_other.iter_markers(name="b")) assert list(sub_item.iter_markers(name="b")) - def test_mark_decorator_baseclasses_merged(self, testdir): - p = testdir.makepyfile( + def test_mark_decorator_baseclasses_merged(self, pytester: Pytester) -> None: + p = pytester.makepyfile( """ import pytest @@ -522,33 +553,37 @@ class Test2(Base2): def test_bar(self): pass """ ) - items, rec = testdir.inline_genitems(p) + items, rec = pytester.inline_genitems(p) self.assert_markers(items, test_foo=("a", "b", "c"), test_bar=("a", "b", "d")) - def test_mark_closest(self, testdir): - p = testdir.makepyfile( + def test_mark_closest(self, pytester: Pytester) -> None: + p = pytester.makepyfile( """ import pytest @pytest.mark.c(location="class") class Test: @pytest.mark.c(location="function") - def test_has_own(): + def test_has_own(self): pass - def test_has_inherited(): + def test_has_inherited(self): pass """ ) - items, rec = testdir.inline_genitems(p) + items, rec = pytester.inline_genitems(p) has_own, has_inherited = items - assert has_own.get_closest_marker("c").kwargs == {"location": "function"} - assert has_inherited.get_closest_marker("c").kwargs == {"location": "class"} + has_own_marker = has_own.get_closest_marker("c") + has_inherited_marker = has_inherited.get_closest_marker("c") + assert has_own_marker is not None + assert has_inherited_marker is not None + assert has_own_marker.kwargs == {"location": "function"} + assert has_inherited_marker.kwargs == {"location": "class"} assert has_own.get_closest_marker("missing") is None - def test_mark_with_wrong_marker(self, testdir): - reprec = testdir.inline_runsource( + def test_mark_with_wrong_marker(self, pytester: Pytester) -> None: + reprec = pytester.inline_runsource( """ import pytest class pytestmark(object): @@ -561,8 +596,8 @@ def test_func(): assert len(values) == 1 assert "TypeError" in str(values[0].longrepr) - def test_mark_dynamically_in_funcarg(self, testdir): - testdir.makeconftest( + def test_mark_dynamically_in_funcarg(self, pytester: Pytester) -> None: + pytester.makeconftest( """ import pytest @pytest.fixture @@ -573,17 +608,17 @@ def pytest_terminal_summary(terminalreporter): terminalreporter._tw.line("keyword: %s" % values[0].keywords) """ ) - testdir.makepyfile( + pytester.makepyfile( """ def test_func(arg): pass """ ) - result = testdir.runpytest() + result = pytester.runpytest() result.stdout.fnmatch_lines(["keyword: *hello*"]) - def test_no_marker_match_on_unmarked_names(self, testdir): - p = testdir.makepyfile( + def test_no_marker_match_on_unmarked_names(self, pytester: Pytester) -> None: + p = pytester.makepyfile( """ import pytest @pytest.mark.shouldmatch @@ -594,27 +629,15 @@ def test_unmarked(): assert 1 """ ) - reprec = testdir.inline_run("-m", "test_unmarked", p) + reprec = pytester.inline_run("-m", "test_unmarked", p) passed, skipped, failed = reprec.listoutcomes() assert len(passed) + len(skipped) + len(failed) == 0 dlist = reprec.getcalls("pytest_deselected") deselected_tests = dlist[0].items assert len(deselected_tests) == 2 - def test_invalid_m_option(self, testdir): - testdir.makepyfile( - """ - def test_a(): - pass - """ - ) - result = testdir.runpytest("-m bogus/") - result.stdout.fnmatch_lines( - ["INTERNALERROR> Marker expression must be valid Python!"] - ) - - def test_keywords_at_node_level(self, testdir): - testdir.makepyfile( + def test_keywords_at_node_level(self, pytester: Pytester) -> None: + pytester.makepyfile( """ import pytest @pytest.fixture(scope="session", autouse=True) @@ -632,11 +655,11 @@ def test_function(): pass """ ) - reprec = testdir.inline_run() + reprec = pytester.inline_run() reprec.assertoutcome(passed=1) - def test_keyword_added_for_session(self, testdir): - testdir.makeconftest( + def test_keyword_added_for_session(self, pytester: Pytester) -> None: + pytester.makeconftest( """ import pytest def pytest_collection_modifyitems(session): @@ -647,7 +670,7 @@ def pytest_collection_modifyitems(session): session.add_marker(10)) """ ) - testdir.makepyfile( + pytester.makepyfile( """ def test_some(request): assert "mark1" in request.keywords @@ -660,26 +683,25 @@ def test_some(request): assert marker.kwargs == {} """ ) - reprec = testdir.inline_run("-m", "mark1") + reprec = pytester.inline_run("-m", "mark1") reprec.assertoutcome(passed=1) - def assert_markers(self, items, **expected): - """assert that given items have expected marker names applied to them. - expected should be a dict of (item name -> seq of expected marker names) + def assert_markers(self, items, **expected) -> None: + """Assert that given items have expected marker names applied to them. + expected should be a dict of (item name -> seq of expected marker names). - .. note:: this could be moved to ``testdir`` if proven to be useful + Note: this could be moved to ``pytester`` if proven to be useful to other modules. """ - items = {x.name: x for x in items} for name, expected_markers in expected.items(): markers = {m.name for m in items[name].iter_markers()} assert markers == set(expected_markers) @pytest.mark.filterwarnings("ignore") - def test_mark_from_parameters(self, testdir): + def test_mark_from_parameters(self, pytester: Pytester) -> None: """#1540""" - testdir.makepyfile( + pytester.makepyfile( """ import pytest @@ -698,13 +720,43 @@ def test_1(parameter): assert True """ ) - reprec = testdir.inline_run() + reprec = pytester.inline_run() reprec.assertoutcome(skipped=1) + def test_reevaluate_dynamic_expr(self, pytester: Pytester) -> None: + """#7360""" + py_file1 = pytester.makepyfile( + test_reevaluate_dynamic_expr1=""" + import pytest + + skip = True + + @pytest.mark.skipif("skip") + def test_should_skip(): + assert True + """ + ) + py_file2 = pytester.makepyfile( + test_reevaluate_dynamic_expr2=""" + import pytest + + skip = False + + @pytest.mark.skipif("skip") + def test_should_not_skip(): + assert True + """ + ) + + file_name1 = os.path.basename(py_file1) + file_name2 = os.path.basename(py_file2) + reprec = pytester.inline_run(file_name1, file_name2) + reprec.assertoutcome(passed=1, skipped=1) + class TestKeywordSelection: - def test_select_simple(self, testdir): - file_test = testdir.makepyfile( + def test_select_simple(self, pytester: Pytester) -> None: + file_test = pytester.makepyfile( """ def test_one(): assert 0 @@ -715,7 +767,7 @@ def test_method_one(self): ) def check(keyword, name): - reprec = testdir.inline_run("-s", "-k", keyword, file_test) + reprec = pytester.inline_run("-s", "-k", keyword, file_test) passed, skipped, failed = reprec.listoutcomes() assert len(failed) == 1 assert failed[0].nodeid.split("::")[-1] == name @@ -736,8 +788,8 @@ def check(keyword, name): "xxx and TestClass and test_2", ], ) - def test_select_extra_keywords(self, testdir, keyword): - p = testdir.makepyfile( + def test_select_extra_keywords(self, pytester: Pytester, keyword) -> None: + p = pytester.makepyfile( test_select=""" def test_1(): pass @@ -746,7 +798,7 @@ def test_2(self): pass """ ) - testdir.makepyfile( + pytester.makepyfile( conftest=""" import pytest @pytest.hookimpl(hookwrapper=True) @@ -757,7 +809,7 @@ def pytest_pycollect_makeitem(name): item.extra_keyword_matches.add("xxx") """ ) - reprec = testdir.inline_run(p.dirpath(), "-s", "-k", keyword) + reprec = pytester.inline_run(p.parent, "-s", "-k", keyword) print("keyword", repr(keyword)) passed, skipped, failed = reprec.listoutcomes() assert len(passed) == 1 @@ -766,15 +818,15 @@ def pytest_pycollect_makeitem(name): assert len(dlist) == 1 assert dlist[0].items[0].name == "test_1" - def test_select_starton(self, testdir): - threepass = testdir.makepyfile( + def test_select_starton(self, pytester: Pytester) -> None: + threepass = pytester.makepyfile( test_threepass=""" def test_one(): assert 1 def test_two(): assert 1 def test_three(): assert 1 """ ) - reprec = testdir.inline_run("-k", "test_two:", threepass) + reprec = pytester.inline_run("-k", "test_two:", threepass) passed, skipped, failed = reprec.listoutcomes() assert len(passed) == 2 assert not failed @@ -783,21 +835,21 @@ def test_three(): assert 1 item = dlist[0].items[0] assert item.name == "test_one" - def test_keyword_extra(self, testdir): - p = testdir.makepyfile( + def test_keyword_extra(self, pytester: Pytester) -> None: + p = pytester.makepyfile( """ def test_one(): assert 0 test_one.mykeyword = True """ ) - reprec = testdir.inline_run("-k", "mykeyword", p) + reprec = pytester.inline_run("-k", "mykeyword", p) passed, skipped, failed = reprec.countoutcomes() assert failed == 1 @pytest.mark.xfail - def test_keyword_extra_dash(self, testdir): - p = testdir.makepyfile( + def test_keyword_extra_dash(self, pytester: Pytester) -> None: + p = pytester.makepyfile( """ def test_one(): assert 0 @@ -806,31 +858,57 @@ def test_one(): ) # with argparse the argument to an option cannot # start with '-' - reprec = testdir.inline_run("-k", "-mykeyword", p) + reprec = pytester.inline_run("-k", "-mykeyword", p) passed, skipped, failed = reprec.countoutcomes() assert passed + skipped + failed == 0 - def test_no_magic_values(self, testdir): + @pytest.mark.parametrize( + "keyword", ["__", "+", ".."], + ) + def test_no_magic_values(self, pytester: Pytester, keyword: str) -> None: """Make sure the tests do not match on magic values, - no double underscored values, like '__dict__', - and no instance values, like '()'. + no double underscored values, like '__dict__' and '+'. """ - p = testdir.makepyfile( + p = pytester.makepyfile( """ def test_one(): assert 1 """ ) - def assert_test_is_not_selected(keyword): - reprec = testdir.inline_run("-k", keyword, p) - passed, skipped, failed = reprec.countoutcomes() - dlist = reprec.getcalls("pytest_deselected") - assert passed + skipped + failed == 0 - deselected_tests = dlist[0].items - assert len(deselected_tests) == 1 + reprec = pytester.inline_run("-k", keyword, p) + passed, skipped, failed = reprec.countoutcomes() + dlist = reprec.getcalls("pytest_deselected") + assert passed + skipped + failed == 0 + deselected_tests = dlist[0].items + assert len(deselected_tests) == 1 - assert_test_is_not_selected("__") - assert_test_is_not_selected("()") + def test_no_match_directories_outside_the_suite(self, pytester: Pytester) -> None: + """`-k` should not match against directories containing the test suite (#7040).""" + test_contents = """ + def test_aaa(): pass + def test_ddd(): pass + """ + pytester.makepyfile( + **{"ddd/tests/__init__.py": "", "ddd/tests/test_foo.py": test_contents} + ) + + def get_collected_names(*args): + _, rec = pytester.inline_genitems(*args) + calls = rec.getcalls("pytest_collection_finish") + assert len(calls) == 1 + return [x.name for x in calls[0].session.items] + + # sanity check: collect both tests in normal runs + assert get_collected_names() == ["test_aaa", "test_ddd"] + + # do not collect anything based on names outside the collection tree + assert get_collected_names("-k", pytester._name) == [] + + # "-k ddd" should only collect "test_ddd", but not + # 'test_aaa' just because one of its parent directories is named "ddd"; + # this was matched previously because Package.name would contain the full path + # to the package + assert get_collected_names("-k", "ddd") == ["test_ddd"] class TestMarkDecorator: @@ -843,7 +921,7 @@ class TestMarkDecorator: ("foo", pytest.mark.bar(), False), ], ) - def test__eq__(self, lhs, rhs, expected): + def test__eq__(self, lhs, rhs, expected) -> None: assert (lhs == rhs) == expected def test_aliases(self) -> None: @@ -854,9 +932,11 @@ def test_aliases(self) -> None: @pytest.mark.parametrize("mark", [None, "", "skip", "xfail"]) -def test_parameterset_for_parametrize_marks(testdir, mark): +def test_parameterset_for_parametrize_marks( + pytester: Pytester, mark: Optional[str] +) -> None: if mark is not None: - testdir.makeini( + pytester.makeini( """ [pytest] {}={} @@ -865,7 +945,7 @@ def test_parameterset_for_parametrize_marks(testdir, mark): ) ) - config = testdir.parseconfig() + config = pytester.parseconfig() from _pytest.mark import pytest_configure, get_empty_parameterset_mark pytest_configure(config) @@ -879,8 +959,8 @@ def test_parameterset_for_parametrize_marks(testdir, mark): assert result_mark.kwargs.get("run") is False -def test_parameterset_for_fail_at_collect(testdir): - testdir.makeini( +def test_parameterset_for_fail_at_collect(pytester: Pytester) -> None: + pytester.makeini( """ [pytest] {}=fail_at_collect @@ -889,7 +969,7 @@ def test_parameterset_for_fail_at_collect(testdir): ) ) - config = testdir.parseconfig() + config = pytester.parseconfig() from _pytest.mark import pytest_configure, get_empty_parameterset_mark pytest_configure(config) @@ -900,7 +980,7 @@ def test_parameterset_for_fail_at_collect(testdir): ): get_empty_parameterset_mark(config, ["a"], pytest_configure) - p1 = testdir.makepyfile( + p1 = pytester.makepyfile( """ import pytest @@ -909,7 +989,7 @@ def test(): pass """ ) - result = testdir.runpytest(str(p1)) + result = pytester.runpytest(str(p1)) result.stdout.fnmatch_lines( [ "collected 0 items / 1 error", @@ -921,13 +1001,13 @@ def test(): assert result.ret == ExitCode.INTERRUPTED -def test_parameterset_for_parametrize_bad_markname(testdir): +def test_parameterset_for_parametrize_bad_markname(pytester: Pytester) -> None: with pytest.raises(pytest.UsageError): - test_parameterset_for_parametrize_marks(testdir, "bad") + test_parameterset_for_parametrize_marks(pytester, "bad") -def test_mark_expressions_no_smear(testdir): - testdir.makepyfile( +def test_mark_expressions_no_smear(pytester: Pytester) -> None: + pytester.makepyfile( """ import pytest @@ -945,7 +1025,7 @@ class TestBarClass(BaseTests): """ ) - reprec = testdir.inline_run("-m", "FOO") + reprec = pytester.inline_run("-m", "FOO") passed, skipped, failed = reprec.countoutcomes() dlist = reprec.getcalls("pytest_deselected") assert passed == 1 @@ -955,13 +1035,13 @@ class TestBarClass(BaseTests): # todo: fixed # keywords smear - expected behaviour - # reprec_keywords = testdir.inline_run("-k", "FOO") + # reprec_keywords = pytester.inline_run("-k", "FOO") # passed_k, skipped_k, failed_k = reprec_keywords.countoutcomes() # assert passed_k == 2 # assert skipped_k == failed_k == 0 -def test_addmarker_order(): +def test_addmarker_order() -> None: session = mock.Mock() session.own_markers = [] session.parent = None @@ -975,9 +1055,9 @@ def test_addmarker_order(): @pytest.mark.filterwarnings("ignore") -def test_markers_from_parametrize(testdir): +def test_markers_from_parametrize(pytester: Pytester) -> None: """#3605""" - testdir.makepyfile( + pytester.makepyfile( """ import pytest @@ -1008,17 +1088,34 @@ def test_custom_mark_parametrized(obj_type): """ ) - result = testdir.runpytest() + result = pytester.runpytest() result.assert_outcomes(passed=4) -def test_pytest_param_id_requires_string(): +def test_pytest_param_id_requires_string() -> None: with pytest.raises(TypeError) as excinfo: - pytest.param(id=True) + pytest.param(id=True) # type: ignore[arg-type] (msg,) = excinfo.value.args assert msg == "Expected id to be a string, got : True" @pytest.mark.parametrize("s", (None, "hello world")) -def test_pytest_param_id_allows_none_or_string(s): +def test_pytest_param_id_allows_none_or_string(s) -> None: assert pytest.param(id=s) + + +@pytest.mark.parametrize("expr", ("NOT internal_err", "NOT (internal_err)", "bogus/")) +def test_marker_expr_eval_failure_handling(pytester: Pytester, expr) -> None: + foo = pytester.makepyfile( + """ + import pytest + + @pytest.mark.internal_err + def test_foo(): + pass + """ + ) + expected = f"ERROR: Wrong expression passed to '-m': {expr}: *" + result = pytester.runpytest(foo, "-m", expr) + result.stderr.fnmatch_lines([expected]) + assert result.ret == ExitCode.USAGE_ERROR diff --git a/testing/test_mark_expression.py b/testing/test_mark_expression.py new file mode 100644 index 00000000000..faca02d9330 --- /dev/null +++ b/testing/test_mark_expression.py @@ -0,0 +1,169 @@ +from typing import Callable + +import pytest +from _pytest.mark.expression import Expression +from _pytest.mark.expression import ParseError + + +def evaluate(input: str, matcher: Callable[[str], bool]) -> bool: + return Expression.compile(input).evaluate(matcher) + + +def test_empty_is_false() -> None: + assert not evaluate("", lambda ident: False) + assert not evaluate("", lambda ident: True) + assert not evaluate(" ", lambda ident: False) + assert not evaluate("\t", lambda ident: False) + + +@pytest.mark.parametrize( + ("expr", "expected"), + ( + ("true", True), + ("true", True), + ("false", False), + ("not true", False), + ("not false", True), + ("not not true", True), + ("not not false", False), + ("true and true", True), + ("true and false", False), + ("false and true", False), + ("true and true and true", True), + ("true and true and false", False), + ("true and true and not true", False), + ("false or false", False), + ("false or true", True), + ("true or true", True), + ("true or true or false", True), + ("true and true or false", True), + ("not true or true", True), + ("(not true) or true", True), + ("not (true or true)", False), + ("true and true or false and false", True), + ("true and (true or false) and false", False), + ("true and (true or (not (not false))) and false", False), + ), +) +def test_basic(expr: str, expected: bool) -> None: + matcher = {"true": True, "false": False}.__getitem__ + assert evaluate(expr, matcher) is expected + + +@pytest.mark.parametrize( + ("expr", "expected"), + ( + (" true ", True), + (" ((((((true)))))) ", True), + (" ( ((\t (((true))))) \t \t)", True), + ("( true and (((false))))", False), + ("not not not not true", True), + ("not not not not not true", False), + ), +) +def test_syntax_oddeties(expr: str, expected: bool) -> None: + matcher = {"true": True, "false": False}.__getitem__ + assert evaluate(expr, matcher) is expected + + +@pytest.mark.parametrize( + ("expr", "column", "message"), + ( + ("(", 2, "expected not OR left parenthesis OR identifier; got end of input"), + (" (", 3, "expected not OR left parenthesis OR identifier; got end of input",), + ( + ")", + 1, + "expected not OR left parenthesis OR identifier; got right parenthesis", + ), + ( + ") ", + 1, + "expected not OR left parenthesis OR identifier; got right parenthesis", + ), + ("not", 4, "expected not OR left parenthesis OR identifier; got end of input",), + ( + "not not", + 8, + "expected not OR left parenthesis OR identifier; got end of input", + ), + ( + "(not)", + 5, + "expected not OR left parenthesis OR identifier; got right parenthesis", + ), + ("and", 1, "expected not OR left parenthesis OR identifier; got and"), + ( + "ident and", + 10, + "expected not OR left parenthesis OR identifier; got end of input", + ), + ("ident and or", 11, "expected not OR left parenthesis OR identifier; got or",), + ("ident ident", 7, "expected end of input; got identifier"), + ), +) +def test_syntax_errors(expr: str, column: int, message: str) -> None: + with pytest.raises(ParseError) as excinfo: + evaluate(expr, lambda ident: True) + assert excinfo.value.column == column + assert excinfo.value.message == message + + +@pytest.mark.parametrize( + "ident", + ( + ".", + "...", + ":::", + "a:::c", + "a+-b", + "אבגד", + "aaאבגדcc", + "a[bcd]", + "1234", + "1234abcd", + "1234and", + "notandor", + "not_and_or", + "not[and]or", + "1234+5678", + "123.232", + "True", + "False", + "None", + "if", + "else", + "while", + ), +) +def test_valid_idents(ident: str) -> None: + assert evaluate(ident, {ident: True}.__getitem__) + + +@pytest.mark.parametrize( + "ident", + ( + "/", + "\\", + "^", + "*", + "=", + "&", + "%", + "$", + "#", + "@", + "!", + "~", + "{", + "}", + '"', + "'", + "|", + ";", + "←", + ), +) +def test_invalid_idents(ident: str) -> None: + with pytest.raises(ParseError): + evaluate(ident, lambda ident: True) diff --git a/testing/test_meta.py b/testing/test_meta.py index ffc8fd38aba..9201bd21611 100644 --- a/testing/test_meta.py +++ b/testing/test_meta.py @@ -1,5 +1,4 @@ -""" -Test importing of all internal packages and modules. +"""Test importing of all internal packages and modules. This ensures all internal packages can be imported without needing the pytest namespace being set, which is critical for the initialization of xdist. @@ -7,29 +6,27 @@ import pkgutil import subprocess import sys +from typing import List import _pytest import pytest -def _modules(): +def _modules() -> List[str]: + pytest_pkg: str = _pytest.__path__ # type: ignore return sorted( n - for _, n, _ in pkgutil.walk_packages( - _pytest.__path__, prefix=_pytest.__name__ + "." - ) + for _, n, _ in pkgutil.walk_packages(pytest_pkg, prefix=_pytest.__name__ + ".") ) @pytest.mark.slow @pytest.mark.parametrize("module", _modules()) -def test_no_warnings(module): +def test_no_warnings(module: str) -> None: # fmt: off subprocess.check_call(( sys.executable, "-W", "error", - # https://github.com/pytest-dev/pytest/issues/5901 - "-W", "ignore:The usage of `cmp` is deprecated and will be removed on or after 2021-06-01. Please use `eq` and `order` instead.:DeprecationWarning", # noqa: E501 - "-c", "import {}".format(module), + "-c", f"__import__({module!r})", )) # fmt: on diff --git a/testing/test_monkeypatch.py b/testing/test_monkeypatch.py index eee8baf3a69..c20ff7480a8 100644 --- a/testing/test_monkeypatch.py +++ b/testing/test_monkeypatch.py @@ -2,13 +2,19 @@ import re import sys import textwrap +from typing import Dict +from typing import Generator +from typing import Type + +import py import pytest from _pytest.monkeypatch import MonkeyPatch +from _pytest.pytester import Testdir @pytest.fixture -def mp(): +def mp() -> Generator[MonkeyPatch, None, None]: cwd = os.getcwd() sys_path = list(sys.path) yield MonkeyPatch() @@ -16,14 +22,14 @@ def mp(): os.chdir(cwd) -def test_setattr(): +def test_setattr() -> None: class A: x = 1 monkeypatch = MonkeyPatch() pytest.raises(AttributeError, monkeypatch.setattr, A, "notexists", 2) monkeypatch.setattr(A, "y", 2, raising=False) - assert A.y == 2 + assert A.y == 2 # type: ignore monkeypatch.undo() assert not hasattr(A, "y") @@ -39,49 +45,53 @@ class A: monkeypatch.undo() # double-undo makes no modification assert A.x == 5 + with pytest.raises(TypeError): + monkeypatch.setattr(A, "y") # type: ignore[call-overload] + class TestSetattrWithImportPath: - def test_string_expression(self, monkeypatch): + def test_string_expression(self, monkeypatch: MonkeyPatch) -> None: monkeypatch.setattr("os.path.abspath", lambda x: "hello2") assert os.path.abspath("123") == "hello2" - def test_string_expression_class(self, monkeypatch): + def test_string_expression_class(self, monkeypatch: MonkeyPatch) -> None: monkeypatch.setattr("_pytest.config.Config", 42) import _pytest - assert _pytest.config.Config == 42 + assert _pytest.config.Config == 42 # type: ignore - def test_unicode_string(self, monkeypatch): + def test_unicode_string(self, monkeypatch: MonkeyPatch) -> None: monkeypatch.setattr("_pytest.config.Config", 42) import _pytest - assert _pytest.config.Config == 42 + assert _pytest.config.Config == 42 # type: ignore monkeypatch.delattr("_pytest.config.Config") - def test_wrong_target(self, monkeypatch): - pytest.raises(TypeError, lambda: monkeypatch.setattr(None, None)) + def test_wrong_target(self, monkeypatch: MonkeyPatch) -> None: + with pytest.raises(TypeError): + monkeypatch.setattr(None, None) # type: ignore[call-overload] - def test_unknown_import(self, monkeypatch): - pytest.raises(ImportError, lambda: monkeypatch.setattr("unkn123.classx", None)) + def test_unknown_import(self, monkeypatch: MonkeyPatch) -> None: + with pytest.raises(ImportError): + monkeypatch.setattr("unkn123.classx", None) - def test_unknown_attr(self, monkeypatch): - pytest.raises( - AttributeError, lambda: monkeypatch.setattr("os.path.qweqwe", None) - ) + def test_unknown_attr(self, monkeypatch: MonkeyPatch) -> None: + with pytest.raises(AttributeError): + monkeypatch.setattr("os.path.qweqwe", None) - def test_unknown_attr_non_raising(self, monkeypatch): + def test_unknown_attr_non_raising(self, monkeypatch: MonkeyPatch) -> None: # https://github.com/pytest-dev/pytest/issues/746 monkeypatch.setattr("os.path.qweqwe", 42, raising=False) - assert os.path.qweqwe == 42 + assert os.path.qweqwe == 42 # type: ignore - def test_delattr(self, monkeypatch): + def test_delattr(self, monkeypatch: MonkeyPatch) -> None: monkeypatch.delattr("os.path.abspath") assert not hasattr(os.path, "abspath") monkeypatch.undo() assert os.path.abspath -def test_delattr(): +def test_delattr() -> None: class A: x = 1 @@ -101,7 +111,7 @@ class A: assert A.x == 1 -def test_setitem(): +def test_setitem() -> None: d = {"x": 1} monkeypatch = MonkeyPatch() monkeypatch.setitem(d, "x", 2) @@ -119,8 +129,8 @@ def test_setitem(): assert d["x"] == 5 -def test_setitem_deleted_meanwhile(): - d = {} +def test_setitem_deleted_meanwhile() -> None: + d: Dict[str, object] = {} monkeypatch = MonkeyPatch() monkeypatch.setitem(d, "x", 2) del d["x"] @@ -129,7 +139,7 @@ def test_setitem_deleted_meanwhile(): @pytest.mark.parametrize("before", [True, False]) -def test_setenv_deleted_meanwhile(before): +def test_setenv_deleted_meanwhile(before: bool) -> None: key = "qwpeoip123" if before: os.environ[key] = "world" @@ -144,8 +154,8 @@ def test_setenv_deleted_meanwhile(before): assert key not in os.environ -def test_delitem(): - d = {"x": 1} +def test_delitem() -> None: + d: Dict[str, object] = {"x": 1} monkeypatch = MonkeyPatch() monkeypatch.delitem(d, "x") assert "x" not in d @@ -161,10 +171,10 @@ def test_delitem(): assert d == {"hello": "world", "x": 1} -def test_setenv(): +def test_setenv() -> None: monkeypatch = MonkeyPatch() with pytest.warns(pytest.PytestWarning): - monkeypatch.setenv("XYZ123", 2) + monkeypatch.setenv("XYZ123", 2) # type: ignore[arg-type] import os assert os.environ["XYZ123"] == "2" @@ -172,7 +182,7 @@ def test_setenv(): assert "XYZ123" not in os.environ -def test_delenv(): +def test_delenv() -> None: name = "xyz1234" assert name not in os.environ monkeypatch = MonkeyPatch() @@ -202,31 +212,28 @@ class TestEnvironWarnings: VAR_NAME = "PYTEST_INTERNAL_MY_VAR" - def test_setenv_non_str_warning(self, monkeypatch): + def test_setenv_non_str_warning(self, monkeypatch: MonkeyPatch) -> None: value = 2 msg = ( "Value of environment variable PYTEST_INTERNAL_MY_VAR type should be str, " "but got 2 (type: int); converted to str implicitly" ) with pytest.warns(pytest.PytestWarning, match=re.escape(msg)): - monkeypatch.setenv(str(self.VAR_NAME), value) + monkeypatch.setenv(str(self.VAR_NAME), value) # type: ignore[arg-type] -def test_setenv_prepend(): +def test_setenv_prepend() -> None: import os monkeypatch = MonkeyPatch() - with pytest.warns(pytest.PytestWarning): - monkeypatch.setenv("XYZ123", 2, prepend="-") - assert os.environ["XYZ123"] == "2" - with pytest.warns(pytest.PytestWarning): - monkeypatch.setenv("XYZ123", 3, prepend="-") + monkeypatch.setenv("XYZ123", "2", prepend="-") + monkeypatch.setenv("XYZ123", "3", prepend="-") assert os.environ["XYZ123"] == "3-2" monkeypatch.undo() assert "XYZ123" not in os.environ -def test_monkeypatch_plugin(testdir): +def test_monkeypatch_plugin(testdir: Testdir) -> None: reprec = testdir.inline_runsource( """ def test_method(monkeypatch): @@ -237,7 +244,7 @@ def test_method(monkeypatch): assert tuple(res) == (1, 0, 0), res -def test_syspath_prepend(mp): +def test_syspath_prepend(mp: MonkeyPatch) -> None: old = list(sys.path) mp.syspath_prepend("world") mp.syspath_prepend("hello") @@ -249,7 +256,7 @@ def test_syspath_prepend(mp): assert sys.path == old -def test_syspath_prepend_double_undo(mp): +def test_syspath_prepend_double_undo(mp: MonkeyPatch) -> None: old_syspath = sys.path[:] try: mp.syspath_prepend("hello world") @@ -261,24 +268,24 @@ def test_syspath_prepend_double_undo(mp): sys.path[:] = old_syspath -def test_chdir_with_path_local(mp, tmpdir): +def test_chdir_with_path_local(mp: MonkeyPatch, tmpdir: py.path.local) -> None: mp.chdir(tmpdir) assert os.getcwd() == tmpdir.strpath -def test_chdir_with_str(mp, tmpdir): +def test_chdir_with_str(mp: MonkeyPatch, tmpdir: py.path.local) -> None: mp.chdir(tmpdir.strpath) assert os.getcwd() == tmpdir.strpath -def test_chdir_undo(mp, tmpdir): +def test_chdir_undo(mp: MonkeyPatch, tmpdir: py.path.local) -> None: cwd = os.getcwd() mp.chdir(tmpdir) mp.undo() assert os.getcwd() == cwd -def test_chdir_double_undo(mp, tmpdir): +def test_chdir_double_undo(mp: MonkeyPatch, tmpdir: py.path.local) -> None: mp.chdir(tmpdir.strpath) mp.undo() tmpdir.chdir() @@ -286,7 +293,7 @@ def test_chdir_double_undo(mp, tmpdir): assert os.getcwd() == tmpdir.strpath -def test_issue185_time_breaks(testdir): +def test_issue185_time_breaks(testdir: Testdir) -> None: testdir.makepyfile( """ import time @@ -304,7 +311,7 @@ def f(): ) -def test_importerror(testdir): +def test_importerror(testdir: Testdir) -> None: p = testdir.mkpydir("package") p.join("a.py").write( textwrap.dedent( @@ -331,43 +338,30 @@ def test_importerror(monkeypatch): ) -class SampleNew: +class Sample: @staticmethod - def hello(): + def hello() -> bool: return True -class SampleNewInherit(SampleNew): - pass - - -class SampleOld: - # oldstyle on python2 - @staticmethod - def hello(): - return True - - -class SampleOldInherit(SampleOld): +class SampleInherit(Sample): pass @pytest.mark.parametrize( - "Sample", - [SampleNew, SampleNewInherit, SampleOld, SampleOldInherit], - ids=["new", "new-inherit", "old", "old-inherit"], + "Sample", [Sample, SampleInherit], ids=["new", "new-inherit"], ) -def test_issue156_undo_staticmethod(Sample): +def test_issue156_undo_staticmethod(Sample: Type[Sample]) -> None: monkeypatch = MonkeyPatch() monkeypatch.setattr(Sample, "hello", None) assert Sample.hello is None - monkeypatch.undo() + monkeypatch.undo() # type: ignore[unreachable] assert Sample.hello() -def test_undo_class_descriptors_delattr(): +def test_undo_class_descriptors_delattr() -> None: class SampleParent: @classmethod def hello(_cls): @@ -394,7 +388,7 @@ class SampleChild(SampleParent): assert original_world == SampleChild.world -def test_issue1338_name_resolving(): +def test_issue1338_name_resolving() -> None: pytest.importorskip("requests") monkeypatch = MonkeyPatch() try: @@ -403,7 +397,7 @@ def test_issue1338_name_resolving(): monkeypatch.undo() -def test_context(): +def test_context() -> None: monkeypatch = MonkeyPatch() import functools @@ -415,7 +409,19 @@ def test_context(): assert inspect.isclass(functools.partial) -def test_syspath_prepend_with_namespace_packages(testdir, monkeypatch): +def test_context_classmethod() -> None: + class A: + x = 1 + + with MonkeyPatch.context() as m: + m.setattr(A, "x", 2) + assert A.x == 2 + assert A.x == 1 + + +def test_syspath_prepend_with_namespace_packages( + testdir: Testdir, monkeypatch: MonkeyPatch +) -> None: for dirname in "hello", "world": d = testdir.mkdir(dirname) ns = d.mkdir("ns_pkg") diff --git a/testing/test_nodes.py b/testing/test_nodes.py index dbb3e2e8f64..f3824c57090 100644 --- a/testing/test_nodes.py +++ b/testing/test_nodes.py @@ -1,60 +1,113 @@ +from typing import List +from typing import Type + import py import pytest from _pytest import nodes +from _pytest.pytester import Pytester +from _pytest.warning_types import PytestWarning @pytest.mark.parametrize( - "baseid, nodeid, expected", + ("nodeid", "expected"), ( - ("", "", True), - ("", "foo", True), - ("", "foo/bar", True), - ("", "foo/bar::TestBaz", True), - ("foo", "food", False), - ("foo/bar::TestBaz", "foo/bar", False), - ("foo/bar::TestBaz", "foo/bar::TestBop", False), - ("foo/bar", "foo/bar::TestBop", True), + ("", [""]), + ("a", ["", "a"]), + ("aa/b", ["", "aa", "aa/b"]), + ("a/b/c", ["", "a", "a/b", "a/b/c"]), + ("a/bbb/c::D", ["", "a", "a/bbb", "a/bbb/c", "a/bbb/c::D"]), + ("a/b/c::D::eee", ["", "a", "a/b", "a/b/c", "a/b/c::D", "a/b/c::D::eee"]), + # :: considered only at the last component. + ("::xx", ["", "::xx"]), + ("a/b/c::D/d::e", ["", "a", "a/b", "a/b/c::D", "a/b/c::D/d", "a/b/c::D/d::e"]), + # : alone is not a separator. + ("a/b::D:e:f::g", ["", "a", "a/b", "a/b::D:e:f", "a/b::D:e:f::g"]), ), ) -def test_ischildnode(baseid, nodeid, expected): - result = nodes.ischildnode(baseid, nodeid) - assert result is expected +def test_iterparentnodeids(nodeid: str, expected: List[str]) -> None: + result = list(nodes.iterparentnodeids(nodeid)) + assert result == expected -def test_node_from_parent_disallowed_arguments(): +def test_node_from_parent_disallowed_arguments() -> None: with pytest.raises(TypeError, match="session is"): - nodes.Node.from_parent(None, session=None) + nodes.Node.from_parent(None, session=None) # type: ignore[arg-type] with pytest.raises(TypeError, match="config is"): - nodes.Node.from_parent(None, config=None) + nodes.Node.from_parent(None, config=None) # type: ignore[arg-type] + + +@pytest.mark.parametrize( + "warn_type, msg", [(DeprecationWarning, "deprecated"), (PytestWarning, "pytest")] +) +def test_node_warn_is_no_longer_only_pytest_warnings( + pytester: Pytester, warn_type: Type[Warning], msg: str +) -> None: + items = pytester.getitems( + """ + def test(): + pass + """ + ) + with pytest.warns(warn_type, match=msg): + items[0].warn(warn_type(msg)) -def test_std_warn_not_pytestwarning(testdir): - items = testdir.getitems( +def test_node_warning_enforces_warning_types(pytester: Pytester) -> None: + items = pytester.getitems( """ def test(): pass """ ) - with pytest.raises(ValueError, match=".*instance of PytestWarning.*"): - items[0].warn(UserWarning("some warning")) + with pytest.raises( + ValueError, match="warning must be an instance of Warning or subclass" + ): + items[0].warn(Exception("ok")) # type: ignore[arg-type] -def test__check_initialpaths_for_relpath(): +def test__check_initialpaths_for_relpath() -> None: """Ensure that it handles dirs, and does not always use dirname.""" cwd = py.path.local() - class FakeSession: + class FakeSession1: _initialpaths = [cwd] - assert nodes._check_initialpaths_for_relpath(FakeSession, cwd) == "" + assert nodes._check_initialpaths_for_relpath(FakeSession1, cwd) == "" sub = cwd.join("file") - class FakeSession: + class FakeSession2: _initialpaths = [cwd] - assert nodes._check_initialpaths_for_relpath(FakeSession, sub) == "file" + assert nodes._check_initialpaths_for_relpath(FakeSession2, sub) == "file" outside = py.path.local("/outside") - assert nodes._check_initialpaths_for_relpath(FakeSession, outside) is None + assert nodes._check_initialpaths_for_relpath(FakeSession2, outside) is None + + +def test_failure_with_changed_cwd(pytester: Pytester) -> None: + """ + Test failure lines should use absolute paths if cwd has changed since + invocation, so the path is correct (#6428). + """ + p = pytester.makepyfile( + """ + import os + import pytest + + @pytest.fixture + def private_dir(): + out_dir = 'ddd' + os.mkdir(out_dir) + old_dir = os.getcwd() + os.chdir(out_dir) + yield out_dir + os.chdir(old_dir) + + def test_show_wrong_path(private_dir): + assert False + """ + ) + result = pytester.runpytest() + result.stdout.fnmatch_lines([str(p) + ":*: AssertionError", "*1 failed in *"]) diff --git a/testing/test_nose.py b/testing/test_nose.py index b6200c6c9ad..13429afafd4 100644 --- a/testing/test_nose.py +++ b/testing/test_nose.py @@ -1,12 +1,13 @@ import pytest +from _pytest.pytester import Pytester def setup_module(mod): mod.nose = pytest.importorskip("nose") -def test_nose_setup(testdir): - p = testdir.makepyfile( +def test_nose_setup(pytester: Pytester) -> None: + p = pytester.makepyfile( """ values = [] from nose.tools import with_setup @@ -22,11 +23,11 @@ def test_world(): test_hello.teardown = lambda: values.append(2) """ ) - result = testdir.runpytest(p, "-p", "nose") + result = pytester.runpytest(p, "-p", "nose") result.assert_outcomes(passed=2) -def test_setup_func_with_setup_decorator(): +def test_setup_func_with_setup_decorator() -> None: from _pytest.nose import call_optional values = [] @@ -40,7 +41,7 @@ def f(self): assert not values -def test_setup_func_not_callable(): +def test_setup_func_not_callable() -> None: from _pytest.nose import call_optional class A: @@ -49,8 +50,8 @@ class A: call_optional(A(), "f") -def test_nose_setup_func(testdir): - p = testdir.makepyfile( +def test_nose_setup_func(pytester: Pytester) -> None: + p = pytester.makepyfile( """ from nose.tools import with_setup @@ -75,12 +76,12 @@ def test_world(): """ ) - result = testdir.runpytest(p, "-p", "nose") + result = pytester.runpytest(p, "-p", "nose") result.assert_outcomes(passed=2) -def test_nose_setup_func_failure(testdir): - p = testdir.makepyfile( +def test_nose_setup_func_failure(pytester: Pytester) -> None: + p = pytester.makepyfile( """ from nose.tools import with_setup @@ -99,12 +100,12 @@ def test_world(): """ ) - result = testdir.runpytest(p, "-p", "nose") + result = pytester.runpytest(p, "-p", "nose") result.stdout.fnmatch_lines(["*TypeError: ()*"]) -def test_nose_setup_func_failure_2(testdir): - testdir.makepyfile( +def test_nose_setup_func_failure_2(pytester: Pytester) -> None: + pytester.makepyfile( """ values = [] @@ -118,13 +119,13 @@ def test_hello(): test_hello.teardown = my_teardown """ ) - reprec = testdir.inline_run() + reprec = pytester.inline_run() reprec.assertoutcome(passed=1) -def test_nose_setup_partial(testdir): +def test_nose_setup_partial(pytester: Pytester) -> None: pytest.importorskip("functools") - p = testdir.makepyfile( + p = pytester.makepyfile( """ from functools import partial @@ -153,12 +154,12 @@ def test_world(): test_hello.teardown = my_teardown_partial """ ) - result = testdir.runpytest(p, "-p", "nose") + result = pytester.runpytest(p, "-p", "nose") result.stdout.fnmatch_lines(["*2 passed*"]) -def test_module_level_setup(testdir): - testdir.makepyfile( +def test_module_level_setup(pytester: Pytester) -> None: + pytester.makepyfile( """ from nose.tools import with_setup items = {} @@ -184,12 +185,12 @@ def test_local_setup(): assert 1 not in items """ ) - result = testdir.runpytest("-p", "nose") + result = pytester.runpytest("-p", "nose") result.stdout.fnmatch_lines(["*2 passed*"]) -def test_nose_style_setup_teardown(testdir): - testdir.makepyfile( +def test_nose_style_setup_teardown(pytester: Pytester) -> None: + pytester.makepyfile( """ values = [] @@ -206,12 +207,12 @@ def test_world(): assert values == [1] """ ) - result = testdir.runpytest("-p", "nose") + result = pytester.runpytest("-p", "nose") result.stdout.fnmatch_lines(["*2 passed*"]) -def test_nose_setup_ordering(testdir): - testdir.makepyfile( +def test_nose_setup_ordering(pytester: Pytester) -> None: + pytester.makepyfile( """ def setup_module(mod): mod.visited = True @@ -223,14 +224,14 @@ def test_first(self): pass """ ) - result = testdir.runpytest() + result = pytester.runpytest() result.stdout.fnmatch_lines(["*1 passed*"]) -def test_apiwrapper_problem_issue260(testdir): +def test_apiwrapper_problem_issue260(pytester: Pytester) -> None: # this would end up trying a call an optional teardown on the class # for plain unittests we don't want nose behaviour - testdir.makepyfile( + pytester.makepyfile( """ import unittest class TestCase(unittest.TestCase): @@ -248,14 +249,14 @@ def test_fun(self): pass """ ) - result = testdir.runpytest() + result = pytester.runpytest() result.assert_outcomes(passed=1) -def test_setup_teardown_linking_issue265(testdir): +def test_setup_teardown_linking_issue265(pytester: Pytester) -> None: # we accidentally didn't integrate nose setupstate with normal setupstate # this test ensures that won't happen again - testdir.makepyfile( + pytester.makepyfile( ''' import pytest @@ -276,12 +277,12 @@ def teardown(self): raise Exception("should not call teardown for skipped tests") ''' ) - reprec = testdir.runpytest() + reprec = pytester.runpytest() reprec.assert_outcomes(passed=1, skipped=1) -def test_SkipTest_during_collection(testdir): - p = testdir.makepyfile( +def test_SkipTest_during_collection(pytester: Pytester) -> None: + p = pytester.makepyfile( """ import nose raise nose.SkipTest("during collection") @@ -289,12 +290,12 @@ def test_failing(): assert False """ ) - result = testdir.runpytest(p) + result = pytester.runpytest(p) result.assert_outcomes(skipped=1) -def test_SkipTest_in_test(testdir): - testdir.makepyfile( +def test_SkipTest_in_test(pytester: Pytester) -> None: + pytester.makepyfile( """ import nose @@ -302,12 +303,12 @@ def test_skipping(): raise nose.SkipTest("in test") """ ) - reprec = testdir.inline_run() + reprec = pytester.inline_run() reprec.assertoutcome(skipped=1) -def test_istest_function_decorator(testdir): - p = testdir.makepyfile( +def test_istest_function_decorator(pytester: Pytester) -> None: + p = pytester.makepyfile( """ import nose.tools @nose.tools.istest @@ -315,12 +316,12 @@ def not_test_prefix(): pass """ ) - result = testdir.runpytest(p) + result = pytester.runpytest(p) result.assert_outcomes(passed=1) -def test_nottest_function_decorator(testdir): - testdir.makepyfile( +def test_nottest_function_decorator(pytester: Pytester) -> None: + pytester.makepyfile( """ import nose.tools @nose.tools.nottest @@ -328,14 +329,14 @@ def test_prefix(): pass """ ) - reprec = testdir.inline_run() + reprec = pytester.inline_run() assert not reprec.getfailedcollections() calls = reprec.getreports("pytest_runtest_logreport") assert not calls -def test_istest_class_decorator(testdir): - p = testdir.makepyfile( +def test_istest_class_decorator(pytester: Pytester) -> None: + p = pytester.makepyfile( """ import nose.tools @nose.tools.istest @@ -344,12 +345,12 @@ def test_method(self): pass """ ) - result = testdir.runpytest(p) + result = pytester.runpytest(p) result.assert_outcomes(passed=1) -def test_nottest_class_decorator(testdir): - testdir.makepyfile( +def test_nottest_class_decorator(pytester: Pytester) -> None: + pytester.makepyfile( """ import nose.tools @nose.tools.nottest @@ -358,14 +359,14 @@ def test_method(self): pass """ ) - reprec = testdir.inline_run() + reprec = pytester.inline_run() assert not reprec.getfailedcollections() calls = reprec.getreports("pytest_runtest_logreport") assert not calls -def test_skip_test_with_unicode(testdir): - testdir.makepyfile( +def test_skip_test_with_unicode(pytester: Pytester) -> None: + pytester.makepyfile( """\ import unittest class TestClass(): @@ -373,12 +374,12 @@ def test_io(self): raise unittest.SkipTest('😊') """ ) - result = testdir.runpytest() + result = pytester.runpytest() result.stdout.fnmatch_lines(["* 1 skipped *"]) -def test_raises(testdir): - testdir.makepyfile( +def test_raises(pytester: Pytester) -> None: + pytester.makepyfile( """ from nose.tools import raises @@ -395,7 +396,7 @@ def test_raises_baseexception_caught(): raise BaseException """ ) - result = testdir.runpytest("-vv") + result = pytester.runpytest("-vv") result.stdout.fnmatch_lines( [ "test_raises.py::test_raises_runtimeerror PASSED*", diff --git a/testing/test_parseopt.py b/testing/test_parseopt.py index 8cfb9e4a9cf..a124009c401 100644 --- a/testing/test_parseopt.py +++ b/testing/test_parseopt.py @@ -1,7 +1,7 @@ import argparse import os import shlex -import shutil +import subprocess import sys import py @@ -9,6 +9,8 @@ import pytest from _pytest.config import argparsing as parseopt from _pytest.config.exceptions import UsageError +from _pytest.monkeypatch import MonkeyPatch +from _pytest.pytester import Pytester @pytest.fixture @@ -287,10 +289,22 @@ def test_multiple_metavar_help(self, parser: parseopt.Parser) -> None: assert "--preferences=value1 value2 value3" in help -def test_argcomplete(testdir, monkeypatch) -> None: - if not shutil.which("bash"): - pytest.skip("bash not available") - script = str(testdir.tmpdir.join("test_argcomplete")) +def test_argcomplete(pytester: Pytester, monkeypatch: MonkeyPatch) -> None: + try: + bash_version = subprocess.run( + ["bash", "--version"], + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + check=True, + universal_newlines=True, + ).stdout + except (OSError, subprocess.CalledProcessError): + pytest.skip("bash is not available") + if "GNU bash" not in bash_version: + # See #7518. + pytest.skip("not a real bash") + + script = str(pytester.path.joinpath("test_argcomplete")) with open(str(script), "w") as fp: # redirect output from argcomplete to stdin and stderr is not trivial @@ -311,7 +325,7 @@ def test_argcomplete(testdir, monkeypatch) -> None: arg = "--fu" monkeypatch.setenv("COMP_LINE", "pytest " + arg) monkeypatch.setenv("COMP_POINT", str(len("pytest " + arg))) - result = testdir.run("bash", str(script), arg) + result = pytester.run("bash", str(script), arg) if result.ret == 255: # argcomplete not found pytest.skip("argcomplete not available") @@ -327,5 +341,5 @@ def test_argcomplete(testdir, monkeypatch) -> None: arg = "test_argc" monkeypatch.setenv("COMP_LINE", "pytest " + arg) monkeypatch.setenv("COMP_POINT", str(len("pytest " + arg))) - result = testdir.run("bash", str(script), arg) + result = pytester.run("bash", str(script), arg) result.stdout.fnmatch_lines(["test_argcomplete", "test_argcomplete.d/"]) diff --git a/testing/test_pastebin.py b/testing/test_pastebin.py index 86a42f9e8a1..eaa9e7511c7 100644 --- a/testing/test_pastebin.py +++ b/testing/test_pastebin.py @@ -1,19 +1,25 @@ +import io +from typing import List +from typing import Union + import pytest +from _pytest.monkeypatch import MonkeyPatch +from _pytest.pytester import Pytester class TestPasteCapture: @pytest.fixture - def pastebinlist(self, monkeypatch, request): - pastebinlist = [] + def pastebinlist(self, monkeypatch, request) -> List[Union[str, bytes]]: + pastebinlist: List[Union[str, bytes]] = [] plugin = request.config.pluginmanager.getplugin("pastebin") monkeypatch.setattr(plugin, "create_new_paste", pastebinlist.append) return pastebinlist - def test_failed(self, testdir, pastebinlist): - testpath = testdir.makepyfile( + def test_failed(self, pytester: Pytester, pastebinlist) -> None: + testpath = pytester.makepyfile( """ import pytest - def test_pass(): + def test_pass() -> None: pass def test_fail(): assert 0 @@ -21,16 +27,16 @@ def test_skip(): pytest.skip("") """ ) - reprec = testdir.inline_run(testpath, "--pastebin=failed") + reprec = pytester.inline_run(testpath, "--pastebin=failed") assert len(pastebinlist) == 1 s = pastebinlist[0] assert s.find("def test_fail") != -1 assert reprec.countoutcomes() == [1, 1, 1] - def test_all(self, testdir, pastebinlist): + def test_all(self, pytester: Pytester, pastebinlist) -> None: from _pytest.pytester import LineMatcher - testpath = testdir.makepyfile( + testpath = pytester.makepyfile( """ import pytest def test_pass(): @@ -41,7 +47,7 @@ def test_skip(): pytest.skip("") """ ) - reprec = testdir.inline_run(testpath, "--pastebin=all", "-v") + reprec = pytester.inline_run(testpath, "--pastebin=all", "-v") assert reprec.countoutcomes() == [1, 1, 1] assert len(pastebinlist) == 1 contents = pastebinlist[0].decode("utf-8") @@ -55,17 +61,17 @@ def test_skip(): ] ) - def test_non_ascii_paste_text(self, testdir, pastebinlist): + def test_non_ascii_paste_text(self, pytester: Pytester, pastebinlist) -> None: """Make sure that text which contains non-ascii characters is pasted correctly. See #1219. """ - testdir.makepyfile( + pytester.makepyfile( test_unicode="""\ def test(): assert '☺' == 1 """ ) - result = testdir.runpytest("--pastebin=all") + result = pytester.runpytest("--pastebin=all") expected_msg = "*assert '☺' == 1*" result.stdout.fnmatch_lines( [ @@ -83,10 +89,8 @@ def pastebin(self, request): return request.config.pluginmanager.getplugin("pastebin") @pytest.fixture - def mocked_urlopen_fail(self, monkeypatch): - """ - monkeypatch the actual urlopen call to emulate a HTTP Error 400 - """ + def mocked_urlopen_fail(self, monkeypatch: MonkeyPatch): + """Monkeypatch the actual urlopen call to emulate a HTTP Error 400.""" calls = [] import urllib.error @@ -94,18 +98,16 @@ def mocked_urlopen_fail(self, monkeypatch): def mocked(url, data): calls.append((url, data)) - raise urllib.error.HTTPError(url, 400, "Bad request", None, None) + raise urllib.error.HTTPError(url, 400, "Bad request", {}, io.BytesIO()) monkeypatch.setattr(urllib.request, "urlopen", mocked) return calls @pytest.fixture - def mocked_urlopen_invalid(self, monkeypatch): - """ - monkeypatch the actual urlopen calls done by the internal plugin + def mocked_urlopen_invalid(self, monkeypatch: MonkeyPatch): + """Monkeypatch the actual urlopen calls done by the internal plugin function that connects to bpaste service, but return a url in an - unexpected format - """ + unexpected format.""" calls = [] def mocked(url, data): @@ -124,11 +126,9 @@ def read(self): return calls @pytest.fixture - def mocked_urlopen(self, monkeypatch): - """ - monkeypatch the actual urlopen calls done by the internal plugin - function that connects to bpaste service. - """ + def mocked_urlopen(self, monkeypatch: MonkeyPatch): + """Monkeypatch the actual urlopen calls done by the internal plugin + function that connects to bpaste service.""" calls = [] def mocked(url, data): @@ -146,7 +146,7 @@ def read(self): monkeypatch.setattr(urllib.request, "urlopen", mocked) return calls - def test_pastebin_invalid_url(self, pastebin, mocked_urlopen_invalid): + def test_pastebin_invalid_url(self, pastebin, mocked_urlopen_invalid) -> None: result = pastebin.create_new_paste(b"full-paste-contents") assert ( result @@ -154,12 +154,12 @@ def test_pastebin_invalid_url(self, pastebin, mocked_urlopen_invalid): ) assert len(mocked_urlopen_invalid) == 1 - def test_pastebin_http_error(self, pastebin, mocked_urlopen_fail): + def test_pastebin_http_error(self, pastebin, mocked_urlopen_fail) -> None: result = pastebin.create_new_paste(b"full-paste-contents") assert result == "bad response: HTTP Error 400: Bad request" assert len(mocked_urlopen_fail) == 1 - def test_create_new_paste(self, pastebin, mocked_urlopen): + def test_create_new_paste(self, pastebin, mocked_urlopen) -> None: result = pastebin.create_new_paste(b"full-paste-contents") assert result == "https://bpaste.net/show/3c0c6750bd" assert len(mocked_urlopen) == 1 @@ -171,7 +171,7 @@ def test_create_new_paste(self, pastebin, mocked_urlopen): assert "code=full-paste-contents" in data.decode() assert "expiry=1week" in data.decode() - def test_create_new_paste_failure(self, pastebin, monkeypatch): + def test_create_new_paste_failure(self, pastebin, monkeypatch: MonkeyPatch) -> None: import io import urllib.request diff --git a/testing/test_pathlib.py b/testing/test_pathlib.py index 45daeaed76a..f60b9f26369 100644 --- a/testing/test_pathlib.py +++ b/testing/test_pathlib.py @@ -1,19 +1,30 @@ import os.path import sys +import unittest.mock +from pathlib import Path +from textwrap import dedent import py import pytest +from _pytest.monkeypatch import MonkeyPatch +from _pytest.pathlib import bestrelpath +from _pytest.pathlib import commonpath +from _pytest.pathlib import ensure_deletable from _pytest.pathlib import fnmatch_ex +from _pytest.pathlib import get_extended_length_path_str from _pytest.pathlib import get_lock_path +from _pytest.pathlib import import_path +from _pytest.pathlib import ImportPathMismatchError from _pytest.pathlib import maybe_delete_a_numbered_dir -from _pytest.pathlib import Path +from _pytest.pathlib import resolve_package_path +from _pytest.pathlib import symlink_or_skip +from _pytest.pathlib import visit -class TestPort: - """Test that our port of py.common.FNMatcher (fnmatch_ex) produces the same results as the - original py.path.local.fnmatch method. - """ +class TestFNMatcherPort: + """Test that our port of py.common.FNMatcher (fnmatch_ex) produces the + same results as the original py.path.local.fnmatch method.""" @pytest.fixture(params=["pathlib", "py.path"]) def match(self, request): @@ -76,6 +87,242 @@ def test_not_matching(self, match, pattern, path): assert not match(pattern, path) +class TestImportPath: + """ + + Most of the tests here were copied from py lib's tests for "py.local.path.pyimport". + + Having our own pyimport-like function is inline with removing py.path dependency in the future. + """ + + @pytest.fixture(scope="session") + def path1(self, tmpdir_factory): + path = tmpdir_factory.mktemp("path") + self.setuptestfs(path) + yield path + assert path.join("samplefile").check() + + def setuptestfs(self, path): + # print "setting up test fs for", repr(path) + samplefile = path.ensure("samplefile") + samplefile.write("samplefile\n") + + execfile = path.ensure("execfile") + execfile.write("x=42") + + execfilepy = path.ensure("execfile.py") + execfilepy.write("x=42") + + d = {1: 2, "hello": "world", "answer": 42} + path.ensure("samplepickle").dump(d) + + sampledir = path.ensure("sampledir", dir=1) + sampledir.ensure("otherfile") + + otherdir = path.ensure("otherdir", dir=1) + otherdir.ensure("__init__.py") + + module_a = otherdir.ensure("a.py") + module_a.write("from .b import stuff as result\n") + module_b = otherdir.ensure("b.py") + module_b.write('stuff="got it"\n') + module_c = otherdir.ensure("c.py") + module_c.write( + dedent( + """ + import py; + import otherdir.a + value = otherdir.a.result + """ + ) + ) + module_d = otherdir.ensure("d.py") + module_d.write( + dedent( + """ + import py; + from otherdir import a + value2 = a.result + """ + ) + ) + + def test_smoke_test(self, path1): + obj = import_path(path1.join("execfile.py")) + assert obj.x == 42 # type: ignore[attr-defined] + assert obj.__name__ == "execfile" + + def test_renamed_dir_creates_mismatch(self, tmpdir, monkeypatch): + p = tmpdir.ensure("a", "test_x123.py") + import_path(p) + tmpdir.join("a").move(tmpdir.join("b")) + with pytest.raises(ImportPathMismatchError): + import_path(tmpdir.join("b", "test_x123.py")) + + # Errors can be ignored. + monkeypatch.setenv("PY_IGNORE_IMPORTMISMATCH", "1") + import_path(tmpdir.join("b", "test_x123.py")) + + # PY_IGNORE_IMPORTMISMATCH=0 does not ignore error. + monkeypatch.setenv("PY_IGNORE_IMPORTMISMATCH", "0") + with pytest.raises(ImportPathMismatchError): + import_path(tmpdir.join("b", "test_x123.py")) + + def test_messy_name(self, tmpdir): + # http://bitbucket.org/hpk42/py-trunk/issue/129 + path = tmpdir.ensure("foo__init__.py") + module = import_path(path) + assert module.__name__ == "foo__init__" + + def test_dir(self, tmpdir): + p = tmpdir.join("hello_123") + p_init = p.ensure("__init__.py") + m = import_path(p) + assert m.__name__ == "hello_123" + m = import_path(p_init) + assert m.__name__ == "hello_123" + + def test_a(self, path1): + otherdir = path1.join("otherdir") + mod = import_path(otherdir.join("a.py")) + assert mod.result == "got it" # type: ignore[attr-defined] + assert mod.__name__ == "otherdir.a" + + def test_b(self, path1): + otherdir = path1.join("otherdir") + mod = import_path(otherdir.join("b.py")) + assert mod.stuff == "got it" # type: ignore[attr-defined] + assert mod.__name__ == "otherdir.b" + + def test_c(self, path1): + otherdir = path1.join("otherdir") + mod = import_path(otherdir.join("c.py")) + assert mod.value == "got it" # type: ignore[attr-defined] + + def test_d(self, path1): + otherdir = path1.join("otherdir") + mod = import_path(otherdir.join("d.py")) + assert mod.value2 == "got it" # type: ignore[attr-defined] + + def test_import_after(self, tmpdir): + tmpdir.ensure("xxxpackage", "__init__.py") + mod1path = tmpdir.ensure("xxxpackage", "module1.py") + mod1 = import_path(mod1path) + assert mod1.__name__ == "xxxpackage.module1" + from xxxpackage import module1 + + assert module1 is mod1 + + def test_check_filepath_consistency(self, monkeypatch, tmpdir): + name = "pointsback123" + ModuleType = type(os) + p = tmpdir.ensure(name + ".py") + for ending in (".pyc", ".pyo"): + mod = ModuleType(name) + pseudopath = tmpdir.ensure(name + ending) + mod.__file__ = str(pseudopath) + monkeypatch.setitem(sys.modules, name, mod) + newmod = import_path(p) + assert mod == newmod + monkeypatch.undo() + mod = ModuleType(name) + pseudopath = tmpdir.ensure(name + "123.py") + mod.__file__ = str(pseudopath) + monkeypatch.setitem(sys.modules, name, mod) + with pytest.raises(ImportPathMismatchError) as excinfo: + import_path(p) + modname, modfile, orig = excinfo.value.args + assert modname == name + assert modfile == pseudopath + assert orig == p + assert issubclass(ImportPathMismatchError, ImportError) + + def test_issue131_on__init__(self, tmpdir): + # __init__.py files may be namespace packages, and thus the + # __file__ of an imported module may not be ourselves + # see issue + p1 = tmpdir.ensure("proja", "__init__.py") + p2 = tmpdir.ensure("sub", "proja", "__init__.py") + m1 = import_path(p1) + m2 = import_path(p2) + assert m1 == m2 + + def test_ensuresyspath_append(self, tmpdir): + root1 = tmpdir.mkdir("root1") + file1 = root1.ensure("x123.py") + assert str(root1) not in sys.path + import_path(file1, mode="append") + assert str(root1) == sys.path[-1] + assert str(root1) not in sys.path[:-1] + + def test_invalid_path(self, tmpdir): + with pytest.raises(ImportError): + import_path(tmpdir.join("invalid.py")) + + @pytest.fixture + def simple_module(self, tmpdir): + fn = tmpdir.join("mymod.py") + fn.write( + dedent( + """ + def foo(x): return 40 + x + """ + ) + ) + return fn + + def test_importmode_importlib(self, simple_module): + """`importlib` mode does not change sys.path.""" + module = import_path(simple_module, mode="importlib") + assert module.foo(2) == 42 # type: ignore[attr-defined] + assert simple_module.dirname not in sys.path + + def test_importmode_twice_is_different_module(self, simple_module): + """`importlib` mode always returns a new module.""" + module1 = import_path(simple_module, mode="importlib") + module2 = import_path(simple_module, mode="importlib") + assert module1 is not module2 + + def test_no_meta_path_found(self, simple_module, monkeypatch): + """Even without any meta_path should still import module.""" + monkeypatch.setattr(sys, "meta_path", []) + module = import_path(simple_module, mode="importlib") + assert module.foo(2) == 42 # type: ignore[attr-defined] + + # mode='importlib' fails if no spec is found to load the module + import importlib.util + + monkeypatch.setattr( + importlib.util, "spec_from_file_location", lambda *args: None + ) + with pytest.raises(ImportError): + import_path(simple_module, mode="importlib") + + +def test_resolve_package_path(tmp_path): + pkg = tmp_path / "pkg1" + pkg.mkdir() + (pkg / "__init__.py").touch() + (pkg / "subdir").mkdir() + (pkg / "subdir/__init__.py").touch() + assert resolve_package_path(pkg) == pkg + assert resolve_package_path(pkg.joinpath("subdir", "__init__.py")) == pkg + + +def test_package_unimportable(tmp_path): + pkg = tmp_path / "pkg1-1" + pkg.mkdir() + pkg.joinpath("__init__.py").touch() + subdir = pkg.joinpath("subdir") + subdir.mkdir() + pkg.joinpath("subdir/__init__.py").touch() + assert resolve_package_path(subdir) == subdir + xyz = subdir.joinpath("xyz.py") + xyz.touch() + assert resolve_package_path(xyz) == subdir + assert not resolve_package_path(pkg) + + def test_access_denied_during_cleanup(tmp_path, monkeypatch): """Ensure that deleting a numbered dir does not fail because of OSErrors (#4262).""" path = tmp_path / "temp-1" @@ -89,3 +336,102 @@ def renamed_failed(*args): lock_path = get_lock_path(path) maybe_delete_a_numbered_dir(path) assert not lock_path.is_file() + + +def test_long_path_during_cleanup(tmp_path): + """Ensure that deleting long path works (particularly on Windows (#6775)).""" + path = (tmp_path / ("a" * 250)).resolve() + if sys.platform == "win32": + # make sure that the full path is > 260 characters without any + # component being over 260 characters + assert len(str(path)) > 260 + extended_path = "\\\\?\\" + str(path) + else: + extended_path = str(path) + os.mkdir(extended_path) + assert os.path.isdir(extended_path) + maybe_delete_a_numbered_dir(path) + assert not os.path.isdir(extended_path) + + +def test_get_extended_length_path_str(): + assert get_extended_length_path_str(r"c:\foo") == r"\\?\c:\foo" + assert get_extended_length_path_str(r"\\share\foo") == r"\\?\UNC\share\foo" + assert get_extended_length_path_str(r"\\?\UNC\share\foo") == r"\\?\UNC\share\foo" + assert get_extended_length_path_str(r"\\?\c:\foo") == r"\\?\c:\foo" + + +def test_suppress_error_removing_lock(tmp_path): + """ensure_deletable should be resilient if lock file cannot be removed (#5456, #7491)""" + path = tmp_path / "dir" + path.mkdir() + lock = get_lock_path(path) + lock.touch() + mtime = lock.stat().st_mtime + + with unittest.mock.patch.object(Path, "unlink", side_effect=OSError) as m: + assert not ensure_deletable( + path, consider_lock_dead_if_created_before=mtime + 30 + ) + assert m.call_count == 1 + assert lock.is_file() + + with unittest.mock.patch.object(Path, "is_file", side_effect=OSError) as m: + assert not ensure_deletable( + path, consider_lock_dead_if_created_before=mtime + 30 + ) + assert m.call_count == 1 + assert lock.is_file() + + # check now that we can remove the lock file in normal circumstances + assert ensure_deletable(path, consider_lock_dead_if_created_before=mtime + 30) + assert not lock.is_file() + + +def test_bestrelpath() -> None: + curdir = Path("/foo/bar/baz/path") + assert bestrelpath(curdir, curdir) == "." + assert bestrelpath(curdir, curdir / "hello" / "world") == "hello" + os.sep + "world" + assert bestrelpath(curdir, curdir.parent / "sister") == ".." + os.sep + "sister" + assert bestrelpath(curdir, curdir.parent) == ".." + assert bestrelpath(curdir, Path("hello")) == "hello" + + +def test_commonpath() -> None: + path = Path("/foo/bar/baz/path") + subpath = path / "sampledir" + assert commonpath(path, subpath) == path + assert commonpath(subpath, path) == path + assert commonpath(Path(str(path) + "suffix"), path) == path.parent + assert commonpath(path, path.parent.parent) == path.parent.parent + + +def test_visit_ignores_errors(tmpdir) -> None: + symlink_or_skip("recursive", tmpdir.join("recursive")) + tmpdir.join("foo").write_binary(b"") + tmpdir.join("bar").write_binary(b"") + + assert [entry.name for entry in visit(tmpdir, recurse=lambda entry: False)] == [ + "bar", + "foo", + ] + + +@pytest.mark.skipif(not sys.platform.startswith("win"), reason="Windows only") +def test_samefile_false_negatives(tmp_path: Path, monkeypatch: MonkeyPatch) -> None: + """ + import_file() should not raise ImportPathMismatchError if the paths are exactly + equal on Windows. It seems directories mounted as UNC paths make os.path.samefile + return False, even when they are clearly equal. + """ + module_path = tmp_path.joinpath("my_module.py") + module_path.write_text("def foo(): return 42") + monkeypatch.syspath_prepend(tmp_path) + + with monkeypatch.context() as mp: + # Forcibly make os.path.samefile() return False here to ensure we are comparing + # the paths too. Using a context to narrow the patch as much as possible given + # this is an important system function. + mp.setattr(os.path, "samefile", lambda x, y: False) + module = import_path(module_path) + assert getattr(module, "foo")() == 42 diff --git a/testing/test_pluginmanager.py b/testing/test_pluginmanager.py index 336f468a8c4..2099f5ae1e3 100644 --- a/testing/test_pluginmanager.py +++ b/testing/test_pluginmanager.py @@ -1,6 +1,7 @@ import os import sys import types +from typing import List import pytest from _pytest.config import ExitCode @@ -10,7 +11,7 @@ @pytest.fixture -def pytestpm(): +def pytestpm() -> PytestPluginManager: return PytestPluginManager() @@ -36,7 +37,7 @@ def pytest_myhook(xyz): pm.hook.pytest_addhooks.call_historic( kwargs=dict(pluginmanager=config.pluginmanager) ) - config.pluginmanager._importconftest(conf) + config.pluginmanager._importconftest(conf, importmode="prepend") # print(config.pluginmanager.get_plugins()) res = config.hook.pytest_myhook(xyz=10) assert res == [11] @@ -63,7 +64,7 @@ def pytest_addoption(parser): default=True) """ ) - config.pluginmanager._importconftest(p) + config.pluginmanager._importconftest(p, importmode="prepend") assert config.option.test123 def test_configure(self, testdir): @@ -86,7 +87,7 @@ def pytest_configure(self): config.pluginmanager.register(A()) assert len(values) == 2 - def test_hook_tracing(self, _config_for_test): + def test_hook_tracing(self, _config_for_test) -> None: pytestpm = _config_for_test.pluginmanager # fully initialized with plugins saveindent = [] @@ -99,7 +100,7 @@ def pytest_plugin_registered(self): saveindent.append(pytestpm.trace.root.indent) raise ValueError() - values = [] + values: List[str] = [] pytestpm.trace.root.setwriter(values.append) undo = pytestpm.enable_tracing() try: @@ -128,10 +129,10 @@ def test_hook_proxy(self, testdir): conftest1 = testdir.tmpdir.join("tests/conftest.py") conftest2 = testdir.tmpdir.join("tests/subdir/conftest.py") - config.pluginmanager._importconftest(conftest1) + config.pluginmanager._importconftest(conftest1, importmode="prepend") ihook_a = session.gethookproxy(testdir.tmpdir.join("tests")) assert ihook_a is not None - config.pluginmanager._importconftest(conftest2) + config.pluginmanager._importconftest(conftest2, importmode="prepend") ihook_b = session.gethookproxy(testdir.tmpdir.join("tests")) assert ihook_a is not ihook_b @@ -215,20 +216,20 @@ def test_canonical_import(self, monkeypatch): assert pm.get_plugin("pytest_xyz") == mod assert pm.is_registered(mod) - def test_consider_module(self, testdir, pytestpm): + def test_consider_module(self, testdir, pytestpm: PytestPluginManager) -> None: testdir.syspathinsert() testdir.makepyfile(pytest_p1="#") testdir.makepyfile(pytest_p2="#") mod = types.ModuleType("temp") - mod.pytest_plugins = ["pytest_p1", "pytest_p2"] + mod.__dict__["pytest_plugins"] = ["pytest_p1", "pytest_p2"] pytestpm.consider_module(mod) assert pytestpm.get_plugin("pytest_p1").__name__ == "pytest_p1" assert pytestpm.get_plugin("pytest_p2").__name__ == "pytest_p2" - def test_consider_module_import_module(self, testdir, _config_for_test): + def test_consider_module_import_module(self, testdir, _config_for_test) -> None: pytestpm = _config_for_test.pluginmanager mod = types.ModuleType("x") - mod.pytest_plugins = "pytest_a" + mod.__dict__["pytest_plugins"] = "pytest_a" aplugin = testdir.makepyfile(pytest_a="#") reprec = testdir.make_hook_recorder(pytestpm) testdir.syspathinsert(aplugin.dirpath()) @@ -361,10 +362,10 @@ def test_plugin_prevent_register_unregistered_alredy_registered(self, pytestpm): def test_plugin_prevent_register_stepwise_on_cacheprovider_unregister( self, pytestpm ): - """ From PR #4304 : The only way to unregister a module is documented at - the end of https://docs.pytest.org/en/latest/plugins.html. + """From PR #4304: The only way to unregister a module is documented at + the end of https://docs.pytest.org/en/stable/plugins.html. - When unregister cacheprovider, then unregister stepwise too + When unregister cacheprovider, then unregister stepwise too. """ pytestpm.register(42, name="cacheprovider") pytestpm.register(43, name="stepwise") diff --git a/testing/test_pytester.py b/testing/test_pytester.py index 959000061a5..f2e8dd5a36a 100644 --- a/testing/test_pytester.py +++ b/testing/test_pytester.py @@ -13,6 +13,7 @@ from _pytest.pytester import CwdSnapshot from _pytest.pytester import HookRecorder from _pytest.pytester import LineMatcher +from _pytest.pytester import Pytester from _pytest.pytester import SysModulesSnapshot from _pytest.pytester import SysPathsSnapshot from _pytest.pytester import Testdir @@ -23,7 +24,9 @@ def test_make_hook_recorder(testdir) -> None: recorder = testdir.make_hook_recorder(item.config.pluginmanager) assert not recorder.getfailures() - pytest.xfail("internal reportrecorder tests need refactoring") + # (The silly condition is to fool mypy that the code below this is reachable) + if 1 + 1 == 2: + pytest.xfail("internal reportrecorder tests need refactoring") class rep: excinfo = None @@ -166,18 +169,18 @@ def test_potato(): def make_holder(): class apiclass: def pytest_xyz(self, arg): - "x" + """X""" def pytest_xyz_noarg(self): - "x" + """X""" apimod = type(os)("api") def pytest_xyz(arg): - "x" + """X""" def pytest_xyz_noarg(): - "x" + """X""" apimod.pytest_xyz = pytest_xyz # type: ignore apimod.pytest_xyz_noarg = pytest_xyz_noarg # type: ignore @@ -225,7 +228,7 @@ def test_inline_run_test_module_not_cleaned_up(self, testdir) -> None: def spy_factory(self): class SysModulesSnapshotSpy: - instances = [] # type: List[SysModulesSnapshotSpy] + instances: List["SysModulesSnapshotSpy"] = [] # noqa: F821 def __init__(self, preserve=None) -> None: SysModulesSnapshotSpy.instances.append(self) @@ -406,7 +409,7 @@ def test_preserve_container(self, monkeypatch, path_type) -> None: original_data = list(getattr(sys, path_type)) original_other = getattr(sys, other_path_type) original_other_data = list(original_other) - new = [] # type: List[object] + new: List[object] = [] snapshot = SysPathsSnapshot() monkeypatch.setattr(sys, path_type, new) snapshot.restore() @@ -443,7 +446,7 @@ def test_one(): def test_unicode_args(testdir) -> None: - result = testdir.runpytest("-k", "💩") + result = testdir.runpytest("-k", "אבג") assert result.ret == ExitCode.NO_TESTS_COLLECTED @@ -484,20 +487,20 @@ def test_linematcher_with_nonlist() -> None: lm = LineMatcher([]) with pytest.raises(TypeError, match="invalid type for lines2: set"): - lm.fnmatch_lines(set()) # type: ignore[arg-type] # noqa: F821 + lm.fnmatch_lines(set()) # type: ignore[arg-type] with pytest.raises(TypeError, match="invalid type for lines2: dict"): - lm.fnmatch_lines({}) # type: ignore[arg-type] # noqa: F821 + lm.fnmatch_lines({}) # type: ignore[arg-type] with pytest.raises(TypeError, match="invalid type for lines2: set"): - lm.re_match_lines(set()) # type: ignore[arg-type] # noqa: F821 + lm.re_match_lines(set()) # type: ignore[arg-type] with pytest.raises(TypeError, match="invalid type for lines2: dict"): - lm.re_match_lines({}) # type: ignore[arg-type] # noqa: F821 + lm.re_match_lines({}) # type: ignore[arg-type] with pytest.raises(TypeError, match="invalid type for lines2: Source"): - lm.fnmatch_lines(Source()) # type: ignore[arg-type] # noqa: F821 + lm.fnmatch_lines(Source()) # type: ignore[arg-type] lm.fnmatch_lines([]) lm.fnmatch_lines(()) lm.fnmatch_lines("") - assert lm._getlines({}) == {} # type: ignore[arg-type,comparison-overlap] # noqa: F821 - assert lm._getlines(set()) == set() # type: ignore[arg-type,comparison-overlap] # noqa: F821 + assert lm._getlines({}) == {} # type: ignore[arg-type,comparison-overlap] + assert lm._getlines(set()) == set() # type: ignore[arg-type,comparison-overlap] assert lm._getlines(Source()) == [] assert lm._getlines(Source("pass\npass")) == ["pass", "pass"] @@ -578,20 +581,20 @@ def test_linematcher_no_matching(function) -> None: obtained = str(e.value).splitlines() if function == "no_fnmatch_line": assert obtained == [ - "nomatch: '{}'".format(good_pattern), + f"nomatch: '{good_pattern}'", " and: 'cachedir: .pytest_cache'", " and: 'collecting ... collected 1 item'", " and: ''", - "fnmatch: '{}'".format(good_pattern), + f"fnmatch: '{good_pattern}'", " with: 'show_fixtures_per_test.py OK'", ] else: assert obtained == [ - " nomatch: '{}'".format(good_pattern), + f" nomatch: '{good_pattern}'", " and: 'cachedir: .pytest_cache'", " and: 'collecting ... collected 1 item'", " and: ''", - "re.match: '{}'".format(good_pattern), + f"re.match: '{good_pattern}'", " with: 'show_fixtures_per_test.py OK'", ] @@ -607,6 +610,11 @@ def test_linematcher_no_matching_after_match() -> None: assert str(e.value).splitlines() == ["fnmatch: '*'", " with: '1'"] +def test_linematcher_string_api() -> None: + lm = LineMatcher(["foo", "bar"]) + assert str(lm) == "foo\nbar" + + def test_pytester_addopts_before_testdir(request, monkeypatch) -> None: orig = os.environ.get("PYTEST_ADDOPTS", None) monkeypatch.setenv("PYTEST_ADDOPTS", "--orig-unused") @@ -679,28 +687,39 @@ def test_popen_default_stdin_stderr_and_stdin_None(testdir) -> None: # stdout, stderr default to pipes, # stdin can be None to not close the pipe, avoiding # "ValueError: flush of closed file" with `communicate()`. + # + # Wraps the test to make it not hang when run with "-s". p1 = testdir.makepyfile( - """ + ''' import sys - print(sys.stdin.read()) # empty - print('stdout') - sys.stderr.write('stderr') - """ + + def test_inner(testdir): + p1 = testdir.makepyfile( + """ + import sys + print(sys.stdin.read()) # empty + print('stdout') + sys.stderr.write('stderr') + """ + ) + proc = testdir.popen([sys.executable, str(p1)], stdin=None) + stdout, stderr = proc.communicate(b"ignored") + assert stdout.splitlines() == [b"", b"stdout"] + assert stderr.splitlines() == [b"stderr"] + assert proc.returncode == 0 + ''' ) - proc = testdir.popen([sys.executable, str(p1)], stdin=None) - stdout, stderr = proc.communicate(b"ignored") - assert stdout.splitlines() == [b"", b"stdout"] - assert stderr.splitlines() == [b"stderr"] - assert proc.returncode == 0 + result = testdir.runpytest("-p", "pytester", str(p1)) + assert result.ret == 0 -def test_spawn_uses_tmphome(testdir) -> None: - tmphome = str(testdir.tmpdir) +def test_spawn_uses_tmphome(pytester: Pytester) -> None: + tmphome = str(pytester.path) assert os.environ.get("HOME") == tmphome - testdir.monkeypatch.setenv("CUSTOMENV", "42") + pytester._monkeypatch.setenv("CUSTOMENV", "42") - p1 = testdir.makepyfile( + p1 = pytester.makepyfile( """ import os @@ -711,7 +730,7 @@ def test(): tmphome=tmphome ) ) - child = testdir.spawn_pytest(str(p1)) + child = pytester.spawn_pytest(str(p1)) out = child.read() assert child.wait() == 0, out.decode("utf8") @@ -752,16 +771,46 @@ def test_error2(bad_fixture): """ ) result = testdir.runpytest(str(p1)) - result.assert_outcomes(error=2) + result.assert_outcomes(errors=2) + + assert result.parseoutcomes() == {"errors": 2} - assert result.parseoutcomes() == {"error": 2} + +def test_parse_summary_line_always_plural(): + """Parsing summaries always returns plural nouns (#6505)""" + lines = [ + "some output 1", + "some output 2", + "======= 1 failed, 1 passed, 1 warning, 1 error in 0.13s ====", + "done.", + ] + assert pytester.RunResult.parse_summary_nouns(lines) == { + "errors": 1, + "failed": 1, + "passed": 1, + "warnings": 1, + } + + lines = [ + "some output 1", + "some output 2", + "======= 1 failed, 1 passed, 2 warnings, 2 errors in 0.13s ====", + "done.", + ] + assert pytester.RunResult.parse_summary_nouns(lines) == { + "errors": 2, + "failed": 1, + "passed": 1, + "warnings": 2, + } def test_makefile_joins_absolute_path(testdir: Testdir) -> None: absfile = testdir.tmpdir / "absfile" - if sys.platform == "win32": - with pytest.raises(OSError): - testdir.makepyfile(**{str(absfile): ""}) - else: - p1 = testdir.makepyfile(**{str(absfile): ""}) - assert str(p1) == (testdir.tmpdir / absfile) + ".py" + p1 = testdir.makepyfile(**{str(absfile): ""}) + assert str(p1) == str(testdir.tmpdir / "absfile.py") + + +def test_testtmproot(testdir): + """Check test_tmproot is a py.path attribute for backward compatibility.""" + assert testdir.test_tmproot.check(dir=1) diff --git a/testing/test_recwarn.py b/testing/test_recwarn.py index 1d445d1bf05..91efe6d2393 100644 --- a/testing/test_recwarn.py +++ b/testing/test_recwarn.py @@ -3,6 +3,7 @@ from typing import Optional import pytest +from _pytest.pytester import Pytester from _pytest.recwarn import WarningsRecorder @@ -12,8 +13,8 @@ def test_recwarn_stacklevel(recwarn: WarningsRecorder) -> None: assert warn.filename == __file__ -def test_recwarn_functional(testdir) -> None: - testdir.makepyfile( +def test_recwarn_functional(pytester: Pytester) -> None: + pytester.makepyfile( """ import warnings def test_method(recwarn): @@ -22,13 +23,13 @@ def test_method(recwarn): assert isinstance(warn.message, UserWarning) """ ) - reprec = testdir.inline_run() + reprec = pytester.inline_run() reprec.assertoutcome(passed=1) class TestWarningsRecorderChecker: def test_recording(self) -> None: - rec = WarningsRecorder() + rec = WarningsRecorder(_ispytest=True) with rec: assert not rec.list warnings.warn_explicit("hello", UserWarning, "xyz", 13) @@ -45,7 +46,7 @@ def test_recording(self) -> None: def test_warn_stacklevel(self) -> None: """#4243""" - rec = WarningsRecorder() + rec = WarningsRecorder(_ispytest=True) with rec: warnings.warn("test", DeprecationWarning, 2) @@ -53,21 +54,21 @@ def test_typechecking(self) -> None: from _pytest.recwarn import WarningsChecker with pytest.raises(TypeError): - WarningsChecker(5) # type: ignore + WarningsChecker(5, _ispytest=True) # type: ignore[arg-type] with pytest.raises(TypeError): - WarningsChecker(("hi", RuntimeWarning)) # type: ignore + WarningsChecker(("hi", RuntimeWarning), _ispytest=True) # type: ignore[arg-type] with pytest.raises(TypeError): - WarningsChecker([DeprecationWarning, RuntimeWarning]) # type: ignore + WarningsChecker([DeprecationWarning, RuntimeWarning], _ispytest=True) # type: ignore[arg-type] def test_invalid_enter_exit(self) -> None: # wrap this test in WarningsRecorder to ensure warning state gets reset - with WarningsRecorder(): + with WarningsRecorder(_ispytest=True): with pytest.raises(RuntimeError): - rec = WarningsRecorder() + rec = WarningsRecorder(_ispytest=True) rec.__exit__(None, None, None) # can't exit before entering with pytest.raises(RuntimeError): - rec = WarningsRecorder() + rec = WarningsRecorder(_ispytest=True) with rec: with rec: pass # can't enter twice @@ -328,9 +329,9 @@ class MyRuntimeWarning(RuntimeWarning): assert str(record[0].message) == "user" assert str(record[1].message) == "runtime" - def test_double_test(self, testdir) -> None: + def test_double_test(self, pytester: Pytester) -> None: """If a test is run again, the warning should still be raised""" - testdir.makepyfile( + pytester.makepyfile( """ import pytest import warnings @@ -341,7 +342,7 @@ def test(run): warnings.warn("runtime", RuntimeWarning) """ ) - result = testdir.runpytest() + result = pytester.runpytest() result.stdout.fnmatch_lines(["*2 passed in*"]) def test_match_regex(self) -> None: @@ -370,13 +371,14 @@ def test_none_of_multiple_warns(self) -> None: @pytest.mark.filterwarnings("ignore") def test_can_capture_previously_warned(self) -> None: - def f(): + def f() -> int: warnings.warn(UserWarning("ohai")) return 10 assert f() == 10 assert pytest.warns(UserWarning, f) == 10 assert pytest.warns(UserWarning, f) == 10 + assert pytest.warns(UserWarning, f) != "10" # type: ignore[comparison-overlap] def test_warns_context_manager_with_kwargs(self) -> None: with pytest.raises(TypeError) as excinfo: diff --git a/testing/test_reports.py b/testing/test_reports.py index 8c509ec479d..b97b1fc2970 100644 --- a/testing/test_reports.py +++ b/testing/test_reports.py @@ -1,16 +1,19 @@ -import sys +from pathlib import Path +from typing import Sequence +from typing import Union import pytest from _pytest._code.code import ExceptionChainRepr -from _pytest.pathlib import Path +from _pytest._code.code import ExceptionRepr +from _pytest.config import Config +from _pytest.pytester import Testdir from _pytest.reports import CollectReport from _pytest.reports import TestReport class TestReportSerialization: - def test_xdist_longrepr_to_str_issue_241(self, testdir): - """ - Regarding issue pytest-xdist#241 + def test_xdist_longrepr_to_str_issue_241(self, testdir: Testdir) -> None: + """Regarding issue pytest-xdist#241. This test came originally from test_remote.py in xdist (ca03269). """ @@ -32,7 +35,7 @@ def test_b(): pass assert test_b_call.outcome == "passed" assert test_b_call._to_json()["longrepr"] is None - def test_xdist_report_longrepr_reprcrash_130(self, testdir): + def test_xdist_report_longrepr_reprcrash_130(self, testdir: Testdir) -> None: """Regarding issue pytest-xdist#130 This test came originally from test_remote.py in xdist (ca03269). @@ -47,14 +50,18 @@ def test_fail(): assert len(reports) == 3 rep = reports[1] added_section = ("Failure Metadata", "metadata metadata", "*") + assert isinstance(rep.longrepr, ExceptionRepr) rep.longrepr.sections.append(added_section) d = rep._to_json() a = TestReport._from_json(d) + assert isinstance(a.longrepr, ExceptionRepr) # Check assembled == rep assert a.__dict__.keys() == rep.__dict__.keys() for key in rep.__dict__.keys(): if key != "longrepr": assert getattr(a, key) == getattr(rep, key) + assert rep.longrepr.reprcrash is not None + assert a.longrepr.reprcrash is not None assert rep.longrepr.reprcrash.lineno == a.longrepr.reprcrash.lineno assert rep.longrepr.reprcrash.message == a.longrepr.reprcrash.message assert rep.longrepr.reprcrash.path == a.longrepr.reprcrash.path @@ -67,7 +74,7 @@ def test_fail(): # Missing section attribute PR171 assert added_section in a.longrepr.sections - def test_reprentries_serialization_170(self, testdir): + def test_reprentries_serialization_170(self, testdir: Testdir) -> None: """Regarding issue pytest-xdist#170 This test came originally from test_remote.py in xdist (ca03269). @@ -85,24 +92,35 @@ def test_repr_entry(): reports = reprec.getreports("pytest_runtest_logreport") assert len(reports) == 3 rep = reports[1] + assert isinstance(rep.longrepr, ExceptionRepr) d = rep._to_json() a = TestReport._from_json(d) + assert isinstance(a.longrepr, ExceptionRepr) rep_entries = rep.longrepr.reprtraceback.reprentries a_entries = a.longrepr.reprtraceback.reprentries for i in range(len(a_entries)): - assert isinstance(rep_entries[i], ReprEntry) - assert rep_entries[i].lines == a_entries[i].lines - assert rep_entries[i].reprfileloc.lineno == a_entries[i].reprfileloc.lineno - assert ( - rep_entries[i].reprfileloc.message == a_entries[i].reprfileloc.message - ) - assert rep_entries[i].reprfileloc.path == a_entries[i].reprfileloc.path - assert rep_entries[i].reprfuncargs.args == a_entries[i].reprfuncargs.args - assert rep_entries[i].reprlocals.lines == a_entries[i].reprlocals.lines - assert rep_entries[i].style == a_entries[i].style - - def test_reprentries_serialization_196(self, testdir): + rep_entry = rep_entries[i] + assert isinstance(rep_entry, ReprEntry) + assert rep_entry.reprfileloc is not None + assert rep_entry.reprfuncargs is not None + assert rep_entry.reprlocals is not None + + a_entry = a_entries[i] + assert isinstance(a_entry, ReprEntry) + assert a_entry.reprfileloc is not None + assert a_entry.reprfuncargs is not None + assert a_entry.reprlocals is not None + + assert rep_entry.lines == a_entry.lines + assert rep_entry.reprfileloc.lineno == a_entry.reprfileloc.lineno + assert rep_entry.reprfileloc.message == a_entry.reprfileloc.message + assert rep_entry.reprfileloc.path == a_entry.reprfileloc.path + assert rep_entry.reprfuncargs.args == a_entry.reprfuncargs.args + assert rep_entry.reprlocals.lines == a_entry.reprlocals.lines + assert rep_entry.style == a_entry.style + + def test_reprentries_serialization_196(self, testdir: Testdir) -> None: """Regarding issue pytest-xdist#196 This test came originally from test_remote.py in xdist (ca03269). @@ -120,8 +138,10 @@ def test_repr_entry_native(): reports = reprec.getreports("pytest_runtest_logreport") assert len(reports) == 3 rep = reports[1] + assert isinstance(rep.longrepr, ExceptionRepr) d = rep._to_json() a = TestReport._from_json(d) + assert isinstance(a.longrepr, ExceptionRepr) rep_entries = rep.longrepr.reprtraceback.reprentries a_entries = a.longrepr.reprtraceback.reprentries @@ -129,10 +149,8 @@ def test_repr_entry_native(): assert isinstance(rep_entries[i], ReprEntryNative) assert rep_entries[i].lines == a_entries[i].lines - def test_itemreport_outcomes(self, testdir): - """ - This test came originally from test_remote.py in xdist (ca03269). - """ + def test_itemreport_outcomes(self, testdir: Testdir) -> None: + # This test came originally from test_remote.py in xdist (ca03269). reprec = testdir.inline_runsource( """ import pytest @@ -157,6 +175,7 @@ def test_xfail_imperative(): assert newrep.failed == rep.failed assert newrep.skipped == rep.skipped if newrep.skipped and not hasattr(newrep, "wasxfail"): + assert isinstance(newrep.longrepr, tuple) assert len(newrep.longrepr) == 3 assert newrep.outcome == rep.outcome assert newrep.when == rep.when @@ -164,7 +183,7 @@ def test_xfail_imperative(): if rep.failed: assert newrep.longreprtext == rep.longreprtext - def test_collectreport_passed(self, testdir): + def test_collectreport_passed(self, testdir: Testdir) -> None: """This test came originally from test_remote.py in xdist (ca03269).""" reprec = testdir.inline_runsource("def test_func(): pass") reports = reprec.getreports("pytest_collectreport") @@ -175,7 +194,7 @@ def test_collectreport_passed(self, testdir): assert newrep.failed == rep.failed assert newrep.skipped == rep.skipped - def test_collectreport_fail(self, testdir): + def test_collectreport_fail(self, testdir: Testdir) -> None: """This test came originally from test_remote.py in xdist (ca03269).""" reprec = testdir.inline_runsource("qwe abc") reports = reprec.getreports("pytest_collectreport") @@ -189,13 +208,13 @@ def test_collectreport_fail(self, testdir): if rep.failed: assert newrep.longrepr == str(rep.longrepr) - def test_extended_report_deserialization(self, testdir): + def test_extended_report_deserialization(self, testdir: Testdir) -> None: """This test came originally from test_remote.py in xdist (ca03269).""" reprec = testdir.inline_runsource("qwe abc") reports = reprec.getreports("pytest_collectreport") assert reports for rep in reports: - rep.extra = True + rep.extra = True # type: ignore[attr-defined] d = rep._to_json() newrep = CollectReport._from_json(d) assert newrep.extra @@ -205,7 +224,7 @@ def test_extended_report_deserialization(self, testdir): if rep.failed: assert newrep.longrepr == str(rep.longrepr) - def test_paths_support(self, testdir): + def test_paths_support(self, testdir: Testdir) -> None: """Report attributes which are py.path or pathlib objects should become strings.""" testdir.makepyfile( """ @@ -217,13 +236,13 @@ def test_a(): reports = reprec.getreports("pytest_runtest_logreport") assert len(reports) == 3 test_a_call = reports[1] - test_a_call.path1 = testdir.tmpdir - test_a_call.path2 = Path(testdir.tmpdir) + test_a_call.path1 = testdir.tmpdir # type: ignore[attr-defined] + test_a_call.path2 = Path(testdir.tmpdir) # type: ignore[attr-defined] data = test_a_call._to_json() assert data["path1"] == str(testdir.tmpdir) assert data["path2"] == str(testdir.tmpdir) - def test_deserialization_failure(self, testdir): + def test_deserialization_failure(self, testdir: Testdir) -> None: """Check handling of failure during deserialization of report types.""" testdir.makepyfile( """ @@ -246,7 +265,7 @@ def test_a(): TestReport._from_json(data) @pytest.mark.parametrize("report_class", [TestReport, CollectReport]) - def test_chained_exceptions(self, testdir, tw_mock, report_class): + def test_chained_exceptions(self, testdir: Testdir, tw_mock, report_class) -> None: """Check serialization/deserialization of report objects containing chained exceptions (#5786)""" testdir.makepyfile( """ @@ -266,7 +285,9 @@ def test_a(): reprec = testdir.inline_run() if report_class is TestReport: - reports = reprec.getreports("pytest_runtest_logreport") + reports: Union[ + Sequence[TestReport], Sequence[CollectReport] + ] = reprec.getreports("pytest_runtest_logreport") # we have 3 reports: setup/call/teardown assert len(reports) == 3 # get the call report @@ -278,7 +299,7 @@ def test_a(): assert len(reports) == 2 report = reports[1] - def check_longrepr(longrepr): + def check_longrepr(longrepr: ExceptionChainRepr) -> None: """Check the attributes of the given longrepr object according to the test file. We can get away with testing both CollectReport and TestReport with this function because @@ -302,6 +323,7 @@ def check_longrepr(longrepr): assert report.failed assert len(report.sections) == 0 + assert isinstance(report.longrepr, ExceptionChainRepr) report.longrepr.addsection("title", "contents", "=") check_longrepr(report.longrepr) @@ -316,57 +338,32 @@ def check_longrepr(longrepr): # elsewhere and we do check the contents of the longrepr object after loading it. loaded_report.longrepr.toterminal(tw_mock) - def test_chained_exceptions_no_reprcrash(self, testdir, tw_mock): + def test_chained_exceptions_no_reprcrash(self, testdir: Testdir, tw_mock) -> None: """Regression test for tracebacks without a reprcrash (#5971) This happens notably on exceptions raised by multiprocess.pool: the exception transfer from subprocess to main process creates an artificial exception, which ExceptionInfo can't obtain the ReprFileLocation from. """ - # somehow in Python 3.5 on Windows this test fails with: - # File "c:\...\3.5.4\x64\Lib\multiprocessing\connection.py", line 302, in _recv_bytes - # overlapped=True) - # OSError: [WinError 6] The handle is invalid - # - # so in this platform we opted to use a mock traceback which is identical to the - # one produced by the multiprocessing module - if sys.version_info[:2] <= (3, 5) and sys.platform.startswith("win"): - testdir.makepyfile( - """ - # equivalent of multiprocessing.pool.RemoteTraceback - class RemoteTraceback(Exception): - def __init__(self, tb): - self.tb = tb - def __str__(self): - return self.tb - def test_a(): - try: - raise ValueError('value error') - except ValueError as e: - # equivalent to how multiprocessing.pool.rebuild_exc does it - e.__cause__ = RemoteTraceback('runtime error') - raise e + testdir.makepyfile( """ - ) - else: - testdir.makepyfile( - """ - from concurrent.futures import ProcessPoolExecutor + from concurrent.futures import ProcessPoolExecutor - def func(): - raise ValueError('value error') + def func(): + raise ValueError('value error') - def test_a(): - with ProcessPoolExecutor() as p: - p.submit(func).result() - """ - ) + def test_a(): + with ProcessPoolExecutor() as p: + p.submit(func).result() + """ + ) + testdir.syspathinsert() reprec = testdir.inline_run() reports = reprec.getreports("pytest_runtest_logreport") - def check_longrepr(longrepr): + def check_longrepr(longrepr: object) -> None: assert isinstance(longrepr, ExceptionChainRepr) assert len(longrepr.chain) == 2 entry1, entry2 = longrepr.chain @@ -377,6 +374,7 @@ def check_longrepr(longrepr): assert "ValueError: value error" in str(tb2) assert fileloc1 is None + assert fileloc2 is not None assert fileloc2.message == "ValueError: value error" # 3 reports: setup/call/teardown: get the call report @@ -393,13 +391,25 @@ def check_longrepr(longrepr): check_longrepr(loaded_report.longrepr) # for same reasons as previous test, ensure we don't blow up here + assert loaded_report.longrepr is not None + assert isinstance(loaded_report.longrepr, ExceptionChainRepr) loaded_report.longrepr.toterminal(tw_mock) + def test_report_prevent_ConftestImportFailure_hiding_exception( + self, testdir: Testdir + ) -> None: + sub_dir = testdir.tmpdir.join("ns").ensure_dir() + sub_dir.join("conftest").new(ext=".py").write("import unknown") + + result = testdir.runpytest_subprocess(".") + result.stdout.fnmatch_lines(["E *Error: No module named 'unknown'"]) + result.stdout.no_fnmatch_line("ERROR - *ConftestImportFailure*") + class TestHooks: """Test that the hooks are working correctly for plugins""" - def test_test_report(self, testdir, pytestconfig): + def test_test_report(self, testdir: Testdir, pytestconfig: Config) -> None: testdir.makepyfile( """ def test_a(): assert False @@ -421,7 +431,7 @@ def test_b(): pass assert new_rep.when == rep.when assert new_rep.outcome == rep.outcome - def test_collect_report(self, testdir, pytestconfig): + def test_collect_report(self, testdir: Testdir, pytestconfig: Config) -> None: testdir.makepyfile( """ def test_a(): assert False @@ -446,7 +456,9 @@ def test_b(): pass @pytest.mark.parametrize( "hook_name", ["pytest_runtest_logreport", "pytest_collectreport"] ) - def test_invalid_report_types(self, testdir, pytestconfig, hook_name): + def test_invalid_report_types( + self, testdir: Testdir, pytestconfig: Config, hook_name: str + ) -> None: testdir.makepyfile( """ def test_a(): pass diff --git a/testing/test_resultlog.py b/testing/test_resultlog.py deleted file mode 100644 index e0a02de8029..00000000000 --- a/testing/test_resultlog.py +++ /dev/null @@ -1,216 +0,0 @@ -import os -from io import StringIO - -import _pytest._code -import pytest -from _pytest.resultlog import pytest_configure -from _pytest.resultlog import pytest_unconfigure -from _pytest.resultlog import ResultLog -from _pytest.resultlog import resultlog_key - -pytestmark = pytest.mark.filterwarnings("ignore:--result-log is deprecated") - - -def test_write_log_entry(): - reslog = ResultLog(None, None) - reslog.logfile = StringIO() - reslog.write_log_entry("name", ".", "") - entry = reslog.logfile.getvalue() - assert entry[-1] == "\n" - entry_lines = entry.splitlines() - assert len(entry_lines) == 1 - assert entry_lines[0] == ". name" - - reslog.logfile = StringIO() - reslog.write_log_entry("name", "s", "Skipped") - entry = reslog.logfile.getvalue() - assert entry[-1] == "\n" - entry_lines = entry.splitlines() - assert len(entry_lines) == 2 - assert entry_lines[0] == "s name" - assert entry_lines[1] == " Skipped" - - reslog.logfile = StringIO() - reslog.write_log_entry("name", "s", "Skipped\n") - entry = reslog.logfile.getvalue() - assert entry[-1] == "\n" - entry_lines = entry.splitlines() - assert len(entry_lines) == 2 - assert entry_lines[0] == "s name" - assert entry_lines[1] == " Skipped" - - reslog.logfile = StringIO() - longrepr = " tb1\n tb 2\nE tb3\nSome Error" - reslog.write_log_entry("name", "F", longrepr) - entry = reslog.logfile.getvalue() - assert entry[-1] == "\n" - entry_lines = entry.splitlines() - assert len(entry_lines) == 5 - assert entry_lines[0] == "F name" - assert entry_lines[1:] == [" " + line for line in longrepr.splitlines()] - - -class TestWithFunctionIntegration: - # XXX (hpk) i think that the resultlog plugin should - # provide a Parser object so that one can remain - # ignorant regarding formatting details. - def getresultlog(self, testdir, arg): - resultlog = testdir.tmpdir.join("resultlog") - testdir.plugins.append("resultlog") - args = ["--resultlog=%s" % resultlog] + [arg] - testdir.runpytest(*args) - return [x for x in resultlog.readlines(cr=0) if x] - - def test_collection_report(self, testdir): - ok = testdir.makepyfile(test_collection_ok="") - fail = testdir.makepyfile(test_collection_fail="XXX") - lines = self.getresultlog(testdir, ok) - assert not lines - - lines = self.getresultlog(testdir, fail) - assert lines - assert lines[0].startswith("F ") - assert lines[0].endswith("test_collection_fail.py"), lines[0] - for x in lines[1:]: - assert x.startswith(" ") - assert "XXX" in "".join(lines[1:]) - - def test_log_test_outcomes(self, testdir): - mod = testdir.makepyfile( - test_mod=""" - import pytest - def test_pass(): pass - def test_skip(): pytest.skip("hello") - def test_fail(): raise ValueError("FAIL") - - @pytest.mark.xfail - def test_xfail(): raise ValueError("XFAIL") - @pytest.mark.xfail - def test_xpass(): pass - - """ - ) - lines = self.getresultlog(testdir, mod) - assert len(lines) >= 3 - assert lines[0].startswith(". ") - assert lines[0].endswith("test_pass") - assert lines[1].startswith("s "), lines[1] - assert lines[1].endswith("test_skip") - assert lines[2].find("hello") != -1 - - assert lines[3].startswith("F ") - assert lines[3].endswith("test_fail") - tb = "".join(lines[4:8]) - assert tb.find('raise ValueError("FAIL")') != -1 - - assert lines[8].startswith("x ") - tb = "".join(lines[8:14]) - assert tb.find('raise ValueError("XFAIL")') != -1 - - assert lines[14].startswith("X ") - assert len(lines) == 15 - - @pytest.mark.parametrize("style", ("native", "long", "short")) - def test_internal_exception(self, style): - # they are produced for example by a teardown failing - # at the end of the run or a failing hook invocation - try: - raise ValueError - except ValueError: - excinfo = _pytest._code.ExceptionInfo.from_current() - reslog = ResultLog(None, StringIO()) - reslog.pytest_internalerror(excinfo.getrepr(style=style)) - entry = reslog.logfile.getvalue() - entry_lines = entry.splitlines() - - assert entry_lines[0].startswith("! ") - if style != "native": - assert os.path.basename(__file__)[:-9] in entry_lines[0] # .pyc/class - assert entry_lines[-1][0] == " " - assert "ValueError" in entry - - -def test_generic(testdir, LineMatcher): - testdir.plugins.append("resultlog") - testdir.makepyfile( - """ - import pytest - def test_pass(): - pass - def test_fail(): - assert 0 - def test_skip(): - pytest.skip("") - @pytest.mark.xfail - def test_xfail(): - assert 0 - @pytest.mark.xfail(run=False) - def test_xfail_norun(): - assert 0 - """ - ) - testdir.runpytest("--resultlog=result.log") - lines = testdir.tmpdir.join("result.log").readlines(cr=0) - LineMatcher(lines).fnmatch_lines( - [ - ". *:test_pass", - "F *:test_fail", - "s *:test_skip", - "x *:test_xfail", - "x *:test_xfail_norun", - ] - ) - - -def test_makedir_for_resultlog(testdir, LineMatcher): - """--resultlog should automatically create directories for the log file""" - testdir.plugins.append("resultlog") - testdir.makepyfile( - """ - import pytest - def test_pass(): - pass - """ - ) - testdir.runpytest("--resultlog=path/to/result.log") - lines = testdir.tmpdir.join("path/to/result.log").readlines(cr=0) - LineMatcher(lines).fnmatch_lines([". *:test_pass"]) - - -def test_no_resultlog_on_slaves(testdir): - config = testdir.parseconfig("-p", "resultlog", "--resultlog=resultlog") - - assert resultlog_key not in config._store - pytest_configure(config) - assert resultlog_key in config._store - pytest_unconfigure(config) - assert resultlog_key not in config._store - - config.slaveinput = {} - pytest_configure(config) - assert resultlog_key not in config._store - pytest_unconfigure(config) - assert resultlog_key not in config._store - - -def test_failure_issue380(testdir): - testdir.makeconftest( - """ - import pytest - class MyCollector(pytest.File): - def collect(self): - raise ValueError() - def repr_failure(self, excinfo): - return "somestring" - def pytest_collect_file(path, parent): - return MyCollector(parent=parent, fspath=path) - """ - ) - testdir.makepyfile( - """ - def test_func(): - pass - """ - ) - result = testdir.runpytest("--resultlog=log") - assert result.ret == 2 diff --git a/testing/test_runner.py b/testing/test_runner.py index ab4ae67e566..a1f1db48d06 100644 --- a/testing/test_runner.py +++ b/testing/test_runner.py @@ -5,6 +5,7 @@ from typing import Dict from typing import List from typing import Tuple +from typing import Type import py @@ -13,13 +14,9 @@ from _pytest import outcomes from _pytest import reports from _pytest import runner -from _pytest.compat import TYPE_CHECKING from _pytest.config import ExitCode from _pytest.outcomes import OutcomeException -if TYPE_CHECKING: - from typing import Type - class TestSetupState: def test_setup(self, testdir) -> None: @@ -310,7 +307,7 @@ def teardown_function(func): assert reps[5].failed def test_exact_teardown_issue1206(self, testdir) -> None: - """issue shadowing error with wrong number of arguments on teardown_method.""" + """Issue shadowing error with wrong number of arguments on teardown_method.""" rec = testdir.inline_runsource( """ import pytest @@ -339,10 +336,9 @@ def test_method(self): assert reps[2].failed assert reps[2].when == "teardown" assert reps[2].longrepr.reprcrash.message in ( - # python3 error "TypeError: teardown_method() missing 2 required positional arguments: 'y' and 'z'", - # python2 error - "TypeError: teardown_method() takes exactly 4 arguments (2 given)", + # Python >= 3.10 + "TypeError: TestClass.teardown_method() missing 2 required positional arguments: 'y' and 'z'", ) def test_failure_in_setup_function_ignores_custom_repr(self, testdir) -> None: @@ -423,27 +419,6 @@ def test_func(): assert False, "did not raise" -class TestExecutionForked(BaseFunctionalTests): - pytestmark = pytest.mark.skipif("not hasattr(os, 'fork')") - - def getrunner(self): - # XXX re-arrange this test to live in pytest-xdist - boxed = pytest.importorskip("xdist.boxed") - return boxed.forked_run_report - - def test_suicide(self, testdir) -> None: - reports = testdir.runitem( - """ - def test_func(): - import os - os.kill(os.getpid(), 15) - """ - ) - rep = reports[0] - assert rep.failed - assert rep.when == "???" - - class TestSessionReports: def test_collect_result(self, testdir) -> None: col = testdir.getmodulecol( @@ -468,45 +443,45 @@ class TestClass(object): assert res[1].name == "TestClass" -reporttypes = [ +reporttypes: List[Type[reports.BaseReport]] = [ reports.BaseReport, reports.TestReport, reports.CollectReport, -] # type: List[Type[reports.BaseReport]] +] @pytest.mark.parametrize( "reporttype", reporttypes, ids=[x.__name__ for x in reporttypes] ) -def test_report_extra_parameters(reporttype: "Type[reports.BaseReport]") -> None: +def test_report_extra_parameters(reporttype: Type[reports.BaseReport]) -> None: args = list(inspect.signature(reporttype.__init__).parameters.keys())[1:] - basekw = dict.fromkeys(args, []) # type: Dict[str, List[object]] + basekw: Dict[str, List[object]] = dict.fromkeys(args, []) report = reporttype(newthing=1, **basekw) assert report.newthing == 1 def test_callinfo() -> None: - ci = runner.CallInfo.from_call(lambda: 0, "123") - assert ci.when == "123" + ci = runner.CallInfo.from_call(lambda: 0, "collect") + assert ci.when == "collect" assert ci.result == 0 assert "result" in repr(ci) - assert repr(ci) == "" - assert str(ci) == "" + assert repr(ci) == "" + assert str(ci) == "" - ci = runner.CallInfo.from_call(lambda: 0 / 0, "123") - assert ci.when == "123" - assert not hasattr(ci, "result") - assert repr(ci) == "".format(ci.excinfo) - assert str(ci) == repr(ci) - assert ci.excinfo + ci2 = runner.CallInfo.from_call(lambda: 0 / 0, "collect") + assert ci2.when == "collect" + assert not hasattr(ci2, "result") + assert repr(ci2) == f"" + assert str(ci2) == repr(ci2) + assert ci2.excinfo # Newlines are escaped. def raise_assertion(): assert 0, "assert_msg" - ci = runner.CallInfo.from_call(raise_assertion, "call") - assert repr(ci) == "".format(ci.excinfo) - assert "\n" not in repr(ci) + ci3 = runner.CallInfo.from_call(raise_assertion, "call") + assert repr(ci3) == f"" + assert "\n" not in repr(ci3) # design question: do we want general hooks in python files? @@ -527,9 +502,10 @@ def pytest_runtest_setup(self, item): @pytest.fixture def mylist(self, request): return request.function.mylist - def pytest_runtest_call(self, item, __multicall__): + @pytest.hookimpl(hookwrapper=True) + def pytest_runtest_call(self, item): try: - __multicall__.execute() + (yield).get_result() except ValueError: pass def test_hello1(self, mylist): @@ -554,8 +530,8 @@ def test_outcomeexception_passes_except_Exception() -> None: with pytest.raises(outcomes.OutcomeException): try: raise outcomes.OutcomeException("test") - except Exception: - raise NotImplementedError() + except Exception as e: + raise NotImplementedError from e def test_pytest_exit() -> None: @@ -762,7 +738,7 @@ def test_importorskip_dev_module(monkeypatch) -> None: def test_importorskip_module_level(testdir) -> None: - """importorskip must be able to skip entire modules when used at module level""" + """`importorskip` must be able to skip entire modules when used at module level.""" testdir.makepyfile( """ import pytest @@ -777,7 +753,7 @@ def test_foo(): def test_importorskip_custom_reason(testdir) -> None: - """make sure custom reasons are used""" + """Make sure custom reasons are used.""" testdir.makepyfile( """ import pytest @@ -891,9 +867,8 @@ def test_fix(foo): def test_store_except_info_on_error() -> None: - """ Test that upon test failure, the exception info is stored on - sys.last_traceback and friends. - """ + """Test that upon test failure, the exception info is stored on + sys.last_traceback and friends.""" # Simulate item that might raise a specific exception, depending on `raise_error` class var class ItemMightRaise: nodeid = "item_that_raises" @@ -904,7 +879,7 @@ def runtest(self): raise IndexError("TEST") try: - runner.pytest_runtest_call(ItemMightRaise()) + runner.pytest_runtest_call(ItemMightRaise()) # type: ignore[arg-type] except IndexError: pass # Check that exception info is stored on sys @@ -915,14 +890,14 @@ def runtest(self): # The next run should clear the exception info stored by the previous run ItemMightRaise.raise_error = False - runner.pytest_runtest_call(ItemMightRaise()) + runner.pytest_runtest_call(ItemMightRaise()) # type: ignore[arg-type] assert not hasattr(sys, "last_type") assert not hasattr(sys, "last_value") assert not hasattr(sys, "last_traceback") def test_current_test_env_var(testdir, monkeypatch) -> None: - pytest_current_test_vars = [] # type: List[Tuple[str, str]] + pytest_current_test_vars: List[Tuple[str, str]] = [] monkeypatch.setattr( sys, "pytest_current_test_vars", pytest_current_test_vars, raising=False ) @@ -954,9 +929,7 @@ def test(fix): class TestReportContents: - """ - Test user-level API of ``TestReport`` objects. - """ + """Test user-level API of ``TestReport`` objects.""" def getrunner(self): return lambda item: runner.runtestprotocol(item, log=False) @@ -971,6 +944,33 @@ def test_func(): rep = reports[1] assert rep.longreprtext == "" + def test_longreprtext_skip(self, testdir) -> None: + """TestReport.longreprtext can handle non-str ``longrepr`` attributes (#7559)""" + reports = testdir.runitem( + """ + import pytest + def test_func(): + pytest.skip() + """ + ) + _, call_rep, _ = reports + assert isinstance(call_rep.longrepr, tuple) + assert "Skipped" in call_rep.longreprtext + + def test_longreprtext_collect_skip(self, testdir) -> None: + """CollectReport.longreprtext can handle non-str ``longrepr`` attributes (#7559)""" + testdir.makepyfile( + """ + import pytest + pytest.skip(allow_module_level=True) + """ + ) + rec = testdir.inline_run() + calls = rec.getcalls("pytest_collectreport") + _, call = calls + assert isinstance(call.report.longrepr, tuple) + assert "Skipped" in call.report.longreprtext + def test_longreprtext_failure(self, testdir) -> None: reports = testdir.runitem( """ @@ -1023,6 +1023,17 @@ def test_func(): assert rep.capstdout == "" assert rep.capstderr == "" + def test_longrepr_type(self, testdir) -> None: + reports = testdir.runitem( + """ + import pytest + def test_func(): + pytest.fail(pytrace=False) + """ + ) + rep = reports[1] + assert isinstance(rep.longrepr, _pytest._code.code.ExceptionRepr) + def test_outcome_exception_bad_msg() -> None: """Check that OutcomeExceptions validate their input to prevent confusing errors (#5578)""" diff --git a/testing/test_runner_xunit.py b/testing/test_runner_xunit.py index 0ff508d2c4d..e90d761f633 100644 --- a/testing/test_runner_xunit.py +++ b/testing/test_runner_xunit.py @@ -1,12 +1,12 @@ -""" - test correct setup/teardowns at - module, class, and instance level -""" +"""Test correct setup/teardowns at module, class, and instance level.""" +from typing import List + import pytest +from _pytest.pytester import Pytester -def test_module_and_function_setup(testdir): - reprec = testdir.inline_runsource( +def test_module_and_function_setup(pytester: Pytester) -> None: + reprec = pytester.inline_runsource( """ modlevel = [] def setup_module(module): @@ -38,8 +38,8 @@ def test_module(self): assert rep.passed -def test_module_setup_failure_no_teardown(testdir): - reprec = testdir.inline_runsource( +def test_module_setup_failure_no_teardown(pytester: Pytester) -> None: + reprec = pytester.inline_runsource( """ values = [] def setup_module(module): @@ -58,8 +58,8 @@ def teardown_module(module): assert calls[0].item.module.values == [1] -def test_setup_function_failure_no_teardown(testdir): - reprec = testdir.inline_runsource( +def test_setup_function_failure_no_teardown(pytester: Pytester) -> None: + reprec = pytester.inline_runsource( """ modlevel = [] def setup_function(function): @@ -77,8 +77,8 @@ def test_func(): assert calls[0].item.module.modlevel == [1] -def test_class_setup(testdir): - reprec = testdir.inline_runsource( +def test_class_setup(pytester: Pytester) -> None: + reprec = pytester.inline_runsource( """ class TestSimpleClassSetup(object): clslevel = [] @@ -103,8 +103,8 @@ def test_cleanup(): reprec.assertoutcome(passed=1 + 2 + 1) -def test_class_setup_failure_no_teardown(testdir): - reprec = testdir.inline_runsource( +def test_class_setup_failure_no_teardown(pytester: Pytester) -> None: + reprec = pytester.inline_runsource( """ class TestSimpleClassSetup(object): clslevel = [] @@ -124,8 +124,8 @@ def test_cleanup(): reprec.assertoutcome(failed=1, passed=1) -def test_method_setup(testdir): - reprec = testdir.inline_runsource( +def test_method_setup(pytester: Pytester) -> None: + reprec = pytester.inline_runsource( """ class TestSetupMethod(object): def setup_method(self, meth): @@ -143,8 +143,8 @@ def test_other(self): reprec.assertoutcome(passed=2) -def test_method_setup_failure_no_teardown(testdir): - reprec = testdir.inline_runsource( +def test_method_setup_failure_no_teardown(pytester: Pytester) -> None: + reprec = pytester.inline_runsource( """ class TestMethodSetup(object): clslevel = [] @@ -165,8 +165,8 @@ def test_cleanup(): reprec.assertoutcome(failed=1, passed=1) -def test_method_setup_uses_fresh_instances(testdir): - reprec = testdir.inline_runsource( +def test_method_setup_uses_fresh_instances(pytester: Pytester) -> None: + reprec = pytester.inline_runsource( """ class TestSelfState1(object): memory = [] @@ -180,8 +180,8 @@ def test_afterhello(self): reprec.assertoutcome(passed=2, failed=0) -def test_setup_that_skips_calledagain(testdir): - p = testdir.makepyfile( +def test_setup_that_skips_calledagain(pytester: Pytester) -> None: + p = pytester.makepyfile( """ import pytest def setup_module(mod): @@ -192,12 +192,12 @@ def test_function2(): pass """ ) - reprec = testdir.inline_run(p) + reprec = pytester.inline_run(p) reprec.assertoutcome(skipped=2) -def test_setup_fails_again_on_all_tests(testdir): - p = testdir.makepyfile( +def test_setup_fails_again_on_all_tests(pytester: Pytester) -> None: + p = pytester.makepyfile( """ import pytest def setup_module(mod): @@ -208,12 +208,12 @@ def test_function2(): pass """ ) - reprec = testdir.inline_run(p) + reprec = pytester.inline_run(p) reprec.assertoutcome(failed=2) -def test_setup_funcarg_setup_when_outer_scope_fails(testdir): - p = testdir.makepyfile( +def test_setup_funcarg_setup_when_outer_scope_fails(pytester: Pytester) -> None: + p = pytester.makepyfile( """ import pytest def setup_module(mod): @@ -227,7 +227,7 @@ def test_function2(hello): pass """ ) - result = testdir.runpytest(p) + result = pytester.runpytest(p) result.stdout.fnmatch_lines( [ "*function1*", @@ -242,16 +242,16 @@ def test_function2(hello): @pytest.mark.parametrize("arg", ["", "arg"]) def test_setup_teardown_function_level_with_optional_argument( - testdir, monkeypatch, arg -): - """parameter to setup/teardown xunit-style functions parameter is now optional (#1728).""" + pytester: Pytester, monkeypatch, arg: str, +) -> None: + """Parameter to setup/teardown xunit-style functions parameter is now optional (#1728).""" import sys - trace_setups_teardowns = [] + trace_setups_teardowns: List[str] = [] monkeypatch.setattr( sys, "trace_setups_teardowns", trace_setups_teardowns, raising=False ) - p = testdir.makepyfile( + p = pytester.makepyfile( """ import pytest import sys @@ -277,7 +277,7 @@ def test_method_2(self): pass arg=arg ) ) - result = testdir.inline_run(p) + result = pytester.inline_run(p) result.assertoutcome(passed=4) expected = [ diff --git a/testing/test_session.py b/testing/test_session.py index 1800771dad5..5389e5b2b19 100644 --- a/testing/test_session.py +++ b/testing/test_session.py @@ -1,10 +1,12 @@ import pytest from _pytest.config import ExitCode +from _pytest.monkeypatch import MonkeyPatch +from _pytest.pytester import Pytester class SessionTests: - def test_basic_testitem_events(self, testdir): - tfile = testdir.makepyfile( + def test_basic_testitem_events(self, pytester: Pytester) -> None: + tfile = pytester.makepyfile( """ def test_one(): pass @@ -17,7 +19,7 @@ def test_two(self, someargs): pass """ ) - reprec = testdir.inline_run(tfile) + reprec = pytester.inline_run(tfile) passed, skipped, failed = reprec.listoutcomes() assert len(skipped) == 0 assert len(passed) == 1 @@ -35,8 +37,8 @@ def end(x): # assert len(colreports) == 4 # assert colreports[1].report.failed - def test_nested_import_error(self, testdir): - tfile = testdir.makepyfile( + def test_nested_import_error(self, pytester: Pytester) -> None: + tfile = pytester.makepyfile( """ import import_fails def test_this(): @@ -47,14 +49,14 @@ def test_this(): a = 1 """, ) - reprec = testdir.inline_run(tfile) + reprec = pytester.inline_run(tfile) values = reprec.getfailedcollections() assert len(values) == 1 out = str(values[0].longrepr) assert out.find("does_not_work") != -1 - def test_raises_output(self, testdir): - reprec = testdir.inline_runsource( + def test_raises_output(self, pytester: Pytester) -> None: + reprec = pytester.inline_runsource( """ import pytest def test_raises_doesnt(): @@ -63,18 +65,18 @@ def test_raises_doesnt(): ) passed, skipped, failed = reprec.listoutcomes() assert len(failed) == 1 - out = failed[0].longrepr.reprcrash.message + out = failed[0].longrepr.reprcrash.message # type: ignore[union-attr] assert "DID NOT RAISE" in out - def test_syntax_error_module(self, testdir): - reprec = testdir.inline_runsource("this is really not python") + def test_syntax_error_module(self, pytester: Pytester) -> None: + reprec = pytester.inline_runsource("this is really not python") values = reprec.getfailedcollections() assert len(values) == 1 out = str(values[0].longrepr) assert out.find("not python") != -1 - def test_exit_first_problem(self, testdir): - reprec = testdir.inline_runsource( + def test_exit_first_problem(self, pytester: Pytester) -> None: + reprec = pytester.inline_runsource( """ def test_one(): assert 0 def test_two(): assert 0 @@ -85,8 +87,8 @@ def test_two(): assert 0 assert failed == 1 assert passed == skipped == 0 - def test_maxfail(self, testdir): - reprec = testdir.inline_runsource( + def test_maxfail(self, pytester: Pytester) -> None: + reprec = pytester.inline_runsource( """ def test_one(): assert 0 def test_two(): assert 0 @@ -98,8 +100,8 @@ def test_three(): assert 0 assert failed == 2 assert passed == skipped == 0 - def test_broken_repr(self, testdir): - p = testdir.makepyfile( + def test_broken_repr(self, pytester: Pytester) -> None: + p = pytester.makepyfile( """ import pytest @@ -124,14 +126,14 @@ def test_implicit_bad_repr1(self): """ ) - reprec = testdir.inline_run(p) + reprec = pytester.inline_run(p) passed, skipped, failed = reprec.listoutcomes() assert (len(passed), len(skipped), len(failed)) == (1, 0, 1) - out = failed[0].longrepr.reprcrash.message + out = failed[0].longrepr.reprcrash.message # type: ignore[union-attr] assert out.find("<[reprexc() raised in repr()] BrokenRepr1") != -1 - def test_broken_repr_with_showlocals_verbose(self, testdir): - p = testdir.makepyfile( + def test_broken_repr_with_showlocals_verbose(self, pytester: Pytester) -> None: + p = pytester.makepyfile( """ class ObjWithErrorInRepr: def __repr__(self): @@ -142,10 +144,10 @@ def test_repr_error(): assert x == "value" """ ) - reprec = testdir.inline_run("--showlocals", "-vv", p) + reprec = pytester.inline_run("--showlocals", "-vv", p) passed, skipped, failed = reprec.listoutcomes() assert (len(passed), len(skipped), len(failed)) == (0, 0, 1) - entries = failed[0].longrepr.reprtraceback.reprentries + entries = failed[0].longrepr.reprtraceback.reprentries # type: ignore[union-attr] assert len(entries) == 1 repr_locals = entries[0].reprlocals assert repr_locals.lines @@ -154,8 +156,8 @@ def test_repr_error(): "x = <[NotImplementedError() raised in repr()] ObjWithErrorInRepr" ) - def test_skip_file_by_conftest(self, testdir): - testdir.makepyfile( + def test_skip_file_by_conftest(self, pytester: Pytester) -> None: + pytester.makepyfile( conftest=""" import pytest def pytest_collect_file(): @@ -166,7 +168,7 @@ def test_one(): pass """, ) try: - reprec = testdir.inline_run(testdir.tmpdir) + reprec = pytester.inline_run(pytester.path) except pytest.skip.Exception: # pragma: no cover pytest.fail("wrong skipped caught") reports = reprec.getreports("pytest_collectreport") @@ -175,8 +177,8 @@ def test_one(): pass class TestNewSession(SessionTests): - def test_order_of_execution(self, testdir): - reprec = testdir.inline_runsource( + def test_order_of_execution(self, pytester: Pytester) -> None: + reprec = pytester.inline_runsource( """ values = [] def test_1(): @@ -201,8 +203,8 @@ def test_4(self): assert failed == skipped == 0 assert passed == 7 - def test_collect_only_with_various_situations(self, testdir): - p = testdir.makepyfile( + def test_collect_only_with_various_situations(self, pytester: Pytester) -> None: + p = pytester.makepyfile( test_one=""" def test_one(): raise ValueError() @@ -217,7 +219,7 @@ class TestY(TestX): test_three="xxxdsadsadsadsa", __init__="", ) - reprec = testdir.inline_run("--collect-only", p.dirpath()) + reprec = pytester.inline_run("--collect-only", p.parent) itemstarted = reprec.getcalls("pytest_itemcollected") assert len(itemstarted) == 3 @@ -229,66 +231,66 @@ class TestY(TestX): colfail = [x for x in finished if x.failed] assert len(colfail) == 1 - def test_minus_x_import_error(self, testdir): - testdir.makepyfile(__init__="") - testdir.makepyfile(test_one="xxxx", test_two="yyyy") - reprec = testdir.inline_run("-x", testdir.tmpdir) + def test_minus_x_import_error(self, pytester: Pytester) -> None: + pytester.makepyfile(__init__="") + pytester.makepyfile(test_one="xxxx", test_two="yyyy") + reprec = pytester.inline_run("-x", pytester.path) finished = reprec.getreports("pytest_collectreport") colfail = [x for x in finished if x.failed] assert len(colfail) == 1 - def test_minus_x_overridden_by_maxfail(self, testdir): - testdir.makepyfile(__init__="") - testdir.makepyfile(test_one="xxxx", test_two="yyyy", test_third="zzz") - reprec = testdir.inline_run("-x", "--maxfail=2", testdir.tmpdir) + def test_minus_x_overridden_by_maxfail(self, pytester: Pytester) -> None: + pytester.makepyfile(__init__="") + pytester.makepyfile(test_one="xxxx", test_two="yyyy", test_third="zzz") + reprec = pytester.inline_run("-x", "--maxfail=2", pytester.path) finished = reprec.getreports("pytest_collectreport") colfail = [x for x in finished if x.failed] assert len(colfail) == 2 -def test_plugin_specify(testdir): +def test_plugin_specify(pytester: Pytester) -> None: with pytest.raises(ImportError): - testdir.parseconfig("-p", "nqweotexistent") + pytester.parseconfig("-p", "nqweotexistent") # pytest.raises(ImportError, # "config.do_configure(config)" # ) -def test_plugin_already_exists(testdir): - config = testdir.parseconfig("-p", "terminal") +def test_plugin_already_exists(pytester: Pytester) -> None: + config = pytester.parseconfig("-p", "terminal") assert config.option.plugins == ["terminal"] config._do_configure() config._ensure_unconfigure() -def test_exclude(testdir): - hellodir = testdir.mkdir("hello") - hellodir.join("test_hello.py").write("x y syntaxerror") - hello2dir = testdir.mkdir("hello2") - hello2dir.join("test_hello2.py").write("x y syntaxerror") - testdir.makepyfile(test_ok="def test_pass(): pass") - result = testdir.runpytest("--ignore=hello", "--ignore=hello2") +def test_exclude(pytester: Pytester) -> None: + hellodir = pytester.mkdir("hello") + hellodir.joinpath("test_hello.py").write_text("x y syntaxerror") + hello2dir = pytester.mkdir("hello2") + hello2dir.joinpath("test_hello2.py").write_text("x y syntaxerror") + pytester.makepyfile(test_ok="def test_pass(): pass") + result = pytester.runpytest("--ignore=hello", "--ignore=hello2") assert result.ret == 0 result.stdout.fnmatch_lines(["*1 passed*"]) -def test_exclude_glob(testdir): - hellodir = testdir.mkdir("hello") - hellodir.join("test_hello.py").write("x y syntaxerror") - hello2dir = testdir.mkdir("hello2") - hello2dir.join("test_hello2.py").write("x y syntaxerror") - hello3dir = testdir.mkdir("hallo3") - hello3dir.join("test_hello3.py").write("x y syntaxerror") - subdir = testdir.mkdir("sub") - subdir.join("test_hello4.py").write("x y syntaxerror") - testdir.makepyfile(test_ok="def test_pass(): pass") - result = testdir.runpytest("--ignore-glob=*h[ea]llo*") +def test_exclude_glob(pytester: Pytester) -> None: + hellodir = pytester.mkdir("hello") + hellodir.joinpath("test_hello.py").write_text("x y syntaxerror") + hello2dir = pytester.mkdir("hello2") + hello2dir.joinpath("test_hello2.py").write_text("x y syntaxerror") + hello3dir = pytester.mkdir("hallo3") + hello3dir.joinpath("test_hello3.py").write_text("x y syntaxerror") + subdir = pytester.mkdir("sub") + subdir.joinpath("test_hello4.py").write_text("x y syntaxerror") + pytester.makepyfile(test_ok="def test_pass(): pass") + result = pytester.runpytest("--ignore-glob=*h[ea]llo*") assert result.ret == 0 result.stdout.fnmatch_lines(["*1 passed*"]) -def test_deselect(testdir): - testdir.makepyfile( +def test_deselect(pytester: Pytester) -> None: + pytester.makepyfile( test_a=""" import pytest @@ -303,7 +305,7 @@ def test_c1(self): pass def test_c2(self): pass """ ) - result = testdir.runpytest( + result = pytester.runpytest( "-v", "--deselect=test_a.py::test_a2[1]", "--deselect=test_a.py::test_a2[2]", @@ -315,8 +317,8 @@ def test_c2(self): pass assert not line.startswith(("test_a.py::test_a2[1]", "test_a.py::test_a2[2]")) -def test_sessionfinish_with_start(testdir): - testdir.makeconftest( +def test_sessionfinish_with_start(pytester: Pytester) -> None: + pytester.makeconftest( """ import os values = [] @@ -329,18 +331,20 @@ def pytest_sessionfinish(): """ ) - res = testdir.runpytest("--collect-only") + res = pytester.runpytest("--collect-only") assert res.ret == ExitCode.NO_TESTS_COLLECTED @pytest.mark.parametrize("path", ["root", "{relative}/root", "{environment}/root"]) -def test_rootdir_option_arg(testdir, monkeypatch, path): - monkeypatch.setenv("PY_ROOTDIR_PATH", str(testdir.tmpdir)) - path = path.format(relative=str(testdir.tmpdir), environment="$PY_ROOTDIR_PATH") - - rootdir = testdir.mkdir("root") - rootdir.mkdir("tests") - testdir.makepyfile( +def test_rootdir_option_arg( + pytester: Pytester, monkeypatch: MonkeyPatch, path: str +) -> None: + monkeypatch.setenv("PY_ROOTDIR_PATH", str(pytester.path)) + path = path.format(relative=str(pytester.path), environment="$PY_ROOTDIR_PATH") + + rootdir = pytester.path / "root" / "tests" + rootdir.mkdir(parents=True) + pytester.makepyfile( """ import os def test_one(): @@ -348,18 +352,18 @@ def test_one(): """ ) - result = testdir.runpytest("--rootdir={}".format(path)) + result = pytester.runpytest(f"--rootdir={path}") result.stdout.fnmatch_lines( [ - "*rootdir: {}/root".format(testdir.tmpdir), + f"*rootdir: {pytester.path}/root", "root/test_rootdir_option_arg.py *", "*1 passed*", ] ) -def test_rootdir_wrong_option_arg(testdir): - result = testdir.runpytest("--rootdir=wrong_dir") +def test_rootdir_wrong_option_arg(pytester: Pytester) -> None: + result = pytester.runpytest("--rootdir=wrong_dir") result.stderr.fnmatch_lines( ["*Directory *wrong_dir* not found. Check your '--rootdir' option.*"] ) diff --git a/testing/test_setuponly.py b/testing/test_setuponly.py index e26a33dee38..fe4bdc514eb 100644 --- a/testing/test_setuponly.py +++ b/testing/test_setuponly.py @@ -1,5 +1,8 @@ +import sys + import pytest from _pytest.config import ExitCode +from _pytest.pytester import Pytester @pytest.fixture(params=["--setup-only", "--setup-plan", "--setup-show"], scope="module") @@ -7,8 +10,10 @@ def mode(request): return request.param -def test_show_only_active_fixtures(testdir, mode, dummy_yaml_custom_test): - testdir.makepyfile( +def test_show_only_active_fixtures( + pytester: Pytester, mode, dummy_yaml_custom_test +) -> None: + pytester.makepyfile( ''' import pytest @pytest.fixture @@ -22,7 +27,7 @@ def test_arg1(arg1): ''' ) - result = testdir.runpytest(mode) + result = pytester.runpytest(mode) assert result.ret == 0 result.stdout.fnmatch_lines( @@ -31,8 +36,8 @@ def test_arg1(arg1): result.stdout.no_fnmatch_line("*_arg0*") -def test_show_different_scopes(testdir, mode): - p = testdir.makepyfile( +def test_show_different_scopes(pytester: Pytester, mode) -> None: + p = pytester.makepyfile( ''' import pytest @pytest.fixture @@ -46,7 +51,7 @@ def test_arg1(arg_session, arg_function): ''' ) - result = testdir.runpytest(mode, p) + result = pytester.runpytest(mode, p) assert result.ret == 0 result.stdout.fnmatch_lines( @@ -60,8 +65,8 @@ def test_arg1(arg_session, arg_function): ) -def test_show_nested_fixtures(testdir, mode): - testdir.makeconftest( +def test_show_nested_fixtures(pytester: Pytester, mode) -> None: + pytester.makeconftest( ''' import pytest @pytest.fixture(scope='session') @@ -69,7 +74,7 @@ def arg_same(): """session scoped fixture""" ''' ) - p = testdir.makepyfile( + p = pytester.makepyfile( ''' import pytest @pytest.fixture(scope='function') @@ -80,7 +85,7 @@ def test_arg1(arg_same): ''' ) - result = testdir.runpytest(mode, p) + result = pytester.runpytest(mode, p) assert result.ret == 0 result.stdout.fnmatch_lines( @@ -94,8 +99,8 @@ def test_arg1(arg_same): ) -def test_show_fixtures_with_autouse(testdir, mode): - p = testdir.makepyfile( +def test_show_fixtures_with_autouse(pytester: Pytester, mode) -> None: + p = pytester.makepyfile( ''' import pytest @pytest.fixture @@ -109,7 +114,7 @@ def test_arg1(arg_function): ''' ) - result = testdir.runpytest(mode, p) + result = pytester.runpytest(mode, p) assert result.ret == 0 result.stdout.fnmatch_lines( @@ -121,8 +126,8 @@ def test_arg1(arg_function): ) -def test_show_fixtures_with_parameters(testdir, mode): - testdir.makeconftest( +def test_show_fixtures_with_parameters(pytester: Pytester, mode) -> None: + pytester.makeconftest( ''' import pytest @pytest.fixture(scope='session', params=['foo', 'bar']) @@ -130,7 +135,7 @@ def arg_same(): """session scoped fixture""" ''' ) - p = testdir.makepyfile( + p = pytester.makepyfile( ''' import pytest @pytest.fixture(scope='function') @@ -141,21 +146,21 @@ def test_arg1(arg_other): ''' ) - result = testdir.runpytest(mode, p) + result = pytester.runpytest(mode, p) assert result.ret == 0 result.stdout.fnmatch_lines( [ - "SETUP S arg_same?foo?", - "TEARDOWN S arg_same?foo?", - "SETUP S arg_same?bar?", - "TEARDOWN S arg_same?bar?", + "SETUP S arg_same?'foo'?", + "TEARDOWN S arg_same?'foo'?", + "SETUP S arg_same?'bar'?", + "TEARDOWN S arg_same?'bar'?", ] ) -def test_show_fixtures_with_parameter_ids(testdir, mode): - testdir.makeconftest( +def test_show_fixtures_with_parameter_ids(pytester: Pytester, mode) -> None: + pytester.makeconftest( ''' import pytest @pytest.fixture( @@ -164,7 +169,7 @@ def arg_same(): """session scoped fixture""" ''' ) - p = testdir.makepyfile( + p = pytester.makepyfile( ''' import pytest @pytest.fixture(scope='function') @@ -175,16 +180,16 @@ def test_arg1(arg_other): ''' ) - result = testdir.runpytest(mode, p) + result = pytester.runpytest(mode, p) assert result.ret == 0 result.stdout.fnmatch_lines( - ["SETUP S arg_same?spam?", "SETUP S arg_same?ham?"] + ["SETUP S arg_same?'spam'?", "SETUP S arg_same?'ham'?"] ) -def test_show_fixtures_with_parameter_ids_function(testdir, mode): - p = testdir.makepyfile( +def test_show_fixtures_with_parameter_ids_function(pytester: Pytester, mode) -> None: + p = pytester.makepyfile( """ import pytest @pytest.fixture(params=['foo', 'bar'], ids=lambda p: p.upper()) @@ -195,14 +200,16 @@ def test_foobar(foobar): """ ) - result = testdir.runpytest(mode, p) + result = pytester.runpytest(mode, p) assert result.ret == 0 - result.stdout.fnmatch_lines(["*SETUP F foobar?FOO?", "*SETUP F foobar?BAR?"]) + result.stdout.fnmatch_lines( + ["*SETUP F foobar?'FOO'?", "*SETUP F foobar?'BAR'?"] + ) -def test_dynamic_fixture_request(testdir): - p = testdir.makepyfile( +def test_dynamic_fixture_request(pytester: Pytester) -> None: + p = pytester.makepyfile( """ import pytest @pytest.fixture() @@ -216,7 +223,7 @@ def test_dyn(dependent_fixture): """ ) - result = testdir.runpytest("--setup-only", p) + result = pytester.runpytest("--setup-only", p) assert result.ret == 0 result.stdout.fnmatch_lines( @@ -227,8 +234,8 @@ def test_dyn(dependent_fixture): ) -def test_capturing(testdir): - p = testdir.makepyfile( +def test_capturing(pytester: Pytester) -> None: + p = pytester.makepyfile( """ import pytest, sys @pytest.fixture() @@ -243,15 +250,15 @@ def test_capturing(two): """ ) - result = testdir.runpytest("--setup-only", p) + result = pytester.runpytest("--setup-only", p) result.stdout.fnmatch_lines( ["this should be captured", "this should also be captured"] ) -def test_show_fixtures_and_execute_test(testdir): - """ Verifies that setups are shown and tests are executed. """ - p = testdir.makepyfile( +def test_show_fixtures_and_execute_test(pytester: Pytester) -> None: + """Verify that setups are shown and tests are executed.""" + p = pytester.makepyfile( """ import pytest @pytest.fixture @@ -262,7 +269,7 @@ def test_arg(arg): """ ) - result = testdir.runpytest("--setup-show", p) + result = pytester.runpytest("--setup-show", p) assert result.ret == 1 result.stdout.fnmatch_lines( @@ -270,8 +277,8 @@ def test_arg(arg): ) -def test_setup_show_with_KeyboardInterrupt_in_test(testdir): - p = testdir.makepyfile( +def test_setup_show_with_KeyboardInterrupt_in_test(pytester: Pytester) -> None: + p = pytester.makepyfile( """ import pytest @pytest.fixture @@ -281,7 +288,7 @@ def test_arg(arg): raise KeyboardInterrupt() """ ) - result = testdir.runpytest("--setup-show", p, no_reraise_ctrlc=True) + result = pytester.runpytest("--setup-show", p, no_reraise_ctrlc=True) result.stdout.fnmatch_lines( [ "*SETUP F arg*", @@ -292,3 +299,20 @@ def test_arg(arg): ] ) assert result.ret == ExitCode.INTERRUPTED + + +def test_show_fixture_action_with_bytes(pytester: Pytester) -> None: + # Issue 7126, BytesWarning when using --setup-show with bytes parameter + test_file = pytester.makepyfile( + """ + import pytest + + @pytest.mark.parametrize('data', [b'Hello World']) + def test_data(data): + pass + """ + ) + result = pytester.run( + sys.executable, "-bb", "-m", "pytest", "--setup-show", str(test_file) + ) + assert result.ret == 0 diff --git a/testing/test_setupplan.py b/testing/test_setupplan.py index a44474dd155..d51a1873959 100644 --- a/testing/test_setupplan.py +++ b/testing/test_setupplan.py @@ -1,6 +1,11 @@ -def test_show_fixtures_and_test(testdir, dummy_yaml_custom_test): - """ Verifies that fixtures are not executed. """ - testdir.makepyfile( +from _pytest.pytester import Pytester + + +def test_show_fixtures_and_test( + pytester: Pytester, dummy_yaml_custom_test: None +) -> None: + """Verify that fixtures are not executed.""" + pytester.makepyfile( """ import pytest @pytest.fixture @@ -11,7 +16,7 @@ def test_arg(arg): """ ) - result = testdir.runpytest("--setup-plan") + result = pytester.runpytest("--setup-plan") assert result.ret == 0 result.stdout.fnmatch_lines( @@ -19,9 +24,10 @@ def test_arg(arg): ) -def test_show_multi_test_fixture_setup_and_teardown_correctly_simple(testdir): - """ - Verify that when a fixture lives for longer than a single test, --setup-plan +def test_show_multi_test_fixture_setup_and_teardown_correctly_simple( + pytester: Pytester, +) -> None: + """Verify that when a fixture lives for longer than a single test, --setup-plan correctly displays the SETUP/TEARDOWN indicators the right number of times. As reported in https://github.com/pytest-dev/pytest/issues/2049 @@ -32,7 +38,7 @@ def test_show_multi_test_fixture_setup_and_teardown_correctly_simple(testdir): correct fixture lifetimes. It was purely a display bug for --setup-plan, and did not affect the related --setup-show or --setup-only.) """ - testdir.makepyfile( + pytester.makepyfile( """ import pytest @pytest.fixture(scope = 'class') @@ -46,7 +52,7 @@ def test_two(self, fix): """ ) - result = testdir.runpytest("--setup-plan") + result = pytester.runpytest("--setup-plan") assert result.ret == 0 setup_fragment = "SETUP C fix" @@ -67,11 +73,11 @@ def test_two(self, fix): assert teardown_count == 1 -def test_show_multi_test_fixture_setup_and_teardown_same_as_setup_show(testdir): - """ - Verify that SETUP/TEARDOWN messages match what comes out of --setup-show. - """ - testdir.makepyfile( +def test_show_multi_test_fixture_setup_and_teardown_same_as_setup_show( + pytester: Pytester, +) -> None: + """Verify that SETUP/TEARDOWN messages match what comes out of --setup-show.""" + pytester.makepyfile( """ import pytest @pytest.fixture(scope = 'session') @@ -96,15 +102,19 @@ def test_two(self, sess, mod, cls, func): """ ) - plan_result = testdir.runpytest("--setup-plan") - show_result = testdir.runpytest("--setup-show") + plan_result = pytester.runpytest("--setup-plan") + show_result = pytester.runpytest("--setup-show") # the number and text of these lines should be identical plan_lines = [ - l for l in plan_result.stdout.lines if "SETUP" in l or "TEARDOWN" in l + line + for line in plan_result.stdout.lines + if "SETUP" in line or "TEARDOWN" in line ] show_lines = [ - l for l in show_result.stdout.lines if "SETUP" in l or "TEARDOWN" in l + line + for line in show_result.stdout.lines + if "SETUP" in line or "TEARDOWN" in line ] assert plan_lines == show_lines diff --git a/testing/test_skipping.py b/testing/test_skipping.py index 67714d030ed..fc66eb18e64 100644 --- a/testing/test_skipping.py +++ b/testing/test_skipping.py @@ -1,72 +1,80 @@ import sys +import textwrap import pytest +from _pytest.pytester import Pytester from _pytest.runner import runtestprotocol -from _pytest.skipping import MarkEvaluator +from _pytest.skipping import evaluate_skip_marks +from _pytest.skipping import evaluate_xfail_marks from _pytest.skipping import pytest_runtest_setup -class TestEvaluator: - def test_no_marker(self, testdir): - item = testdir.getitem("def test_func(): pass") - evalskipif = MarkEvaluator(item, "skipif") - assert not evalskipif - assert not evalskipif.istrue() +class TestEvaluation: + def test_no_marker(self, pytester: Pytester) -> None: + item = pytester.getitem("def test_func(): pass") + skipped = evaluate_skip_marks(item) + assert not skipped - def test_marked_no_args(self, testdir): - item = testdir.getitem( + def test_marked_xfail_no_args(self, pytester: Pytester) -> None: + item = pytester.getitem( """ import pytest - @pytest.mark.xyz + @pytest.mark.xfail + def test_func(): + pass + """ + ) + xfailed = evaluate_xfail_marks(item) + assert xfailed + assert xfailed.reason == "" + assert xfailed.run + + def test_marked_skipif_no_args(self, pytester: Pytester) -> None: + item = pytester.getitem( + """ + import pytest + @pytest.mark.skipif def test_func(): pass """ ) - ev = MarkEvaluator(item, "xyz") - assert ev - assert ev.istrue() - expl = ev.getexplanation() - assert expl == "" - assert not ev.get("run", False) + skipped = evaluate_skip_marks(item) + assert skipped + assert skipped.reason == "" - def test_marked_one_arg(self, testdir): - item = testdir.getitem( + def test_marked_one_arg(self, pytester: Pytester) -> None: + item = pytester.getitem( """ import pytest - @pytest.mark.xyz("hasattr(os, 'sep')") + @pytest.mark.skipif("hasattr(os, 'sep')") def test_func(): pass """ ) - ev = MarkEvaluator(item, "xyz") - assert ev - assert ev.istrue() - expl = ev.getexplanation() - assert expl == "condition: hasattr(os, 'sep')" + skipped = evaluate_skip_marks(item) + assert skipped + assert skipped.reason == "condition: hasattr(os, 'sep')" - def test_marked_one_arg_with_reason(self, testdir): - item = testdir.getitem( + def test_marked_one_arg_with_reason(self, pytester: Pytester) -> None: + item = pytester.getitem( """ import pytest - @pytest.mark.xyz("hasattr(os, 'sep')", attr=2, reason="hello world") + @pytest.mark.skipif("hasattr(os, 'sep')", attr=2, reason="hello world") def test_func(): pass """ ) - ev = MarkEvaluator(item, "xyz") - assert ev - assert ev.istrue() - expl = ev.getexplanation() - assert expl == "hello world" - assert ev.get("attr") == 2 + skipped = evaluate_skip_marks(item) + assert skipped + assert skipped.reason == "hello world" - def test_marked_one_arg_twice(self, testdir): + def test_marked_one_arg_twice(self, pytester: Pytester) -> None: lines = [ """@pytest.mark.skipif("not hasattr(os, 'murks')")""", - """@pytest.mark.skipif("hasattr(os, 'murks')")""", + """@pytest.mark.skipif(condition="hasattr(os, 'murks')")""", ] for i in range(0, 2): - item = testdir.getitem( + item = pytester.getitem( """ import pytest %s @@ -76,14 +84,12 @@ def test_func(): """ % (lines[i], lines[(i + 1) % 2]) ) - ev = MarkEvaluator(item, "skipif") - assert ev - assert ev.istrue() - expl = ev.getexplanation() - assert expl == "condition: not hasattr(os, 'murks')" - - def test_marked_one_arg_twice2(self, testdir): - item = testdir.getitem( + skipped = evaluate_skip_marks(item) + assert skipped + assert skipped.reason == "condition: not hasattr(os, 'murks')" + + def test_marked_one_arg_twice2(self, pytester: Pytester) -> None: + item = pytester.getitem( """ import pytest @pytest.mark.skipif("hasattr(os, 'murks')") @@ -92,14 +98,14 @@ def test_func(): pass """ ) - ev = MarkEvaluator(item, "skipif") - assert ev - assert ev.istrue() - expl = ev.getexplanation() - assert expl == "condition: not hasattr(os, 'murks')" - - def test_marked_skip_with_not_string(self, testdir): - item = testdir.getitem( + skipped = evaluate_skip_marks(item) + assert skipped + assert skipped.reason == "condition: not hasattr(os, 'murks')" + + def test_marked_skipif_with_boolean_without_reason( + self, pytester: Pytester + ) -> None: + item = pytester.getitem( """ import pytest @pytest.mark.skipif(False) @@ -107,15 +113,36 @@ def test_func(): pass """ ) - ev = MarkEvaluator(item, "skipif") - exc = pytest.raises(pytest.fail.Exception, ev.istrue) + with pytest.raises(pytest.fail.Exception) as excinfo: + evaluate_skip_marks(item) + assert excinfo.value.msg is not None assert ( - """Failed: you need to specify reason=STRING when using booleans as conditions.""" - in exc.value.msg + """Error evaluating 'skipif': you need to specify reason=STRING when using booleans as conditions.""" + in excinfo.value.msg ) - def test_skipif_class(self, testdir): - (item,) = testdir.getitems( + def test_marked_skipif_with_invalid_boolean(self, pytester: Pytester) -> None: + item = pytester.getitem( + """ + import pytest + + class InvalidBool: + def __bool__(self): + raise TypeError("INVALID") + + @pytest.mark.skipif(InvalidBool(), reason="xxx") + def test_func(): + pass + """ + ) + with pytest.raises(pytest.fail.Exception) as excinfo: + evaluate_skip_marks(item) + assert excinfo.value.msg is not None + assert "Error evaluating 'skipif' condition as a boolean" in excinfo.value.msg + assert "INVALID" in excinfo.value.msg + + def test_skipif_class(self, pytester: Pytester) -> None: + (item,) = pytester.getitems( """ import pytest class TestClass(object): @@ -124,17 +151,146 @@ def test_func(self): pass """ ) - item.config._hackxyz = 3 - ev = MarkEvaluator(item, "skipif") - assert ev.istrue() - expl = ev.getexplanation() - assert expl == "condition: config._hackxyz" + item.config._hackxyz = 3 # type: ignore[attr-defined] + skipped = evaluate_skip_marks(item) + assert skipped + assert skipped.reason == "condition: config._hackxyz" + + def test_skipif_markeval_namespace(self, pytester: Pytester) -> None: + pytester.makeconftest( + """ + import pytest + + def pytest_markeval_namespace(): + return {"color": "green"} + """ + ) + p = pytester.makepyfile( + """ + import pytest + + @pytest.mark.skipif("color == 'green'") + def test_1(): + assert True + + @pytest.mark.skipif("color == 'red'") + def test_2(): + assert True + """ + ) + res = pytester.runpytest(p) + assert res.ret == 0 + res.stdout.fnmatch_lines(["*1 skipped*"]) + res.stdout.fnmatch_lines(["*1 passed*"]) + + def test_skipif_markeval_namespace_multiple(self, pytester: Pytester) -> None: + """Keys defined by ``pytest_markeval_namespace()`` in nested plugins override top-level ones.""" + root = pytester.mkdir("root") + root.joinpath("__init__.py").touch() + root.joinpath("conftest.py").write_text( + textwrap.dedent( + """\ + import pytest + + def pytest_markeval_namespace(): + return {"arg": "root"} + """ + ) + ) + root.joinpath("test_root.py").write_text( + textwrap.dedent( + """\ + import pytest + + @pytest.mark.skipif("arg == 'root'") + def test_root(): + assert False + """ + ) + ) + foo = root.joinpath("foo") + foo.mkdir() + foo.joinpath("__init__.py").touch() + foo.joinpath("conftest.py").write_text( + textwrap.dedent( + """\ + import pytest + + def pytest_markeval_namespace(): + return {"arg": "foo"} + """ + ) + ) + foo.joinpath("test_foo.py").write_text( + textwrap.dedent( + """\ + import pytest + + @pytest.mark.skipif("arg == 'foo'") + def test_foo(): + assert False + """ + ) + ) + bar = root.joinpath("bar") + bar.mkdir() + bar.joinpath("__init__.py").touch() + bar.joinpath("conftest.py").write_text( + textwrap.dedent( + """\ + import pytest + + def pytest_markeval_namespace(): + return {"arg": "bar"} + """ + ) + ) + bar.joinpath("test_bar.py").write_text( + textwrap.dedent( + """\ + import pytest + + @pytest.mark.skipif("arg == 'bar'") + def test_bar(): + assert False + """ + ) + ) + + reprec = pytester.inline_run("-vs", "--capture=no") + reprec.assertoutcome(skipped=3) + + def test_skipif_markeval_namespace_ValueError(self, pytester: Pytester) -> None: + pytester.makeconftest( + """ + import pytest + + def pytest_markeval_namespace(): + return True + """ + ) + p = pytester.makepyfile( + """ + import pytest + + @pytest.mark.skipif("color == 'green'") + def test_1(): + assert True + """ + ) + res = pytester.runpytest(p) + assert res.ret == 1 + res.stdout.fnmatch_lines( + [ + "*ValueError: pytest_markeval_namespace() needs to return a dict, got True*" + ] + ) class TestXFail: @pytest.mark.parametrize("strict", [True, False]) - def test_xfail_simple(self, testdir, strict): - item = testdir.getitem( + def test_xfail_simple(self, pytester: Pytester, strict: bool) -> None: + item = pytester.getitem( """ import pytest @pytest.mark.xfail(strict=%s) @@ -149,8 +305,8 @@ def test_func(): assert callreport.skipped assert callreport.wasxfail == "" - def test_xfail_xpassed(self, testdir): - item = testdir.getitem( + def test_xfail_xpassed(self, pytester: Pytester) -> None: + item = pytester.getitem( """ import pytest @pytest.mark.xfail(reason="this is an xfail") @@ -164,11 +320,9 @@ def test_func(): assert callreport.passed assert callreport.wasxfail == "this is an xfail" - def test_xfail_using_platform(self, testdir): - """ - Verify that platform can be used with xfail statements. - """ - item = testdir.getitem( + def test_xfail_using_platform(self, pytester: Pytester) -> None: + """Verify that platform can be used with xfail statements.""" + item = pytester.getitem( """ import pytest @pytest.mark.xfail("platform.platform() == platform.platform()") @@ -181,8 +335,8 @@ def test_func(): callreport = reports[1] assert callreport.wasxfail - def test_xfail_xpassed_strict(self, testdir): - item = testdir.getitem( + def test_xfail_xpassed_strict(self, pytester: Pytester) -> None: + item = pytester.getitem( """ import pytest @pytest.mark.xfail(strict=True, reason="nope") @@ -194,11 +348,11 @@ def test_func(): assert len(reports) == 3 callreport = reports[1] assert callreport.failed - assert callreport.longrepr == "[XPASS(strict)] nope" + assert str(callreport.longrepr) == "[XPASS(strict)] nope" assert not hasattr(callreport, "wasxfail") - def test_xfail_run_anyway(self, testdir): - testdir.makepyfile( + def test_xfail_run_anyway(self, pytester: Pytester) -> None: + pytester.makepyfile( """ import pytest @pytest.mark.xfail @@ -208,13 +362,40 @@ def test_func2(): pytest.xfail("hello") """ ) - result = testdir.runpytest("--runxfail") + result = pytester.runpytest("--runxfail") result.stdout.fnmatch_lines( ["*def test_func():*", "*assert 0*", "*1 failed*1 pass*"] ) - def test_xfail_evalfalse_but_fails(self, testdir): - item = testdir.getitem( + @pytest.mark.parametrize( + "test_input,expected", + [ + ( + ["-rs"], + ["SKIPPED [1] test_sample.py:2: unconditional skip", "*1 skipped*"], + ), + ( + ["-rs", "--runxfail"], + ["SKIPPED [1] test_sample.py:2: unconditional skip", "*1 skipped*"], + ), + ], + ) + def test_xfail_run_with_skip_mark( + self, pytester: Pytester, test_input, expected + ) -> None: + pytester.makepyfile( + test_sample=""" + import pytest + @pytest.mark.skip + def test_skip_location() -> None: + assert 0 + """ + ) + result = pytester.runpytest(*test_input) + result.stdout.fnmatch_lines(expected) + + def test_xfail_evalfalse_but_fails(self, pytester: Pytester) -> None: + item = pytester.getitem( """ import pytest @pytest.mark.xfail('False') @@ -228,8 +409,8 @@ def test_func(): assert not hasattr(callreport, "wasxfail") assert "xfail" in callreport.keywords - def test_xfail_not_report_default(self, testdir): - p = testdir.makepyfile( + def test_xfail_not_report_default(self, pytester: Pytester) -> None: + p = pytester.makepyfile( test_one=""" import pytest @pytest.mark.xfail @@ -237,13 +418,13 @@ def test_this(): assert 0 """ ) - testdir.runpytest(p, "-v") + pytester.runpytest(p, "-v") # result.stdout.fnmatch_lines([ # "*HINT*use*-r*" # ]) - def test_xfail_not_run_xfail_reporting(self, testdir): - p = testdir.makepyfile( + def test_xfail_not_run_xfail_reporting(self, pytester: Pytester) -> None: + p = pytester.makepyfile( test_one=""" import pytest @pytest.mark.xfail(run=False, reason="noway") @@ -257,7 +438,7 @@ def test_this_false(): assert 1 """ ) - result = testdir.runpytest(p, "-rx") + result = pytester.runpytest(p, "-rx") result.stdout.fnmatch_lines( [ "*test_one*test_this*", @@ -268,8 +449,8 @@ def test_this_false(): ] ) - def test_xfail_not_run_no_setup_run(self, testdir): - p = testdir.makepyfile( + def test_xfail_not_run_no_setup_run(self, pytester: Pytester) -> None: + p = pytester.makepyfile( test_one=""" import pytest @pytest.mark.xfail(run=False, reason="hello") @@ -279,13 +460,13 @@ def setup_module(mod): raise ValueError(42) """ ) - result = testdir.runpytest(p, "-rx") + result = pytester.runpytest(p, "-rx") result.stdout.fnmatch_lines( ["*test_one*test_this*", "*NOTRUN*hello", "*1 xfailed*"] ) - def test_xfail_xpass(self, testdir): - p = testdir.makepyfile( + def test_xfail_xpass(self, pytester: Pytester) -> None: + p = pytester.makepyfile( test_one=""" import pytest @pytest.mark.xfail @@ -293,27 +474,27 @@ def test_that(): assert 1 """ ) - result = testdir.runpytest(p, "-rX") + result = pytester.runpytest(p, "-rX") result.stdout.fnmatch_lines(["*XPASS*test_that*", "*1 xpassed*"]) assert result.ret == 0 - def test_xfail_imperative(self, testdir): - p = testdir.makepyfile( + def test_xfail_imperative(self, pytester: Pytester) -> None: + p = pytester.makepyfile( """ import pytest def test_this(): pytest.xfail("hello") """ ) - result = testdir.runpytest(p) + result = pytester.runpytest(p) result.stdout.fnmatch_lines(["*1 xfailed*"]) - result = testdir.runpytest(p, "-rx") + result = pytester.runpytest(p, "-rx") result.stdout.fnmatch_lines(["*XFAIL*test_this*", "*reason:*hello*"]) - result = testdir.runpytest(p, "--runxfail") + result = pytester.runpytest(p, "--runxfail") result.stdout.fnmatch_lines(["*1 pass*"]) - def test_xfail_imperative_in_setup_function(self, testdir): - p = testdir.makepyfile( + def test_xfail_imperative_in_setup_function(self, pytester: Pytester) -> None: + p = pytester.makepyfile( """ import pytest def setup_function(function): @@ -323,11 +504,11 @@ def test_this(): assert 0 """ ) - result = testdir.runpytest(p) + result = pytester.runpytest(p) result.stdout.fnmatch_lines(["*1 xfailed*"]) - result = testdir.runpytest(p, "-rx") + result = pytester.runpytest(p, "-rx") result.stdout.fnmatch_lines(["*XFAIL*test_this*", "*reason:*hello*"]) - result = testdir.runpytest(p, "--runxfail") + result = pytester.runpytest(p, "--runxfail") result.stdout.fnmatch_lines( """ *def test_this* @@ -335,8 +516,8 @@ def test_this(): """ ) - def xtest_dynamic_xfail_set_during_setup(self, testdir): - p = testdir.makepyfile( + def xtest_dynamic_xfail_set_during_setup(self, pytester: Pytester) -> None: + p = pytester.makepyfile( """ import pytest def setup_function(function): @@ -347,11 +528,11 @@ def test_that(): assert 1 """ ) - result = testdir.runpytest(p, "-rxX") + result = pytester.runpytest(p, "-rxX") result.stdout.fnmatch_lines(["*XFAIL*test_this*", "*XPASS*test_that*"]) - def test_dynamic_xfail_no_run(self, testdir): - p = testdir.makepyfile( + def test_dynamic_xfail_no_run(self, pytester: Pytester) -> None: + p = pytester.makepyfile( """ import pytest @pytest.fixture @@ -361,11 +542,11 @@ def test_this(arg): assert 0 """ ) - result = testdir.runpytest(p, "-rxX") + result = pytester.runpytest(p, "-rxX") result.stdout.fnmatch_lines(["*XFAIL*test_this*", "*NOTRUN*"]) - def test_dynamic_xfail_set_during_funcarg_setup(self, testdir): - p = testdir.makepyfile( + def test_dynamic_xfail_set_during_funcarg_setup(self, pytester: Pytester) -> None: + p = pytester.makepyfile( """ import pytest @pytest.fixture @@ -375,9 +556,36 @@ def test_this2(arg): assert 0 """ ) - result = testdir.runpytest(p) + result = pytester.runpytest(p) result.stdout.fnmatch_lines(["*1 xfailed*"]) + def test_dynamic_xfail_set_during_runtest_failed(self, pytester: Pytester) -> None: + # Issue #7486. + p = pytester.makepyfile( + """ + import pytest + def test_this(request): + request.node.add_marker(pytest.mark.xfail(reason="xfail")) + assert 0 + """ + ) + result = pytester.runpytest(p) + result.assert_outcomes(xfailed=1) + + def test_dynamic_xfail_set_during_runtest_passed_strict( + self, pytester: Pytester + ) -> None: + # Issue #7486. + p = pytester.makepyfile( + """ + import pytest + def test_this(request): + request.node.add_marker(pytest.mark.xfail(reason="xfail", strict=True)) + """ + ) + result = pytester.runpytest(p) + result.assert_outcomes(failed=1) + @pytest.mark.parametrize( "expected, actual, matchline", [ @@ -387,8 +595,10 @@ def test_this2(arg): ("(AttributeError, TypeError)", "IndexError", "*1 failed*"), ], ) - def test_xfail_raises(self, expected, actual, matchline, testdir): - p = testdir.makepyfile( + def test_xfail_raises( + self, expected, actual, matchline, pytester: Pytester + ) -> None: + p = pytester.makepyfile( """ import pytest @pytest.mark.xfail(raises=%s) @@ -397,14 +607,13 @@ def test_raises(): """ % (expected, actual) ) - result = testdir.runpytest(p) + result = pytester.runpytest(p) result.stdout.fnmatch_lines([matchline]) - def test_strict_sanity(self, testdir): - """sanity check for xfail(strict=True): a failing test should behave - exactly like a normal xfail. - """ - p = testdir.makepyfile( + def test_strict_sanity(self, pytester: Pytester) -> None: + """Sanity check for xfail(strict=True): a failing test should behave + exactly like a normal xfail.""" + p = pytester.makepyfile( """ import pytest @pytest.mark.xfail(reason='unsupported feature', strict=True) @@ -412,13 +621,13 @@ def test_foo(): assert 0 """ ) - result = testdir.runpytest(p, "-rxX") + result = pytester.runpytest(p, "-rxX") result.stdout.fnmatch_lines(["*XFAIL*", "*unsupported feature*"]) assert result.ret == 0 @pytest.mark.parametrize("strict", [True, False]) - def test_strict_xfail(self, testdir, strict): - p = testdir.makepyfile( + def test_strict_xfail(self, pytester: Pytester, strict: bool) -> None: + p = pytester.makepyfile( """ import pytest @@ -428,7 +637,7 @@ def test_foo(): """ % strict ) - result = testdir.runpytest(p, "-rxX") + result = pytester.runpytest(p, "-rxX") if strict: result.stdout.fnmatch_lines( ["*test_foo*", "*XPASS(strict)*unsupported feature*"] @@ -441,11 +650,11 @@ def test_foo(): ] ) assert result.ret == (1 if strict else 0) - assert testdir.tmpdir.join("foo_executed").isfile() + assert pytester.path.joinpath("foo_executed").exists() @pytest.mark.parametrize("strict", [True, False]) - def test_strict_xfail_condition(self, testdir, strict): - p = testdir.makepyfile( + def test_strict_xfail_condition(self, pytester: Pytester, strict: bool) -> None: + p = pytester.makepyfile( """ import pytest @@ -455,13 +664,13 @@ def test_foo(): """ % strict ) - result = testdir.runpytest(p, "-rxX") + result = pytester.runpytest(p, "-rxX") result.stdout.fnmatch_lines(["*1 passed*"]) assert result.ret == 0 @pytest.mark.parametrize("strict", [True, False]) - def test_xfail_condition_keyword(self, testdir, strict): - p = testdir.makepyfile( + def test_xfail_condition_keyword(self, pytester: Pytester, strict: bool) -> None: + p = pytester.makepyfile( """ import pytest @@ -471,20 +680,22 @@ def test_foo(): """ % strict ) - result = testdir.runpytest(p, "-rxX") + result = pytester.runpytest(p, "-rxX") result.stdout.fnmatch_lines(["*1 passed*"]) assert result.ret == 0 @pytest.mark.parametrize("strict_val", ["true", "false"]) - def test_strict_xfail_default_from_file(self, testdir, strict_val): - testdir.makeini( + def test_strict_xfail_default_from_file( + self, pytester: Pytester, strict_val + ) -> None: + pytester.makeini( """ [pytest] xfail_strict = %s """ % strict_val ) - p = testdir.makepyfile( + p = pytester.makepyfile( """ import pytest @pytest.mark.xfail(reason='unsupported feature') @@ -492,15 +703,42 @@ def test_foo(): pass """ ) - result = testdir.runpytest(p, "-rxX") + result = pytester.runpytest(p, "-rxX") strict = strict_val == "true" result.stdout.fnmatch_lines(["*1 failed*" if strict else "*1 xpassed*"]) assert result.ret == (1 if strict else 0) + def test_xfail_markeval_namespace(self, pytester: Pytester) -> None: + pytester.makeconftest( + """ + import pytest + + def pytest_markeval_namespace(): + return {"color": "green"} + """ + ) + p = pytester.makepyfile( + """ + import pytest + + @pytest.mark.xfail("color == 'green'") + def test_1(): + assert False + + @pytest.mark.xfail("color == 'red'") + def test_2(): + assert False + """ + ) + res = pytester.runpytest(p) + assert res.ret == 1 + res.stdout.fnmatch_lines(["*1 failed*"]) + res.stdout.fnmatch_lines(["*1 xfailed*"]) + class TestXFailwithSetupTeardown: - def test_failing_setup_issue9(self, testdir): - testdir.makepyfile( + def test_failing_setup_issue9(self, pytester: Pytester) -> None: + pytester.makepyfile( """ import pytest def setup_function(func): @@ -511,11 +749,11 @@ def test_func(): pass """ ) - result = testdir.runpytest() + result = pytester.runpytest() result.stdout.fnmatch_lines(["*1 xfail*"]) - def test_failing_teardown_issue9(self, testdir): - testdir.makepyfile( + def test_failing_teardown_issue9(self, pytester: Pytester) -> None: + pytester.makepyfile( """ import pytest def teardown_function(func): @@ -526,13 +764,13 @@ def test_func(): pass """ ) - result = testdir.runpytest() + result = pytester.runpytest() result.stdout.fnmatch_lines(["*1 xfail*"]) class TestSkip: - def test_skip_class(self, testdir): - testdir.makepyfile( + def test_skip_class(self, pytester: Pytester) -> None: + pytester.makepyfile( """ import pytest @pytest.mark.skip @@ -546,11 +784,11 @@ def test_baz(): pass """ ) - rec = testdir.inline_run() + rec = pytester.inline_run() rec.assertoutcome(skipped=2, passed=1) - def test_skips_on_false_string(self, testdir): - testdir.makepyfile( + def test_skips_on_false_string(self, pytester: Pytester) -> None: + pytester.makepyfile( """ import pytest @pytest.mark.skip('False') @@ -558,11 +796,11 @@ def test_foo(): pass """ ) - rec = testdir.inline_run() + rec = pytester.inline_run() rec.assertoutcome(skipped=1) - def test_arg_as_reason(self, testdir): - testdir.makepyfile( + def test_arg_as_reason(self, pytester: Pytester) -> None: + pytester.makepyfile( """ import pytest @pytest.mark.skip('testing stuff') @@ -570,11 +808,11 @@ def test_bar(): pass """ ) - result = testdir.runpytest("-rs") + result = pytester.runpytest("-rs") result.stdout.fnmatch_lines(["*testing stuff*", "*1 skipped*"]) - def test_skip_no_reason(self, testdir): - testdir.makepyfile( + def test_skip_no_reason(self, pytester: Pytester) -> None: + pytester.makepyfile( """ import pytest @pytest.mark.skip @@ -582,11 +820,11 @@ def test_foo(): pass """ ) - result = testdir.runpytest("-rs") + result = pytester.runpytest("-rs") result.stdout.fnmatch_lines(["*unconditional skip*", "*1 skipped*"]) - def test_skip_with_reason(self, testdir): - testdir.makepyfile( + def test_skip_with_reason(self, pytester: Pytester) -> None: + pytester.makepyfile( """ import pytest @pytest.mark.skip(reason="for lolz") @@ -594,11 +832,11 @@ def test_bar(): pass """ ) - result = testdir.runpytest("-rs") + result = pytester.runpytest("-rs") result.stdout.fnmatch_lines(["*for lolz*", "*1 skipped*"]) - def test_only_skips_marked_test(self, testdir): - testdir.makepyfile( + def test_only_skips_marked_test(self, pytester: Pytester) -> None: + pytester.makepyfile( """ import pytest @pytest.mark.skip @@ -611,11 +849,11 @@ def test_baz(): assert True """ ) - result = testdir.runpytest("-rs") + result = pytester.runpytest("-rs") result.stdout.fnmatch_lines(["*nothing in particular*", "*1 passed*2 skipped*"]) - def test_strict_and_skip(self, testdir): - testdir.makepyfile( + def test_strict_and_skip(self, pytester: Pytester) -> None: + pytester.makepyfile( """ import pytest @pytest.mark.skip @@ -623,13 +861,13 @@ def test_hello(): pass """ ) - result = testdir.runpytest("-rs") + result = pytester.runpytest("-rs") result.stdout.fnmatch_lines(["*unconditional skip*", "*1 skipped*"]) class TestSkipif: - def test_skipif_conditional(self, testdir): - item = testdir.getitem( + def test_skipif_conditional(self, pytester: Pytester) -> None: + item = pytester.getitem( """ import pytest @pytest.mark.skipif("hasattr(os, 'sep')") @@ -643,8 +881,8 @@ def test_func(): @pytest.mark.parametrize( "params", ["\"hasattr(sys, 'platform')\"", 'True, reason="invalid platform"'] ) - def test_skipif_reporting(self, testdir, params): - p = testdir.makepyfile( + def test_skipif_reporting(self, pytester: Pytester, params) -> None: + p = pytester.makepyfile( test_foo=""" import pytest @pytest.mark.skipif(%(params)s) @@ -653,12 +891,12 @@ def test_that(): """ % dict(params=params) ) - result = testdir.runpytest(p, "-s", "-rs") + result = pytester.runpytest(p, "-s", "-rs") result.stdout.fnmatch_lines(["*SKIP*1*test_foo.py*platform*", "*1 skipped*"]) assert result.ret == 0 - def test_skipif_using_platform(self, testdir): - item = testdir.getitem( + def test_skipif_using_platform(self, pytester: Pytester) -> None: + item = pytester.getitem( """ import pytest @pytest.mark.skipif("platform.platform() == platform.platform()") @@ -672,8 +910,10 @@ def test_func(): "marker, msg1, msg2", [("skipif", "SKIP", "skipped"), ("xfail", "XPASS", "xpassed")], ) - def test_skipif_reporting_multiple(self, testdir, marker, msg1, msg2): - testdir.makepyfile( + def test_skipif_reporting_multiple( + self, pytester: Pytester, marker, msg1, msg2 + ) -> None: + pytester.makepyfile( test_foo=""" import pytest @pytest.mark.{marker}(False, reason='first_condition') @@ -684,25 +924,22 @@ def test_foobar(): marker=marker ) ) - result = testdir.runpytest("-s", "-rsxX") + result = pytester.runpytest("-s", "-rsxX") result.stdout.fnmatch_lines( - [ - "*{msg1}*test_foo.py*second_condition*".format(msg1=msg1), - "*1 {msg2}*".format(msg2=msg2), - ] + [f"*{msg1}*test_foo.py*second_condition*", f"*1 {msg2}*"] ) assert result.ret == 0 -def test_skip_not_report_default(testdir): - p = testdir.makepyfile( +def test_skip_not_report_default(pytester: Pytester) -> None: + p = pytester.makepyfile( test_one=""" import pytest def test_this(): pytest.skip("hello") """ ) - result = testdir.runpytest(p, "-v") + result = pytester.runpytest(p, "-v") result.stdout.fnmatch_lines( [ # "*HINT*use*-r*", @@ -711,8 +948,8 @@ def test_this(): ) -def test_skipif_class(testdir): - p = testdir.makepyfile( +def test_skipif_class(pytester: Pytester) -> None: + p = pytester.makepyfile( """ import pytest @@ -724,12 +961,12 @@ def test_though(self): assert 0 """ ) - result = testdir.runpytest(p) + result = pytester.runpytest(p) result.stdout.fnmatch_lines(["*2 skipped*"]) -def test_skipped_reasons_functional(testdir): - testdir.makepyfile( +def test_skipped_reasons_functional(pytester: Pytester) -> None: + pytester.makepyfile( test_one=""" import pytest from conftest import doskip @@ -755,18 +992,18 @@ def doskip(): pytest.skip('test') """, ) - result = testdir.runpytest("-rs") + result = pytester.runpytest("-rs") result.stdout.fnmatch_lines_random( [ - "SKIPPED [[]2[]] */conftest.py:4: test", + "SKIPPED [[]2[]] conftest.py:4: test", "SKIPPED [[]1[]] test_one.py:14: via_decorator", ] ) assert result.ret == 0 -def test_skipped_folding(testdir): - testdir.makepyfile( +def test_skipped_folding(pytester: Pytester) -> None: + pytester.makepyfile( test_one=""" import pytest pytestmark = pytest.mark.skip("Folding") @@ -779,13 +1016,13 @@ def test_method(self): pass """ ) - result = testdir.runpytest("-rs") + result = pytester.runpytest("-rs") result.stdout.fnmatch_lines(["*SKIP*2*test_one.py: Folding"]) assert result.ret == 0 -def test_reportchars(testdir): - testdir.makepyfile( +def test_reportchars(pytester: Pytester) -> None: + pytester.makepyfile( """ import pytest def test_1(): @@ -800,14 +1037,14 @@ def test_4(): pytest.skip("four") """ ) - result = testdir.runpytest("-rfxXs") + result = pytester.runpytest("-rfxXs") result.stdout.fnmatch_lines( ["FAIL*test_1*", "XFAIL*test_2*", "XPASS*test_3*", "SKIP*four*"] ) -def test_reportchars_error(testdir): - testdir.makepyfile( +def test_reportchars_error(pytester: Pytester) -> None: + pytester.makepyfile( conftest=""" def pytest_runtest_teardown(): assert 0 @@ -817,12 +1054,12 @@ def test_foo(): pass """, ) - result = testdir.runpytest("-rE") + result = pytester.runpytest("-rE") result.stdout.fnmatch_lines(["ERROR*test_foo*"]) -def test_reportchars_all(testdir): - testdir.makepyfile( +def test_reportchars_all(pytester: Pytester) -> None: + pytester.makepyfile( """ import pytest def test_1(): @@ -842,7 +1079,7 @@ def test_5(fail): pass """ ) - result = testdir.runpytest("-ra") + result = pytester.runpytest("-ra") result.stdout.fnmatch_lines( [ "SKIP*four*", @@ -854,8 +1091,8 @@ def test_5(fail): ) -def test_reportchars_all_error(testdir): - testdir.makepyfile( +def test_reportchars_all_error(pytester: Pytester) -> None: + pytester.makepyfile( conftest=""" def pytest_runtest_teardown(): assert 0 @@ -865,12 +1102,12 @@ def test_foo(): pass """, ) - result = testdir.runpytest("-ra") + result = pytester.runpytest("-ra") result.stdout.fnmatch_lines(["ERROR*test_foo*"]) -def test_errors_in_xfail_skip_expressions(testdir): - testdir.makepyfile( +def test_errors_in_xfail_skip_expressions(pytester: Pytester) -> None: + pytester.makepyfile( """ import pytest @pytest.mark.skipif("asd") @@ -884,19 +1121,20 @@ def test_func(): pass """ ) - result = testdir.runpytest() + result = pytester.runpytest() markline = " ^" - if hasattr(sys, "pypy_version_info") and sys.pypy_version_info < (6,): + pypy_version_info = getattr(sys, "pypy_version_info", None) + if pypy_version_info is not None and pypy_version_info < (6,): markline = markline[5:] elif sys.version_info >= (3, 8) or hasattr(sys, "pypy_version_info"): markline = markline[4:] result.stdout.fnmatch_lines( [ "*ERROR*test_nameerror*", - "*evaluating*skipif*expression*", + "*evaluating*skipif*condition*", "*asd*", "*ERROR*test_syntax*", - "*evaluating*xfail*expression*", + "*evaluating*xfail*condition*", " syntax error", markline, "SyntaxError: invalid syntax", @@ -905,8 +1143,8 @@ def test_func(): ) -def test_xfail_skipif_with_globals(testdir): - testdir.makepyfile( +def test_xfail_skipif_with_globals(pytester: Pytester) -> None: + pytester.makepyfile( """ import pytest x = 3 @@ -918,41 +1156,28 @@ def test_boolean(): assert 0 """ ) - result = testdir.runpytest("-rsx") + result = pytester.runpytest("-rsx") result.stdout.fnmatch_lines(["*SKIP*x == 3*", "*XFAIL*test_boolean*", "*x == 3*"]) -def test_direct_gives_error(testdir): - testdir.makepyfile( - """ - import pytest - @pytest.mark.skipif(True) - def test_skip1(): - pass - """ - ) - result = testdir.runpytest() - result.stdout.fnmatch_lines(["*1 error*"]) - - -def test_default_markers(testdir): - result = testdir.runpytest("--markers") +def test_default_markers(pytester: Pytester) -> None: + result = pytester.runpytest("--markers") result.stdout.fnmatch_lines( [ - "*skipif(*condition)*skip*", - "*xfail(*condition, reason=None, run=True, raises=None, strict=False)*expected failure*", + "*skipif(condition, ..., [*], reason=...)*skip*", + "*xfail(condition, ..., [*], reason=..., run=True, raises=None, strict=xfail_strict)*expected failure*", ] ) -def test_xfail_test_setup_exception(testdir): - testdir.makeconftest( +def test_xfail_test_setup_exception(pytester: Pytester) -> None: + pytester.makeconftest( """ def pytest_runtest_setup(): 0 / 0 """ ) - p = testdir.makepyfile( + p = pytester.makepyfile( """ import pytest @pytest.mark.xfail @@ -960,14 +1185,14 @@ def test_func(): assert 0 """ ) - result = testdir.runpytest(p) + result = pytester.runpytest(p) assert result.ret == 0 assert "xfailed" in result.stdout.str() result.stdout.no_fnmatch_line("*xpassed*") -def test_imperativeskip_on_xfail_test(testdir): - testdir.makepyfile( +def test_imperativeskip_on_xfail_test(pytester: Pytester) -> None: + pytester.makepyfile( """ import pytest @pytest.mark.xfail @@ -979,14 +1204,14 @@ def test_hello(): pass """ ) - testdir.makeconftest( + pytester.makeconftest( """ import pytest def pytest_runtest_setup(item): pytest.skip("abc") """ ) - result = testdir.runpytest("-rsxX") + result = pytester.runpytest("-rsxX") result.stdout.fnmatch_lines_random( """ *SKIP*abc* @@ -997,8 +1222,8 @@ def pytest_runtest_setup(item): class TestBooleanCondition: - def test_skipif(self, testdir): - testdir.makepyfile( + def test_skipif(self, pytester: Pytester) -> None: + pytester.makepyfile( """ import pytest @pytest.mark.skipif(True, reason="True123") @@ -1009,15 +1234,15 @@ def test_func2(): pass """ ) - result = testdir.runpytest() + result = pytester.runpytest() result.stdout.fnmatch_lines( """ *1 passed*1 skipped* """ ) - def test_skipif_noreason(self, testdir): - testdir.makepyfile( + def test_skipif_noreason(self, pytester: Pytester) -> None: + pytester.makepyfile( """ import pytest @pytest.mark.skipif(True) @@ -1025,15 +1250,15 @@ def test_func(): pass """ ) - result = testdir.runpytest("-rs") + result = pytester.runpytest("-rs") result.stdout.fnmatch_lines( """ *1 error* """ ) - def test_xfail(self, testdir): - testdir.makepyfile( + def test_xfail(self, pytester: Pytester) -> None: + pytester.makepyfile( """ import pytest @pytest.mark.xfail(True, reason="True123") @@ -1041,7 +1266,7 @@ def test_func(): assert 0 """ ) - result = testdir.runpytest("-rxs") + result = pytester.runpytest("-rxs") result.stdout.fnmatch_lines( """ *XFAIL* @@ -1051,9 +1276,9 @@ def test_func(): ) -def test_xfail_item(testdir): +def test_xfail_item(pytester: Pytester) -> None: # Ensure pytest.xfail works with non-Python Item - testdir.makeconftest( + pytester.makeconftest( """ import pytest @@ -1063,21 +1288,19 @@ def runtest(self): pytest.xfail("Expected Failure") def pytest_collect_file(path, parent): - return MyItem("foo", parent) + return MyItem.from_parent(name="foo", parent=parent) """ ) - result = testdir.inline_run() + result = pytester.inline_run() passed, skipped, failed = result.listoutcomes() assert not failed xfailed = [r for r in skipped if hasattr(r, "wasxfail")] assert xfailed -def test_module_level_skip_error(testdir): - """ - Verify that using pytest.skip at module level causes a collection error - """ - testdir.makepyfile( +def test_module_level_skip_error(pytester: Pytester) -> None: + """Verify that using pytest.skip at module level causes a collection error.""" + pytester.makepyfile( """ import pytest pytest.skip("skip_module_level") @@ -1086,17 +1309,15 @@ def test_func(): assert True """ ) - result = testdir.runpytest() + result = pytester.runpytest() result.stdout.fnmatch_lines( ["*Using pytest.skip outside of a test is not allowed*"] ) -def test_module_level_skip_with_allow_module_level(testdir): - """ - Verify that using pytest.skip(allow_module_level=True) is allowed - """ - testdir.makepyfile( +def test_module_level_skip_with_allow_module_level(pytester: Pytester) -> None: + """Verify that using pytest.skip(allow_module_level=True) is allowed.""" + pytester.makepyfile( """ import pytest pytest.skip("skip_module_level", allow_module_level=True) @@ -1105,15 +1326,13 @@ def test_func(): assert 0 """ ) - result = testdir.runpytest("-rxs") + result = pytester.runpytest("-rxs") result.stdout.fnmatch_lines(["*SKIP*skip_module_level"]) -def test_invalid_skip_keyword_parameter(testdir): - """ - Verify that using pytest.skip() with unknown parameter raises an error - """ - testdir.makepyfile( +def test_invalid_skip_keyword_parameter(pytester: Pytester) -> None: + """Verify that using pytest.skip() with unknown parameter raises an error.""" + pytester.makepyfile( """ import pytest pytest.skip("skip_module_level", unknown=1) @@ -1122,45 +1341,47 @@ def test_func(): assert 0 """ ) - result = testdir.runpytest() + result = pytester.runpytest() result.stdout.fnmatch_lines(["*TypeError:*['unknown']*"]) -def test_mark_xfail_item(testdir): +def test_mark_xfail_item(pytester: Pytester) -> None: # Ensure pytest.mark.xfail works with non-Python Item - testdir.makeconftest( + pytester.makeconftest( """ import pytest class MyItem(pytest.Item): nodeid = 'foo' def setup(self): - marker = pytest.mark.xfail(True, reason="Expected failure") + marker = pytest.mark.xfail("1 == 2", reason="Expected failure - false") + self.add_marker(marker) + marker = pytest.mark.xfail(True, reason="Expected failure - true") self.add_marker(marker) def runtest(self): assert False def pytest_collect_file(path, parent): - return MyItem("foo", parent) + return MyItem.from_parent(name="foo", parent=parent) """ ) - result = testdir.inline_run() + result = pytester.inline_run() passed, skipped, failed = result.listoutcomes() assert not failed xfailed = [r for r in skipped if hasattr(r, "wasxfail")] assert xfailed -def test_summary_list_after_errors(testdir): +def test_summary_list_after_errors(pytester: Pytester) -> None: """Ensure the list of errors/fails/xfails/skips appears after tracebacks in terminal reporting.""" - testdir.makepyfile( + pytester.makepyfile( """ import pytest def test_fail(): assert 0 """ ) - result = testdir.runpytest("-ra") + result = pytester.runpytest("-ra") result.stdout.fnmatch_lines( [ "=* FAILURES *=", @@ -1170,9 +1391,26 @@ def test_fail(): ) -def test_importorskip(): +def test_importorskip() -> None: with pytest.raises( pytest.skip.Exception, match="^could not import 'doesnotexist': No module named .*", ): pytest.importorskip("doesnotexist") + + +def test_relpath_rootdir(pytester: Pytester) -> None: + pytester.makepyfile( + **{ + "tests/test_1.py": """ + import pytest + @pytest.mark.skip() + def test_pass(): + pass + """, + } + ) + result = pytester.runpytest("-rs", "tests/test_1.py", "--rootdir=tests") + result.stdout.fnmatch_lines( + ["SKIPPED [[]1[]] tests/test_1.py:2: unconditional skip"] + ) diff --git a/testing/test_stepwise.py b/testing/test_stepwise.py index 3bc77857d97..ff2ec16b707 100644 --- a/testing/test_stepwise.py +++ b/testing/test_stepwise.py @@ -1,11 +1,13 @@ import pytest +from _pytest.monkeypatch import MonkeyPatch +from _pytest.pytester import Pytester @pytest.fixture -def stepwise_testdir(testdir): +def stepwise_pytester(pytester: Pytester) -> Pytester: # Rather than having to modify our testfile between tests, we introduce # a flag for whether or not the second test should fail. - testdir.makeconftest( + pytester.makeconftest( """ def pytest_addoption(parser): group = parser.getgroup('general') @@ -15,7 +17,7 @@ def pytest_addoption(parser): ) # Create a simple test suite. - testdir.makepyfile( + pytester.makepyfile( test_a=""" def test_success_before_fail(): assert 1 @@ -34,7 +36,7 @@ def test_success_after_last_fail(): """ ) - testdir.makepyfile( + pytester.makepyfile( test_b=""" def test_success(): assert 1 @@ -42,19 +44,19 @@ def test_success(): ) # customize cache directory so we don't use the tox's cache directory, which makes tests in this module flaky - testdir.makeini( + pytester.makeini( """ [pytest] cache_dir = .cache """ ) - return testdir + return pytester @pytest.fixture -def error_testdir(testdir): - testdir.makepyfile( +def error_pytester(pytester: Pytester) -> Pytester: + pytester.makepyfile( test_a=""" def test_error(nonexisting_fixture): assert 1 @@ -64,31 +66,57 @@ def test_success_after_fail(): """ ) - return testdir + return pytester @pytest.fixture -def broken_testdir(testdir): - testdir.makepyfile( +def broken_pytester(pytester: Pytester) -> Pytester: + pytester.makepyfile( working_testfile="def test_proper(): assert 1", broken_testfile="foobar" ) - return testdir + return pytester -def test_run_without_stepwise(stepwise_testdir): - result = stepwise_testdir.runpytest("-v", "--strict-markers", "--fail") +def _strip_resource_warnings(lines): + # Strip unreliable ResourceWarnings, so no-output assertions on stderr can work. + # (https://github.com/pytest-dev/pytest/issues/5088) + return [ + x + for x in lines + if not x.startswith(("Exception ignored in:", "ResourceWarning")) + ] + +def test_run_without_stepwise(stepwise_pytester: Pytester) -> None: + result = stepwise_pytester.runpytest("-v", "--strict-markers", "--fail") result.stdout.fnmatch_lines(["*test_success_before_fail PASSED*"]) result.stdout.fnmatch_lines(["*test_fail_on_flag FAILED*"]) result.stdout.fnmatch_lines(["*test_success_after_fail PASSED*"]) -def test_fail_and_continue_with_stepwise(stepwise_testdir): +def test_stepwise_output_summary(pytester: Pytester) -> None: + pytester.makepyfile( + """ + import pytest + @pytest.mark.parametrize("expected", [True, True, True, True, False]) + def test_data(expected): + assert expected + """ + ) + result = pytester.runpytest("-v", "--stepwise") + result.stdout.fnmatch_lines(["stepwise: no previously failed tests, not skipping."]) + result = pytester.runpytest("-v", "--stepwise") + result.stdout.fnmatch_lines( + ["stepwise: skipping 4 already passed items.", "*1 failed, 4 deselected*"] + ) + + +def test_fail_and_continue_with_stepwise(stepwise_pytester: Pytester) -> None: # Run the tests with a failing second test. - result = stepwise_testdir.runpytest( + result = stepwise_pytester.runpytest( "-v", "--strict-markers", "--stepwise", "--fail" ) - assert not result.stderr.str() + assert _strip_resource_warnings(result.stderr.lines) == [] stdout = result.stdout.str() # Make sure we stop after first failing test. @@ -97,8 +125,8 @@ def test_fail_and_continue_with_stepwise(stepwise_testdir): assert "test_success_after_fail" not in stdout # "Fix" the test that failed in the last run and run it again. - result = stepwise_testdir.runpytest("-v", "--strict-markers", "--stepwise") - assert not result.stderr.str() + result = stepwise_pytester.runpytest("-v", "--strict-markers", "--stepwise") + assert _strip_resource_warnings(result.stderr.lines) == [] stdout = result.stdout.str() # Make sure the latest failing test runs and then continues. @@ -107,16 +135,12 @@ def test_fail_and_continue_with_stepwise(stepwise_testdir): assert "test_success_after_fail PASSED" in stdout -def test_run_with_skip_option(stepwise_testdir): - result = stepwise_testdir.runpytest( - "-v", - "--strict-markers", - "--stepwise", - "--stepwise-skip", - "--fail", - "--fail-last", +@pytest.mark.parametrize("stepwise_skip", ["--stepwise-skip", "--sw-skip"]) +def test_run_with_skip_option(stepwise_pytester: Pytester, stepwise_skip: str) -> None: + result = stepwise_pytester.runpytest( + "-v", "--strict-markers", "--stepwise", stepwise_skip, "--fail", "--fail-last", ) - assert not result.stderr.str() + assert _strip_resource_warnings(result.stderr.lines) == [] stdout = result.stdout.str() # Make sure first fail is ignore and second fail stops the test run. @@ -126,48 +150,50 @@ def test_run_with_skip_option(stepwise_testdir): assert "test_success_after_last_fail" not in stdout -def test_fail_on_errors(error_testdir): - result = error_testdir.runpytest("-v", "--strict-markers", "--stepwise") +def test_fail_on_errors(error_pytester: Pytester) -> None: + result = error_pytester.runpytest("-v", "--strict-markers", "--stepwise") - assert not result.stderr.str() + assert _strip_resource_warnings(result.stderr.lines) == [] stdout = result.stdout.str() assert "test_error ERROR" in stdout assert "test_success_after_fail" not in stdout -def test_change_testfile(stepwise_testdir): - result = stepwise_testdir.runpytest( +def test_change_testfile(stepwise_pytester: Pytester) -> None: + result = stepwise_pytester.runpytest( "-v", "--strict-markers", "--stepwise", "--fail", "test_a.py" ) - assert not result.stderr.str() + assert _strip_resource_warnings(result.stderr.lines) == [] stdout = result.stdout.str() assert "test_fail_on_flag FAILED" in stdout # Make sure the second test run starts from the beginning, since the # test to continue from does not exist in testfile_b. - result = stepwise_testdir.runpytest( + result = stepwise_pytester.runpytest( "-v", "--strict-markers", "--stepwise", "test_b.py" ) - assert not result.stderr.str() + assert _strip_resource_warnings(result.stderr.lines) == [] stdout = result.stdout.str() assert "test_success PASSED" in stdout @pytest.mark.parametrize("broken_first", [True, False]) -def test_stop_on_collection_errors(broken_testdir, broken_first): +def test_stop_on_collection_errors( + broken_pytester: Pytester, broken_first: bool +) -> None: """Stop during collection errors. Broken test first or broken test last actually surfaced a bug (#5444), so we test both situations.""" files = ["working_testfile.py", "broken_testfile.py"] if broken_first: files.reverse() - result = broken_testdir.runpytest("-v", "--strict-markers", "--stepwise", *files) + result = broken_pytester.runpytest("-v", "--strict-markers", "--stepwise", *files) result.stdout.fnmatch_lines("*error during collection*") -def test_xfail_handling(testdir, monkeypatch): +def test_xfail_handling(pytester: Pytester, monkeypatch: MonkeyPatch) -> None: """Ensure normal xfail is ignored, and strict xfail interrupts the session in sw mode (#5547) @@ -184,8 +210,8 @@ def test_b(): assert {assert_value} def test_c(): pass def test_d(): pass """ - testdir.makepyfile(contents.format(assert_value="0", strict="False")) - result = testdir.runpytest("--sw", "-v") + pytester.makepyfile(contents.format(assert_value="0", strict="False")) + result = pytester.runpytest("--sw", "-v") result.stdout.fnmatch_lines( [ "*::test_a PASSED *", @@ -196,8 +222,8 @@ def test_d(): pass ] ) - testdir.makepyfile(contents.format(assert_value="1", strict="True")) - result = testdir.runpytest("--sw", "-v") + pytester.makepyfile(contents.format(assert_value="1", strict="True")) + result = pytester.runpytest("--sw", "-v") result.stdout.fnmatch_lines( [ "*::test_a PASSED *", @@ -207,8 +233,8 @@ def test_d(): pass ] ) - testdir.makepyfile(contents.format(assert_value="0", strict="True")) - result = testdir.runpytest("--sw", "-v") + pytester.makepyfile(contents.format(assert_value="0", strict="True")) + result = pytester.runpytest("--sw", "-v") result.stdout.fnmatch_lines( [ "*::test_b XFAIL *", diff --git a/testing/test_store.py b/testing/test_store.py index 98014887ec1..b6d4208a092 100644 --- a/testing/test_store.py +++ b/testing/test_store.py @@ -47,7 +47,7 @@ def test_store() -> None: # Can't accidentally add attributes to store object itself. with pytest.raises(AttributeError): - store.foo = "nope" # type: ignore[attr-defined] # noqa: F821 + store.foo = "nope" # type: ignore[attr-defined] # No interaction with anoter store. store2 = Store() diff --git a/testing/test_terminal.py b/testing/test_terminal.py index 38ca1957a91..7ad5849d4b9 100644 --- a/testing/test_terminal.py +++ b/testing/test_terminal.py @@ -1,11 +1,12 @@ -""" -terminal reporting of the full testing process. -""" +"""Terminal reporting of the full testing process.""" import collections import os import sys import textwrap from io import StringIO +from pathlib import Path +from types import SimpleNamespace +from typing import cast from typing import Dict from typing import List from typing import Tuple @@ -14,12 +15,20 @@ import py import _pytest.config +import _pytest.terminal import pytest +from _pytest._io.wcwidth import wcswidth +from _pytest.config import Config from _pytest.config import ExitCode -from _pytest.pytester import Testdir +from _pytest.monkeypatch import MonkeyPatch +from _pytest.pytester import Pytester from _pytest.reports import BaseReport +from _pytest.reports import CollectReport +from _pytest.reports import TestReport from _pytest.terminal import _folded_skips +from _pytest.terminal import _format_trimmed from _pytest.terminal import _get_line_with_reprcrash_message +from _pytest.terminal import _get_raw_skip_reason from _pytest.terminal import _plugin_nameversions from _pytest.terminal import getreportopt from _pytest.terminal import TerminalReporter @@ -71,8 +80,8 @@ def test_plugin_nameversion(input, expected): class TestTerminal: - def test_pass_skip_fail(self, testdir, option): - testdir.makepyfile( + def test_pass_skip_fail(self, pytester: Pytester, option) -> None: + pytester.makepyfile( """ import pytest def test_ok(): @@ -83,7 +92,7 @@ def test_func(): assert 0 """ ) - result = testdir.runpytest(*option.args) + result = pytester.runpytest(*option.args) if option.verbosity > 0: result.stdout.fnmatch_lines( [ @@ -100,16 +109,16 @@ def test_func(): [" def test_func():", "> assert 0", "E assert 0"] ) - def test_internalerror(self, testdir, linecomp): - modcol = testdir.getmodulecol("def test_one(): pass") + def test_internalerror(self, pytester: Pytester, linecomp) -> None: + modcol = pytester.getmodulecol("def test_one(): pass") rep = TerminalReporter(modcol.config, file=linecomp.stringio) with pytest.raises(ValueError) as excinfo: raise ValueError("hello") rep.pytest_internalerror(excinfo.getrepr()) linecomp.assert_contains_lines(["INTERNALERROR> *ValueError*hello*"]) - def test_writeline(self, testdir, linecomp): - modcol = testdir.getmodulecol("def test_one(): pass") + def test_writeline(self, pytester: Pytester, linecomp) -> None: + modcol = pytester.getmodulecol("def test_one(): pass") rep = TerminalReporter(modcol.config, file=linecomp.stringio) rep.write_fspath_result(modcol.nodeid, ".") rep.write_line("hello world") @@ -118,8 +127,8 @@ def test_writeline(self, testdir, linecomp): assert lines[1].endswith(modcol.name + " .") assert lines[2] == "hello world" - def test_show_runtest_logstart(self, testdir, linecomp): - item = testdir.getitem("def test_func(): pass") + def test_show_runtest_logstart(self, pytester: Pytester, linecomp) -> None: + item = pytester.getitem("def test_func(): pass") tr = TerminalReporter(item.config, file=linecomp.stringio) item.config.pluginmanager.register(tr) location = item.reportinfo() @@ -128,23 +137,27 @@ def test_show_runtest_logstart(self, testdir, linecomp): ) linecomp.assert_contains_lines(["*test_show_runtest_logstart.py*"]) - def test_runtest_location_shown_before_test_starts(self, testdir): - testdir.makepyfile( + def test_runtest_location_shown_before_test_starts( + self, pytester: Pytester + ) -> None: + pytester.makepyfile( """ def test_1(): import time time.sleep(20) """ ) - child = testdir.spawn_pytest("") + child = pytester.spawn_pytest("") child.expect(".*test_runtest_location.*py") child.sendeof() child.kill(15) - def test_report_collect_after_half_a_second(self, testdir): + def test_report_collect_after_half_a_second( + self, pytester: Pytester, monkeypatch: MonkeyPatch + ) -> None: """Test for "collecting" being updated after 0.5s""" - testdir.makepyfile( + pytester.makepyfile( **{ "test1.py": """ import _pytest.terminal @@ -158,9 +171,9 @@ def test_1(): } ) # Explicitly test colored output. - testdir.monkeypatch.setenv("PY_COLORS", "1") + monkeypatch.setenv("PY_COLORS", "1") - child = testdir.spawn_pytest("-v test1.py test2.py") + child = pytester.spawn_pytest("-v test1.py test2.py") child.expect(r"collecting \.\.\.") child.expect(r"collecting 1 item") child.expect(r"collecting 2 items") @@ -168,8 +181,10 @@ def test_1(): rest = child.read().decode("utf8") assert "= \x1b[32m\x1b[1m2 passed\x1b[0m\x1b[32m in" in rest - def test_itemreport_subclasses_show_subclassed_file(self, testdir): - testdir.makepyfile( + def test_itemreport_subclasses_show_subclassed_file( + self, pytester: Pytester + ) -> None: + pytester.makepyfile( **{ "tests/test_p1": """ class BaseTests(object): @@ -192,10 +207,10 @@ class TestMore(BaseTests): pass """, } ) - result = testdir.runpytest("tests/test_p2.py", "--rootdir=tests") + result = pytester.runpytest("tests/test_p2.py", "--rootdir=tests") result.stdout.fnmatch_lines(["tests/test_p2.py .*", "=* 1 passed in *"]) - result = testdir.runpytest("-vv", "-rA", "tests/test_p2.py", "--rootdir=tests") + result = pytester.runpytest("-vv", "-rA", "tests/test_p2.py", "--rootdir=tests") result.stdout.fnmatch_lines( [ "tests/test_p2.py::TestMore::test_p1 <- test_p1.py PASSED *", @@ -203,7 +218,7 @@ class TestMore(BaseTests): pass "PASSED tests/test_p2.py::TestMore::test_p1", ] ) - result = testdir.runpytest("-vv", "-rA", "tests/test_p3.py", "--rootdir=tests") + result = pytester.runpytest("-vv", "-rA", "tests/test_p3.py", "--rootdir=tests") result.stdout.fnmatch_lines( [ "tests/test_p3.py::TestMore::test_p1 <- test_p1.py FAILED *", @@ -219,9 +234,11 @@ class TestMore(BaseTests): pass ] ) - def test_itemreport_directclasses_not_shown_as_subclasses(self, testdir): - a = testdir.mkpydir("a123") - a.join("test_hello123.py").write( + def test_itemreport_directclasses_not_shown_as_subclasses( + self, pytester: Pytester + ) -> None: + a = pytester.mkpydir("a123") + a.joinpath("test_hello123.py").write_text( textwrap.dedent( """\ class TestClass(object): @@ -230,14 +247,14 @@ def test_method(self): """ ) ) - result = testdir.runpytest("-vv") + result = pytester.runpytest("-vv") assert result.ret == 0 result.stdout.fnmatch_lines(["*a123/test_hello123.py*PASS*"]) result.stdout.no_fnmatch_line("* <- *") @pytest.mark.parametrize("fulltrace", ("", "--fulltrace")) - def test_keyboard_interrupt(self, testdir, fulltrace): - testdir.makepyfile( + def test_keyboard_interrupt(self, pytester: Pytester, fulltrace) -> None: + pytester.makepyfile( """ def test_foobar(): assert 0 @@ -248,7 +265,7 @@ def test_interrupt_me(): """ ) - result = testdir.runpytest(fulltrace, no_reraise_ctrlc=True) + result = pytester.runpytest(fulltrace, no_reraise_ctrlc=True) result.stdout.fnmatch_lines( [ " def test_foobar():", @@ -267,37 +284,37 @@ def test_interrupt_me(): ) result.stdout.fnmatch_lines(["*KeyboardInterrupt*"]) - def test_keyboard_in_sessionstart(self, testdir): - testdir.makeconftest( + def test_keyboard_in_sessionstart(self, pytester: Pytester) -> None: + pytester.makeconftest( """ def pytest_sessionstart(): raise KeyboardInterrupt """ ) - testdir.makepyfile( + pytester.makepyfile( """ def test_foobar(): pass """ ) - result = testdir.runpytest(no_reraise_ctrlc=True) + result = pytester.runpytest(no_reraise_ctrlc=True) assert result.ret == 2 result.stdout.fnmatch_lines(["*KeyboardInterrupt*"]) - def test_collect_single_item(self, testdir): + def test_collect_single_item(self, pytester: Pytester) -> None: """Use singular 'item' when reporting a single test item""" - testdir.makepyfile( + pytester.makepyfile( """ def test_foobar(): pass """ ) - result = testdir.runpytest() + result = pytester.runpytest() result.stdout.fnmatch_lines(["collected 1 item"]) - def test_rewrite(self, testdir, monkeypatch): - config = testdir.parseconfig() + def test_rewrite(self, pytester: Pytester, monkeypatch) -> None: + config = pytester.parseconfig() f = StringIO() monkeypatch.setattr(f, "isatty", lambda *args: True) tr = TerminalReporter(config, f) @@ -306,60 +323,131 @@ def test_rewrite(self, testdir, monkeypatch): tr.rewrite("hey", erase=True) assert f.getvalue() == "hello" + "\r" + "hey" + (6 * " ") + def test_report_teststatus_explicit_markup( + self, monkeypatch: MonkeyPatch, pytester: Pytester, color_mapping + ) -> None: + """Test that TerminalReporter handles markup explicitly provided by + a pytest_report_teststatus hook.""" + monkeypatch.setenv("PY_COLORS", "1") + pytester.makeconftest( + """ + def pytest_report_teststatus(report): + return 'foo', 'F', ('FOO', {'red': True}) + """ + ) + pytester.makepyfile( + """ + def test_foobar(): + pass + """ + ) + result = pytester.runpytest("-v") + result.stdout.fnmatch_lines( + color_mapping.format_for_fnmatch(["*{red}FOO{reset}*"]) + ) + + def test_verbose_skip_reason(self, pytester: Pytester) -> None: + pytester.makepyfile( + """ + import pytest + + @pytest.mark.skip(reason="123") + def test_1(): + pass + + @pytest.mark.xfail(reason="456") + def test_2(): + pass + + @pytest.mark.xfail(reason="789") + def test_3(): + assert False + + @pytest.mark.xfail(reason="") + def test_4(): + assert False + """ + ) + result = pytester.runpytest("-v") + result.stdout.fnmatch_lines( + [ + "test_verbose_skip_reason.py::test_1 SKIPPED (123) *", + "test_verbose_skip_reason.py::test_2 XPASS (456) *", + "test_verbose_skip_reason.py::test_3 XFAIL (789) *", + "test_verbose_skip_reason.py::test_4 XFAIL *", + ] + ) + class TestCollectonly: - def test_collectonly_basic(self, testdir): - testdir.makepyfile( + def test_collectonly_basic(self, pytester: Pytester) -> None: + pytester.makepyfile( """ def test_func(): pass """ ) - result = testdir.runpytest("--collect-only") + result = pytester.runpytest("--collect-only") result.stdout.fnmatch_lines( ["", " "] ) - def test_collectonly_skipped_module(self, testdir): - testdir.makepyfile( + def test_collectonly_skipped_module(self, pytester: Pytester) -> None: + pytester.makepyfile( """ import pytest pytest.skip("hello") """ ) - result = testdir.runpytest("--collect-only", "-rs") + result = pytester.runpytest("--collect-only", "-rs") result.stdout.fnmatch_lines(["*ERROR collecting*"]) - def test_collectonly_display_test_description(self, testdir): - testdir.makepyfile( + def test_collectonly_displays_test_description( + self, pytester: Pytester, dummy_yaml_custom_test + ) -> None: + """Used dummy_yaml_custom_test for an Item without ``obj``.""" + pytester.makepyfile( """ def test_with_description(): - \""" This test has a description. - \""" - assert True - """ + ''' This test has a description. + + more1. + more2.''' + """ + ) + result = pytester.runpytest("--collect-only", "--verbose") + result.stdout.fnmatch_lines( + [ + "", + " ", + "", + " ", + " This test has a description.", + " ", + " more1.", + " more2.", + ], + consecutive=True, ) - result = testdir.runpytest("--collect-only", "--verbose") - result.stdout.fnmatch_lines([" This test has a description."]) - def test_collectonly_failed_module(self, testdir): - testdir.makepyfile("""raise ValueError(0)""") - result = testdir.runpytest("--collect-only") + def test_collectonly_failed_module(self, pytester: Pytester) -> None: + pytester.makepyfile("""raise ValueError(0)""") + result = pytester.runpytest("--collect-only") result.stdout.fnmatch_lines(["*raise ValueError*", "*1 error*"]) - def test_collectonly_fatal(self, testdir): - testdir.makeconftest( + def test_collectonly_fatal(self, pytester: Pytester) -> None: + pytester.makeconftest( """ def pytest_collectstart(collector): assert 0, "urgs" """ ) - result = testdir.runpytest("--collect-only") + result = pytester.runpytest("--collect-only") result.stdout.fnmatch_lines(["*INTERNAL*args*"]) assert result.ret == 3 - def test_collectonly_simple(self, testdir): - p = testdir.makepyfile( + def test_collectonly_simple(self, pytester: Pytester) -> None: + p = pytester.makepyfile( """ def test_func1(): pass @@ -368,7 +456,7 @@ def test_method(self): pass """ ) - result = testdir.runpytest("--collect-only", p) + result = pytester.runpytest("--collect-only", p) # assert stderr.startswith("inserting into sys.path") assert result.ret == 0 result.stdout.fnmatch_lines( @@ -380,9 +468,9 @@ def test_method(self): ] ) - def test_collectonly_error(self, testdir): - p = testdir.makepyfile("import Errlkjqweqwe") - result = testdir.runpytest("--collect-only", p) + def test_collectonly_error(self, pytester: Pytester) -> None: + p = pytester.makepyfile("import Errlkjqweqwe") + result = pytester.runpytest("--collect-only", p) assert result.ret == 2 result.stdout.fnmatch_lines( textwrap.dedent( @@ -395,29 +483,71 @@ def test_collectonly_error(self, testdir): ).strip() ) - def test_collectonly_missing_path(self, testdir): - """this checks issue 115, - failure in parseargs will cause session - not to have the items attribute - """ - result = testdir.runpytest("--collect-only", "uhm_missing_path") + def test_collectonly_missing_path(self, pytester: Pytester) -> None: + """Issue 115: failure in parseargs will cause session not to + have the items attribute.""" + result = pytester.runpytest("--collect-only", "uhm_missing_path") assert result.ret == 4 - result.stderr.fnmatch_lines(["*ERROR: file not found*"]) + result.stderr.fnmatch_lines( + ["*ERROR: file or directory not found: uhm_missing_path"] + ) - def test_collectonly_quiet(self, testdir): - testdir.makepyfile("def test_foo(): pass") - result = testdir.runpytest("--collect-only", "-q") + def test_collectonly_quiet(self, pytester: Pytester) -> None: + pytester.makepyfile("def test_foo(): pass") + result = pytester.runpytest("--collect-only", "-q") result.stdout.fnmatch_lines(["*test_foo*"]) - def test_collectonly_more_quiet(self, testdir): - testdir.makepyfile(test_fun="def test_foo(): pass") - result = testdir.runpytest("--collect-only", "-qq") + def test_collectonly_more_quiet(self, pytester: Pytester) -> None: + pytester.makepyfile(test_fun="def test_foo(): pass") + result = pytester.runpytest("--collect-only", "-qq") result.stdout.fnmatch_lines(["*test_fun.py: 1*"]) + def test_collect_only_summary_status(self, pytester: Pytester) -> None: + """Custom status depending on test selection using -k or -m. #7701.""" + pytester.makepyfile( + test_collect_foo=""" + def test_foo(): pass + """, + test_collect_bar=""" + def test_foobar(): pass + def test_bar(): pass + """, + ) + result = pytester.runpytest("--collect-only") + result.stdout.fnmatch_lines("*== 3 tests collected in * ==*") + + result = pytester.runpytest("--collect-only", "test_collect_foo.py") + result.stdout.fnmatch_lines("*== 1 test collected in * ==*") + + result = pytester.runpytest("--collect-only", "-k", "foo") + result.stdout.fnmatch_lines("*== 2/3 tests collected (1 deselected) in * ==*") + + result = pytester.runpytest("--collect-only", "-k", "test_bar") + result.stdout.fnmatch_lines("*== 1/3 tests collected (2 deselected) in * ==*") + + result = pytester.runpytest("--collect-only", "-k", "invalid") + result.stdout.fnmatch_lines("*== no tests collected (3 deselected) in * ==*") + + pytester.mkdir("no_tests_here") + result = pytester.runpytest("--collect-only", "no_tests_here") + result.stdout.fnmatch_lines("*== no tests collected in * ==*") + + pytester.makepyfile( + test_contains_error=""" + raise RuntimeError + """, + ) + result = pytester.runpytest("--collect-only") + result.stdout.fnmatch_lines("*== 3 tests collected, 1 error in * ==*") + result = pytester.runpytest("--collect-only", "-k", "foo") + result.stdout.fnmatch_lines( + "*== 2/3 tests collected (1 deselected), 1 error in * ==*" + ) + class TestFixtureReporting: - def test_setup_fixture_error(self, testdir): - testdir.makepyfile( + def test_setup_fixture_error(self, pytester: Pytester) -> None: + pytester.makepyfile( """ def setup_function(function): print("setup func") @@ -426,7 +556,7 @@ def test_nada(): pass """ ) - result = testdir.runpytest() + result = pytester.runpytest() result.stdout.fnmatch_lines( [ "*ERROR at setup of test_nada*", @@ -438,8 +568,8 @@ def test_nada(): ) assert result.ret != 0 - def test_teardown_fixture_error(self, testdir): - testdir.makepyfile( + def test_teardown_fixture_error(self, pytester: Pytester) -> None: + pytester.makepyfile( """ def test_nada(): pass @@ -448,7 +578,7 @@ def teardown_function(function): assert 0 """ ) - result = testdir.runpytest() + result = pytester.runpytest() result.stdout.fnmatch_lines( [ "*ERROR at teardown*", @@ -460,8 +590,8 @@ def teardown_function(function): ] ) - def test_teardown_fixture_error_and_test_failure(self, testdir): - testdir.makepyfile( + def test_teardown_fixture_error_and_test_failure(self, pytester: Pytester) -> None: + pytester.makepyfile( """ def test_fail(): assert 0, "failingfunc" @@ -471,7 +601,7 @@ def teardown_function(function): assert False """ ) - result = testdir.runpytest() + result = pytester.runpytest() result.stdout.fnmatch_lines( [ "*ERROR at teardown of test_fail*", @@ -486,9 +616,9 @@ def teardown_function(function): ] ) - def test_setup_teardown_output_and_test_failure(self, testdir): - """ Test for issue #442 """ - testdir.makepyfile( + def test_setup_teardown_output_and_test_failure(self, pytester: Pytester) -> None: + """Test for issue #442.""" + pytester.makepyfile( """ def setup_function(function): print("setup func") @@ -500,7 +630,7 @@ def teardown_function(function): print("teardown func") """ ) - result = testdir.runpytest() + result = pytester.runpytest() result.stdout.fnmatch_lines( [ "*test_fail*", @@ -516,8 +646,8 @@ def teardown_function(function): class TestTerminalFunctional: - def test_deselected(self, testdir): - testpath = testdir.makepyfile( + def test_deselected(self, pytester: Pytester) -> None: + testpath = pytester.makepyfile( """ def test_one(): pass @@ -527,14 +657,14 @@ def test_three(): pass """ ) - result = testdir.runpytest("-k", "test_two:", testpath) + result = pytester.runpytest("-k", "test_two:", testpath) result.stdout.fnmatch_lines( ["collected 3 items / 1 deselected / 2 selected", "*test_deselected.py ..*"] ) assert result.ret == 0 - def test_deselected_with_hookwrapper(self, testdir): - testpath = testdir.makeconftest( + def test_deselected_with_hookwrapper(self, pytester: Pytester) -> None: + pytester.makeconftest( """ import pytest @@ -545,7 +675,7 @@ def pytest_collection_modifyitems(config, items): config.hook.pytest_deselected(items=[deselected]) """ ) - testpath = testdir.makepyfile( + testpath = pytester.makepyfile( """ def test_one(): pass @@ -555,7 +685,7 @@ def test_three(): pass """ ) - result = testdir.runpytest(testpath) + result = pytester.runpytest(testpath) result.stdout.fnmatch_lines( [ "collected 3 items / 1 deselected / 2 selected", @@ -564,8 +694,10 @@ def test_three(): ) assert result.ret == 0 - def test_show_deselected_items_using_markexpr_before_test_execution(self, testdir): - testdir.makepyfile( + def test_show_deselected_items_using_markexpr_before_test_execution( + self, pytester: Pytester + ) -> None: + pytester.makepyfile( test_show_deselected=""" import pytest @@ -581,7 +713,7 @@ def test_pass(): pass """ ) - result = testdir.runpytest("-m", "not foo") + result = pytester.runpytest("-m", "not foo") result.stdout.fnmatch_lines( [ "collected 3 items / 1 deselected / 2 selected", @@ -592,8 +724,8 @@ def test_pass(): result.stdout.no_fnmatch_line("*= 1 deselected =*") assert result.ret == 0 - def test_no_skip_summary_if_failure(self, testdir): - testdir.makepyfile( + def test_no_skip_summary_if_failure(self, pytester: Pytester) -> None: + pytester.makepyfile( """ import pytest def test_ok(): @@ -604,12 +736,12 @@ def test_skip(): pytest.skip("dontshow") """ ) - result = testdir.runpytest() + result = pytester.runpytest() assert result.stdout.str().find("skip test summary") == -1 assert result.ret == 1 - def test_passes(self, testdir): - p1 = testdir.makepyfile( + def test_passes(self, pytester: Pytester) -> None: + p1 = pytester.makepyfile( """ def test_passes(): pass @@ -618,23 +750,26 @@ def test_method(self): pass """ ) - old = p1.dirpath().chdir() + old = p1.parent + pytester.chdir() try: - result = testdir.runpytest() + result = pytester.runpytest() finally: - old.chdir() + os.chdir(old) result.stdout.fnmatch_lines(["test_passes.py ..*", "* 2 pass*"]) assert result.ret == 0 - def test_header_trailer_info(self, testdir, request): - testdir.monkeypatch.delenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD") - testdir.makepyfile( + def test_header_trailer_info( + self, monkeypatch: MonkeyPatch, pytester: Pytester, request + ) -> None: + monkeypatch.delenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD") + pytester.makepyfile( """ def test_passes(): pass """ ) - result = testdir.runpytest() + result = pytester.runpytest() verinfo = ".".join(map(str, sys.version_info[:3])) result.stdout.fnmatch_lines( [ @@ -654,37 +789,115 @@ def test_passes(): if request.config.pluginmanager.list_plugin_distinfo(): result.stdout.fnmatch_lines(["plugins: *"]) - def test_header(self, testdir): - testdir.tmpdir.join("tests").ensure_dir() - testdir.tmpdir.join("gui").ensure_dir() + def test_no_header_trailer_info( + self, monkeypatch: MonkeyPatch, pytester: Pytester, request + ) -> None: + monkeypatch.delenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD") + pytester.makepyfile( + """ + def test_passes(): + pass + """ + ) + result = pytester.runpytest("--no-header") + verinfo = ".".join(map(str, sys.version_info[:3])) + result.stdout.no_fnmatch_line( + "platform %s -- Python %s*pytest-%s*py-%s*pluggy-%s" + % ( + sys.platform, + verinfo, + pytest.__version__, + py.__version__, + pluggy.__version__, + ) + ) + if request.config.pluginmanager.list_plugin_distinfo(): + result.stdout.no_fnmatch_line("plugins: *") + + def test_header(self, pytester: Pytester) -> None: + pytester.path.joinpath("tests").mkdir() + pytester.path.joinpath("gui").mkdir() # no ini file - result = testdir.runpytest() + result = pytester.runpytest() result.stdout.fnmatch_lines(["rootdir: *test_header0"]) - # with inifile - testdir.makeini("""[pytest]""") - result = testdir.runpytest() - result.stdout.fnmatch_lines(["rootdir: *test_header0, inifile: tox.ini"]) + # with configfile + pytester.makeini("""[pytest]""") + result = pytester.runpytest() + result.stdout.fnmatch_lines(["rootdir: *test_header0, configfile: tox.ini"]) # with testpaths option, and not passing anything in the command-line - testdir.makeini( + pytester.makeini( """ [pytest] testpaths = tests gui """ ) - result = testdir.runpytest() + result = pytester.runpytest() result.stdout.fnmatch_lines( - ["rootdir: *test_header0, inifile: tox.ini, testpaths: tests, gui"] + ["rootdir: *test_header0, configfile: tox.ini, testpaths: tests, gui"] ) # with testpaths option, passing directory in command-line: do not show testpaths then - result = testdir.runpytest("tests") - result.stdout.fnmatch_lines(["rootdir: *test_header0, inifile: tox.ini"]) + result = pytester.runpytest("tests") + result.stdout.fnmatch_lines(["rootdir: *test_header0, configfile: tox.ini"]) + + def test_header_absolute_testpath( + self, pytester: Pytester, monkeypatch: MonkeyPatch + ) -> None: + """Regresstion test for #7814.""" + tests = pytester.path.joinpath("tests") + tests.mkdir() + pytester.makepyprojecttoml( + """ + [tool.pytest.ini_options] + testpaths = ['{}'] + """.format( + tests + ) + ) + result = pytester.runpytest() + result.stdout.fnmatch_lines( + [ + "rootdir: *absolute_testpath0, configfile: pyproject.toml, testpaths: {}".format( + tests + ) + ] + ) + + def test_no_header(self, pytester: Pytester) -> None: + pytester.path.joinpath("tests").mkdir() + pytester.path.joinpath("gui").mkdir() + + # with testpaths option, and not passing anything in the command-line + pytester.makeini( + """ + [pytest] + testpaths = tests gui + """ + ) + result = pytester.runpytest("--no-header") + result.stdout.no_fnmatch_line( + "rootdir: *test_header0, inifile: tox.ini, testpaths: tests, gui" + ) + + # with testpaths option, passing directory in command-line: do not show testpaths then + result = pytester.runpytest("tests", "--no-header") + result.stdout.no_fnmatch_line("rootdir: *test_header0, inifile: tox.ini") + + def test_no_summary(self, pytester: Pytester) -> None: + p1 = pytester.makepyfile( + """ + def test_no_summary(): + assert false + """ + ) + result = pytester.runpytest(p1, "--no-summary") + result.stdout.no_fnmatch_line("*= FAILURES =*") - def test_showlocals(self, testdir): - p1 = testdir.makepyfile( + def test_showlocals(self, pytester: Pytester) -> None: + p1 = pytester.makepyfile( """ def test_showlocals(): x = 3 @@ -692,7 +905,7 @@ def test_showlocals(): assert 0 """ ) - result = testdir.runpytest(p1, "-l") + result = pytester.runpytest(p1, "-l") result.stdout.fnmatch_lines( [ # "_ _ * Locals *", @@ -701,8 +914,8 @@ def test_showlocals(): ] ) - def test_showlocals_short(self, testdir): - p1 = testdir.makepyfile( + def test_showlocals_short(self, pytester: Pytester) -> None: + p1 = pytester.makepyfile( """ def test_showlocals_short(): x = 3 @@ -710,7 +923,7 @@ def test_showlocals_short(): assert 0 """ ) - result = testdir.runpytest(p1, "-l", "--tb=short") + result = pytester.runpytest(p1, "-l", "--tb=short") result.stdout.fnmatch_lines( [ "test_showlocals_short.py:*", @@ -722,8 +935,8 @@ def test_showlocals_short(): ) @pytest.fixture - def verbose_testfile(self, testdir): - return testdir.makepyfile( + def verbose_testfile(self, pytester: Pytester) -> Path: + return pytester.makepyfile( """ import pytest def test_fail(): @@ -740,8 +953,8 @@ def check(x): """ ) - def test_verbose_reporting(self, verbose_testfile, testdir): - result = testdir.runpytest( + def test_verbose_reporting(self, verbose_testfile, pytester: Pytester) -> None: + result = pytester.runpytest( verbose_testfile, "-v", "-Walways::pytest.PytestWarning" ) result.stdout.fnmatch_lines( @@ -754,12 +967,18 @@ def test_verbose_reporting(self, verbose_testfile, testdir): ) assert result.ret == 1 - def test_verbose_reporting_xdist(self, verbose_testfile, testdir, pytestconfig): + def test_verbose_reporting_xdist( + self, + verbose_testfile, + monkeypatch: MonkeyPatch, + pytester: Pytester, + pytestconfig, + ) -> None: if not pytestconfig.pluginmanager.get_plugin("xdist"): pytest.skip("xdist plugin not installed") - testdir.monkeypatch.delenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD") - result = testdir.runpytest( + monkeypatch.delenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD") + result = pytester.runpytest( verbose_testfile, "-v", "-n 1", "-Walways::pytest.PytestWarning" ) result.stdout.fnmatch_lines( @@ -767,35 +986,35 @@ def test_verbose_reporting_xdist(self, verbose_testfile, testdir, pytestconfig): ) assert result.ret == 1 - def test_quiet_reporting(self, testdir): - p1 = testdir.makepyfile("def test_pass(): pass") - result = testdir.runpytest(p1, "-q") + def test_quiet_reporting(self, pytester: Pytester) -> None: + p1 = pytester.makepyfile("def test_pass(): pass") + result = pytester.runpytest(p1, "-q") s = result.stdout.str() assert "test session starts" not in s - assert p1.basename not in s + assert p1.name not in s assert "===" not in s assert "passed" in s - def test_more_quiet_reporting(self, testdir): - p1 = testdir.makepyfile("def test_pass(): pass") - result = testdir.runpytest(p1, "-qq") + def test_more_quiet_reporting(self, pytester: Pytester) -> None: + p1 = pytester.makepyfile("def test_pass(): pass") + result = pytester.runpytest(p1, "-qq") s = result.stdout.str() assert "test session starts" not in s - assert p1.basename not in s + assert p1.name not in s assert "===" not in s assert "passed" not in s @pytest.mark.parametrize( "params", [(), ("--collect-only",)], ids=["no-params", "collect-only"] ) - def test_report_collectionfinish_hook(self, testdir, params): - testdir.makeconftest( + def test_report_collectionfinish_hook(self, pytester: Pytester, params) -> None: + pytester.makeconftest( """ def pytest_report_collectionfinish(config, startdir, items): return ['hello from hook: {0} items'.format(len(items))] """ ) - testdir.makepyfile( + pytester.makepyfile( """ import pytest @pytest.mark.parametrize('i', range(3)) @@ -803,25 +1022,25 @@ def test(i): pass """ ) - result = testdir.runpytest(*params) + result = pytester.runpytest(*params) result.stdout.fnmatch_lines(["collected 3 items", "hello from hook: 3 items"]) - def test_summary_f_alias(self, testdir): + def test_summary_f_alias(self, pytester: Pytester) -> None: """Test that 'f' and 'F' report chars are aliases and don't show up twice in the summary (#6334)""" - testdir.makepyfile( + pytester.makepyfile( """ def test(): assert False """ ) - result = testdir.runpytest("-rfF") + result = pytester.runpytest("-rfF") expected = "FAILED test_summary_f_alias.py::test - assert False" result.stdout.fnmatch_lines([expected]) assert result.stdout.lines.count(expected) == 1 - def test_summary_s_alias(self, testdir): + def test_summary_s_alias(self, pytester: Pytester) -> None: """Test that 's' and 'S' report chars are aliases and don't show up twice in the summary""" - testdir.makepyfile( + pytester.makepyfile( """ import pytest @@ -830,18 +1049,18 @@ def test(): pass """ ) - result = testdir.runpytest("-rsS") + result = pytester.runpytest("-rsS") expected = "SKIPPED [1] test_summary_s_alias.py:3: unconditional skip" result.stdout.fnmatch_lines([expected]) assert result.stdout.lines.count(expected) == 1 -def test_fail_extra_reporting(testdir, monkeypatch): +def test_fail_extra_reporting(pytester: Pytester, monkeypatch) -> None: monkeypatch.setenv("COLUMNS", "80") - testdir.makepyfile("def test_this(): assert 0, 'this_failed' * 100") - result = testdir.runpytest("-rN") + pytester.makepyfile("def test_this(): assert 0, 'this_failed' * 100") + result = pytester.runpytest("-rN") result.stdout.no_fnmatch_line("*short test summary*") - result = testdir.runpytest() + result = pytester.runpytest() result.stdout.fnmatch_lines( [ "*test summary*", @@ -850,28 +1069,28 @@ def test_fail_extra_reporting(testdir, monkeypatch): ) -def test_fail_reporting_on_pass(testdir): - testdir.makepyfile("def test_this(): assert 1") - result = testdir.runpytest("-rf") +def test_fail_reporting_on_pass(pytester: Pytester) -> None: + pytester.makepyfile("def test_this(): assert 1") + result = pytester.runpytest("-rf") result.stdout.no_fnmatch_line("*short test summary*") -def test_pass_extra_reporting(testdir): - testdir.makepyfile("def test_this(): assert 1") - result = testdir.runpytest() +def test_pass_extra_reporting(pytester: Pytester) -> None: + pytester.makepyfile("def test_this(): assert 1") + result = pytester.runpytest() result.stdout.no_fnmatch_line("*short test summary*") - result = testdir.runpytest("-rp") + result = pytester.runpytest("-rp") result.stdout.fnmatch_lines(["*test summary*", "PASS*test_pass_extra_reporting*"]) -def test_pass_reporting_on_fail(testdir): - testdir.makepyfile("def test_this(): assert 0") - result = testdir.runpytest("-rp") +def test_pass_reporting_on_fail(pytester: Pytester) -> None: + pytester.makepyfile("def test_this(): assert 0") + result = pytester.runpytest("-rp") result.stdout.no_fnmatch_line("*short test summary*") -def test_pass_output_reporting(testdir): - testdir.makepyfile( +def test_pass_output_reporting(pytester: Pytester) -> None: + pytester.makepyfile( """ def setup_module(): print("setup_module") @@ -886,12 +1105,12 @@ def test_pass_no_output(): pass """ ) - result = testdir.runpytest() + result = pytester.runpytest() s = result.stdout.str() assert "test_pass_has_output" not in s assert "Four score and seven years ago..." not in s assert "test_pass_no_output" not in s - result = testdir.runpytest("-rPp") + result = pytester.runpytest("-rPp") result.stdout.fnmatch_lines( [ "*= PASSES =*", @@ -910,8 +1129,8 @@ def test_pass_no_output(): ) -def test_color_yes(testdir, color_mapping): - p1 = testdir.makepyfile( +def test_color_yes(pytester: Pytester, color_mapping) -> None: + p1 = pytester.makepyfile( """ def fail(): assert 0 @@ -920,8 +1139,7 @@ def test_this(): fail() """ ) - result = testdir.runpytest("--color=yes", str(p1)) - color_mapping.requires_ordered_markup(result) + result = pytester.runpytest("--color=yes", str(p1)) result.stdout.fnmatch_lines( color_mapping.format_for_fnmatch( [ @@ -948,7 +1166,7 @@ def test_this(): ] ) ) - result = testdir.runpytest("--color=yes", "--tb=short", str(p1)) + result = pytester.runpytest("--color=yes", "--tb=short", str(p1)) result.stdout.fnmatch_lines( color_mapping.format_for_fnmatch( [ @@ -970,19 +1188,17 @@ def test_this(): ) -def test_color_no(testdir): - testdir.makepyfile("def test_this(): assert 1") - result = testdir.runpytest("--color=no") +def test_color_no(pytester: Pytester) -> None: + pytester.makepyfile("def test_this(): assert 1") + result = pytester.runpytest("--color=no") assert "test session starts" in result.stdout.str() result.stdout.no_fnmatch_line("*\x1b[1m*") @pytest.mark.parametrize("verbose", [True, False]) -def test_color_yes_collection_on_non_atty(testdir, verbose): - """skip collect progress report when working on non-terminals. - #1397 - """ - testdir.makepyfile( +def test_color_yes_collection_on_non_atty(pytester: Pytester, verbose) -> None: + """#1397: Skip collect progress report when working on non-terminals.""" + pytester.makepyfile( """ import pytest @pytest.mark.parametrize('i', range(10)) @@ -993,7 +1209,7 @@ def test_this(i): args = ["--color=yes"] if verbose: args.append("-vv") - result = testdir.runpytest(*args) + result = pytester.runpytest(*args) assert "test session starts" in result.stdout.str() assert "\x1b[1m" in result.stdout.str() result.stdout.no_fnmatch_line("*collecting 10 items*") @@ -1002,17 +1218,17 @@ def test_this(i): assert "collected 10 items" in result.stdout.str() -def test_getreportopt(): +def test_getreportopt() -> None: from _pytest.terminal import _REPORTCHARS_DEFAULT - class Config: + class FakeConfig: class Option: reportchars = _REPORTCHARS_DEFAULT disable_warnings = False option = Option() - config = Config() + config = cast(Config, FakeConfig()) assert _REPORTCHARS_DEFAULT == "fE" @@ -1061,9 +1277,9 @@ class Option: assert getreportopt(config) == "fE" -def test_terminalreporter_reportopt_addopts(testdir): - testdir.makeini("[pytest]\naddopts=-rs") - testdir.makepyfile( +def test_terminalreporter_reportopt_addopts(pytester: Pytester) -> None: + pytester.makeini("[pytest]\naddopts=-rs") + pytester.makepyfile( """ import pytest @@ -1076,12 +1292,12 @@ def test_opt(tr): assert not tr.hasopt('qwe') """ ) - result = testdir.runpytest() + result = pytester.runpytest() result.stdout.fnmatch_lines(["*1 passed*"]) -def test_tbstyle_short(testdir): - p = testdir.makepyfile( +def test_tbstyle_short(pytester: Pytester) -> None: + p = pytester.makepyfile( """ import pytest @@ -1093,37 +1309,36 @@ def test_opt(arg): assert x """ ) - result = testdir.runpytest("--tb=short") + result = pytester.runpytest("--tb=short") s = result.stdout.str() assert "arg = 42" not in s assert "x = 0" not in s - result.stdout.fnmatch_lines(["*%s:8*" % p.basename, " assert x", "E assert*"]) - result = testdir.runpytest() + result.stdout.fnmatch_lines(["*%s:8*" % p.name, " assert x", "E assert*"]) + result = pytester.runpytest() s = result.stdout.str() assert "x = 0" in s assert "assert x" in s -def test_traceconfig(testdir): - result = testdir.runpytest("--traceconfig") +def test_traceconfig(pytester: Pytester) -> None: + result = pytester.runpytest("--traceconfig") result.stdout.fnmatch_lines(["*active plugins*"]) assert result.ret == ExitCode.NO_TESTS_COLLECTED class TestGenericReporting: - """ this test class can be subclassed with a different option - provider to run e.g. distributed tests. - """ + """Test class which can be subclassed with a different option provider to + run e.g. distributed tests.""" - def test_collect_fail(self, testdir, option): - testdir.makepyfile("import xyz\n") - result = testdir.runpytest(*option.args) + def test_collect_fail(self, pytester: Pytester, option) -> None: + pytester.makepyfile("import xyz\n") + result = pytester.runpytest(*option.args) result.stdout.fnmatch_lines( ["ImportError while importing*", "*No module named *xyz*", "*1 error*"] ) - def test_maxfailures(self, testdir, option): - testdir.makepyfile( + def test_maxfailures(self, pytester: Pytester, option) -> None: + pytester.makepyfile( """ def test_1(): assert 0 @@ -1133,7 +1348,7 @@ def test_3(): assert 0 """ ) - result = testdir.runpytest("--maxfail=2", *option.args) + result = pytester.runpytest("--maxfail=2", *option.args) result.stdout.fnmatch_lines( [ "*def test_1():*", @@ -1143,15 +1358,15 @@ def test_3(): ] ) - def test_maxfailures_with_interrupted(self, testdir): - testdir.makepyfile( + def test_maxfailures_with_interrupted(self, pytester: Pytester) -> None: + pytester.makepyfile( """ def test(request): request.session.shouldstop = "session_interrupted" assert 0 """ ) - result = testdir.runpytest("--maxfail=1", "-ra") + result = pytester.runpytest("--maxfail=1", "-ra") result.stdout.fnmatch_lines( [ "*= short test summary info =*", @@ -1162,8 +1377,8 @@ def test(request): ] ) - def test_tb_option(self, testdir, option): - testdir.makepyfile( + def test_tb_option(self, pytester: Pytester, option) -> None: + pytester.makepyfile( """ import pytest def g(): @@ -1175,7 +1390,7 @@ def test_func(): ) for tbopt in ["long", "short", "no"]: print("testing --tb=%s..." % tbopt) - result = testdir.runpytest("-rN", "--tb=%s" % tbopt) + result = pytester.runpytest("-rN", "--tb=%s" % tbopt) s = result.stdout.str() if tbopt == "long": assert "print(6*7)" in s @@ -1189,8 +1404,8 @@ def test_func(): assert "--calling--" not in s assert "IndexError" not in s - def test_tb_crashline(self, testdir, option): - p = testdir.makepyfile( + def test_tb_crashline(self, pytester: Pytester, option) -> None: + p = pytester.makepyfile( """ import pytest def g(): @@ -1202,16 +1417,16 @@ def test_func2(): assert 0, "hello" """ ) - result = testdir.runpytest("--tb=line") - bn = p.basename + result = pytester.runpytest("--tb=line") + bn = p.name result.stdout.fnmatch_lines( ["*%s:3: IndexError*" % bn, "*%s:8: AssertionError: hello*" % bn] ) s = result.stdout.str() assert "def test_func2" not in s - def test_pytest_report_header(self, testdir, option): - testdir.makeconftest( + def test_pytest_report_header(self, pytester: Pytester, option) -> None: + pytester.makeconftest( """ def pytest_sessionstart(session): session.config._somevalue = 42 @@ -1219,17 +1434,17 @@ def pytest_report_header(config): return "hello: %s" % config._somevalue """ ) - testdir.mkdir("a").join("conftest.py").write( + pytester.mkdir("a").joinpath("conftest.py").write_text( """ def pytest_report_header(config, startdir): return ["line1", str(startdir)] """ ) - result = testdir.runpytest("a") - result.stdout.fnmatch_lines(["*hello: 42*", "line1", str(testdir.tmpdir)]) + result = pytester.runpytest("a") + result.stdout.fnmatch_lines(["*hello: 42*", "line1", str(pytester.path)]) - def test_show_capture(self, testdir): - testdir.makepyfile( + def test_show_capture(self, pytester: Pytester) -> None: + pytester.makepyfile( """ import sys import logging @@ -1241,7 +1456,7 @@ def test_one(): """ ) - result = testdir.runpytest("--tb=short") + result = pytester.runpytest("--tb=short") result.stdout.fnmatch_lines( [ "!This is stdout!", @@ -1250,7 +1465,7 @@ def test_one(): ] ) - result = testdir.runpytest("--show-capture=all", "--tb=short") + result = pytester.runpytest("--show-capture=all", "--tb=short") result.stdout.fnmatch_lines( [ "!This is stdout!", @@ -1259,29 +1474,29 @@ def test_one(): ] ) - stdout = testdir.runpytest("--show-capture=stdout", "--tb=short").stdout.str() + stdout = pytester.runpytest("--show-capture=stdout", "--tb=short").stdout.str() assert "!This is stderr!" not in stdout assert "!This is stdout!" in stdout assert "!This is a warning log msg!" not in stdout - stdout = testdir.runpytest("--show-capture=stderr", "--tb=short").stdout.str() + stdout = pytester.runpytest("--show-capture=stderr", "--tb=short").stdout.str() assert "!This is stdout!" not in stdout assert "!This is stderr!" in stdout assert "!This is a warning log msg!" not in stdout - stdout = testdir.runpytest("--show-capture=log", "--tb=short").stdout.str() + stdout = pytester.runpytest("--show-capture=log", "--tb=short").stdout.str() assert "!This is stdout!" not in stdout assert "!This is stderr!" not in stdout assert "!This is a warning log msg!" in stdout - stdout = testdir.runpytest("--show-capture=no", "--tb=short").stdout.str() + stdout = pytester.runpytest("--show-capture=no", "--tb=short").stdout.str() assert "!This is stdout!" not in stdout assert "!This is stderr!" not in stdout assert "!This is a warning log msg!" not in stdout - def test_show_capture_with_teardown_logs(self, testdir): + def test_show_capture_with_teardown_logs(self, pytester: Pytester) -> None: """Ensure that the capturing of teardown logs honor --show-capture setting""" - testdir.makepyfile( + pytester.makepyfile( """ import logging import sys @@ -1299,30 +1514,30 @@ def test_func(): """ ) - result = testdir.runpytest("--show-capture=stdout", "--tb=short").stdout.str() + result = pytester.runpytest("--show-capture=stdout", "--tb=short").stdout.str() assert "!stdout!" in result assert "!stderr!" not in result assert "!log!" not in result - result = testdir.runpytest("--show-capture=stderr", "--tb=short").stdout.str() + result = pytester.runpytest("--show-capture=stderr", "--tb=short").stdout.str() assert "!stdout!" not in result assert "!stderr!" in result assert "!log!" not in result - result = testdir.runpytest("--show-capture=log", "--tb=short").stdout.str() + result = pytester.runpytest("--show-capture=log", "--tb=short").stdout.str() assert "!stdout!" not in result assert "!stderr!" not in result assert "!log!" in result - result = testdir.runpytest("--show-capture=no", "--tb=short").stdout.str() + result = pytester.runpytest("--show-capture=no", "--tb=short").stdout.str() assert "!stdout!" not in result assert "!stderr!" not in result assert "!log!" not in result @pytest.mark.xfail("not hasattr(os, 'dup')") -def test_fdopen_kept_alive_issue124(testdir): - testdir.makepyfile( +def test_fdopen_kept_alive_issue124(pytester: Pytester) -> None: + pytester.makepyfile( """ import os, sys k = [] @@ -1335,12 +1550,12 @@ def test_close_kept_alive_file(): stdout.close() """ ) - result = testdir.runpytest() + result = pytester.runpytest() result.stdout.fnmatch_lines(["*2 passed*"]) -def test_tbstyle_native_setup_error(testdir): - testdir.makepyfile( +def test_tbstyle_native_setup_error(pytester: Pytester) -> None: + pytester.makepyfile( """ import pytest @pytest.fixture @@ -1351,14 +1566,14 @@ def test_error_fixture(setup_error_fixture): pass """ ) - result = testdir.runpytest("--tb=native") + result = pytester.runpytest("--tb=native") result.stdout.fnmatch_lines( ['*File *test_tbstyle_native_setup_error.py", line *, in setup_error_fixture*'] ) -def test_terminal_summary(testdir): - testdir.makeconftest( +def test_terminal_summary(pytester: Pytester) -> None: + pytester.makeconftest( """ def pytest_terminal_summary(terminalreporter, exitstatus): w = terminalreporter @@ -1367,7 +1582,7 @@ def pytest_terminal_summary(terminalreporter, exitstatus): w.line("exitstatus: {0}".format(exitstatus)) """ ) - result = testdir.runpytest() + result = pytester.runpytest() result.stdout.fnmatch_lines( """ *==== hello ====* @@ -1378,18 +1593,18 @@ def pytest_terminal_summary(terminalreporter, exitstatus): @pytest.mark.filterwarnings("default") -def test_terminal_summary_warnings_are_displayed(testdir): +def test_terminal_summary_warnings_are_displayed(pytester: Pytester) -> None: """Test that warnings emitted during pytest_terminal_summary are displayed. (#1305). """ - testdir.makeconftest( + pytester.makeconftest( """ import warnings def pytest_terminal_summary(terminalreporter): warnings.warn(UserWarning('internal warning')) """ ) - testdir.makepyfile( + pytester.makepyfile( """ def test_failure(): import warnings @@ -1397,7 +1612,7 @@ def test_failure(): assert 0 """ ) - result = testdir.runpytest("-ra") + result = pytester.runpytest("-ra") result.stdout.fnmatch_lines( [ "*= warnings summary =*", @@ -1415,8 +1630,8 @@ def test_failure(): @pytest.mark.filterwarnings("default") -def test_terminal_summary_warnings_header_once(testdir): - testdir.makepyfile( +def test_terminal_summary_warnings_header_once(pytester: Pytester) -> None: + pytester.makepyfile( """ def test_failure(): import warnings @@ -1424,7 +1639,7 @@ def test_failure(): assert 0 """ ) - result = testdir.runpytest("-ra") + result = pytester.runpytest("-ra") result.stdout.fnmatch_lines( [ "*= warnings summary =*", @@ -1439,6 +1654,21 @@ def test_failure(): assert stdout.count("=== warnings summary ") == 1 +@pytest.mark.filterwarnings("default") +def test_terminal_no_summary_warnings_header_once(pytester: Pytester) -> None: + pytester.makepyfile( + """ + def test_failure(): + import warnings + warnings.warn("warning_from_" + "test") + assert 0 + """ + ) + result = pytester.runpytest("--no-summary") + result.stdout.no_fnmatch_line("*= warnings summary =*") + result.stdout.no_fnmatch_line("*= short test summary info =*") + + @pytest.fixture(scope="session") def tr() -> TerminalReporter: config = _pytest.config._prepareconfig() @@ -1452,66 +1682,66 @@ def tr() -> TerminalReporter: # dict value, not the actual contents, so tuples of anything # suffice # Important statuses -- the highest priority of these always wins - ("red", [("1 failed", {"bold": True, "red": True})], {"failed": (1,)}), + ("red", [("1 failed", {"bold": True, "red": True})], {"failed": [1]}), ( "red", [ ("1 failed", {"bold": True, "red": True}), ("1 passed", {"bold": False, "green": True}), ], - {"failed": (1,), "passed": (1,)}, + {"failed": [1], "passed": [1]}, ), - ("red", [("1 error", {"bold": True, "red": True})], {"error": (1,)}), - ("red", [("2 errors", {"bold": True, "red": True})], {"error": (1, 2)}), + ("red", [("1 error", {"bold": True, "red": True})], {"error": [1]}), + ("red", [("2 errors", {"bold": True, "red": True})], {"error": [1, 2]}), ( "red", [ ("1 passed", {"bold": False, "green": True}), ("1 error", {"bold": True, "red": True}), ], - {"error": (1,), "passed": (1,)}, + {"error": [1], "passed": [1]}, ), # (a status that's not known to the code) - ("yellow", [("1 weird", {"bold": True, "yellow": True})], {"weird": (1,)}), + ("yellow", [("1 weird", {"bold": True, "yellow": True})], {"weird": [1]}), ( "yellow", [ ("1 passed", {"bold": False, "green": True}), ("1 weird", {"bold": True, "yellow": True}), ], - {"weird": (1,), "passed": (1,)}, + {"weird": [1], "passed": [1]}, ), - ("yellow", [("1 warning", {"bold": True, "yellow": True})], {"warnings": (1,)}), + ("yellow", [("1 warning", {"bold": True, "yellow": True})], {"warnings": [1]}), ( "yellow", [ ("1 passed", {"bold": False, "green": True}), ("1 warning", {"bold": True, "yellow": True}), ], - {"warnings": (1,), "passed": (1,)}, + {"warnings": [1], "passed": [1]}, ), ( "green", [("5 passed", {"bold": True, "green": True})], - {"passed": (1, 2, 3, 4, 5)}, + {"passed": [1, 2, 3, 4, 5]}, ), # "Boring" statuses. These have no effect on the color of the summary # line. Thus, if *every* test has a boring status, the summary line stays # at its default color, i.e. yellow, to warn the user that the test run # produced no useful information - ("yellow", [("1 skipped", {"bold": True, "yellow": True})], {"skipped": (1,)}), + ("yellow", [("1 skipped", {"bold": True, "yellow": True})], {"skipped": [1]}), ( "green", [ ("1 passed", {"bold": True, "green": True}), ("1 skipped", {"bold": False, "yellow": True}), ], - {"skipped": (1,), "passed": (1,)}, + {"skipped": [1], "passed": [1]}, ), ( "yellow", [("1 deselected", {"bold": True, "yellow": True})], - {"deselected": (1,)}, + {"deselected": [1]}, ), ( "green", @@ -1519,34 +1749,34 @@ def tr() -> TerminalReporter: ("1 passed", {"bold": True, "green": True}), ("1 deselected", {"bold": False, "yellow": True}), ], - {"deselected": (1,), "passed": (1,)}, + {"deselected": [1], "passed": [1]}, ), - ("yellow", [("1 xfailed", {"bold": True, "yellow": True})], {"xfailed": (1,)}), + ("yellow", [("1 xfailed", {"bold": True, "yellow": True})], {"xfailed": [1]}), ( "green", [ ("1 passed", {"bold": True, "green": True}), ("1 xfailed", {"bold": False, "yellow": True}), ], - {"xfailed": (1,), "passed": (1,)}, + {"xfailed": [1], "passed": [1]}, ), - ("yellow", [("1 xpassed", {"bold": True, "yellow": True})], {"xpassed": (1,)}), + ("yellow", [("1 xpassed", {"bold": True, "yellow": True})], {"xpassed": [1]}), ( "yellow", [ ("1 passed", {"bold": False, "green": True}), ("1 xpassed", {"bold": True, "yellow": True}), ], - {"xpassed": (1,), "passed": (1,)}, + {"xpassed": [1], "passed": [1]}, ), # Likewise if no tests were found at all ("yellow", [("no tests ran", {"yellow": True})], {}), # Test the empty-key special case - ("yellow", [("no tests ran", {"yellow": True})], {"": (1,)}), + ("yellow", [("no tests ran", {"yellow": True})], {"": [1]}), ( "green", [("1 passed", {"bold": True, "green": True})], - {"": (1,), "passed": (1,)}, + {"": [1], "passed": [1]}, ), # A couple more complex combinations ( @@ -1556,7 +1786,7 @@ def tr() -> TerminalReporter: ("2 passed", {"bold": False, "green": True}), ("3 xfailed", {"bold": False, "yellow": True}), ], - {"passed": (1, 2), "failed": (1,), "xfailed": (1, 2, 3)}, + {"passed": [1, 2], "failed": [1], "xfailed": [1, 2, 3]}, ), ( "green", @@ -1567,10 +1797,10 @@ def tr() -> TerminalReporter: ("2 xfailed", {"bold": False, "yellow": True}), ], { - "passed": (1,), - "skipped": (1, 2), - "deselected": (1, 2, 3), - "xfailed": (1, 2), + "passed": [1], + "skipped": [1, 2], + "deselected": [1, 2, 3], + "xfailed": [1, 2], }, ), ], @@ -1579,7 +1809,7 @@ def test_summary_stats( tr: TerminalReporter, exp_line: List[Tuple[str, Dict[str, bool]]], exp_color: str, - stats_arg: Dict[str, List], + stats_arg: Dict[str, List[object]], ) -> None: tr.stats = stats_arg @@ -1587,16 +1817,16 @@ def test_summary_stats( class fake_session: testscollected = 0 - tr._session = fake_session # type: ignore[assignment] # noqa: F821 + tr._session = fake_session # type: ignore[assignment] assert tr._is_last_item # Reset cache. tr._main_color = None print("Based on stats: %s" % stats_arg) - print('Expect summary: "{}"; with color "{}"'.format(exp_line, exp_color)) + print(f'Expect summary: "{exp_line}"; with color "{exp_color}"') (line, color) = tr.build_summary_stats_line() - print('Actually got: "{}"; with color "{}"'.format(line, color)) + print(f'Actually got: "{line}"; with color "{color}"') assert line == exp_line assert color == exp_color @@ -1623,8 +1853,8 @@ class TestClassicOutputStyle: """Ensure classic output style works as expected (#3883)""" @pytest.fixture - def test_files(self, testdir): - testdir.makepyfile( + def test_files(self, pytester: Pytester) -> None: + pytester.makepyfile( **{ "test_one.py": "def test_one(): pass", "test_two.py": "def test_two(): assert 0", @@ -1636,39 +1866,39 @@ def test_three_3(): pass } ) - def test_normal_verbosity(self, testdir, test_files): - result = testdir.runpytest("-o", "console_output_style=classic") + def test_normal_verbosity(self, pytester: Pytester, test_files) -> None: + result = pytester.runpytest("-o", "console_output_style=classic") result.stdout.fnmatch_lines( [ "test_one.py .", "test_two.py F", - "sub{}test_three.py .F.".format(os.sep), + f"sub{os.sep}test_three.py .F.", "*2 failed, 3 passed in*", ] ) - def test_verbose(self, testdir, test_files): - result = testdir.runpytest("-o", "console_output_style=classic", "-v") + def test_verbose(self, pytester: Pytester, test_files) -> None: + result = pytester.runpytest("-o", "console_output_style=classic", "-v") result.stdout.fnmatch_lines( [ "test_one.py::test_one PASSED", "test_two.py::test_two FAILED", - "sub{}test_three.py::test_three_1 PASSED".format(os.sep), - "sub{}test_three.py::test_three_2 FAILED".format(os.sep), - "sub{}test_three.py::test_three_3 PASSED".format(os.sep), + f"sub{os.sep}test_three.py::test_three_1 PASSED", + f"sub{os.sep}test_three.py::test_three_2 FAILED", + f"sub{os.sep}test_three.py::test_three_3 PASSED", "*2 failed, 3 passed in*", ] ) - def test_quiet(self, testdir, test_files): - result = testdir.runpytest("-o", "console_output_style=classic", "-q") + def test_quiet(self, pytester: Pytester, test_files) -> None: + result = pytester.runpytest("-o", "console_output_style=classic", "-q") result.stdout.fnmatch_lines([".F.F.", "*2 failed, 3 passed in*"]) class TestProgressOutputStyle: @pytest.fixture - def many_tests_files(self, testdir): - testdir.makepyfile( + def many_tests_files(self, pytester: Pytester) -> None: + pytester.makepyfile( test_bar=""" import pytest @pytest.mark.parametrize('i', range(10)) @@ -1686,10 +1916,10 @@ def test_foobar(i): pass """, ) - def test_zero_tests_collected(self, testdir): + def test_zero_tests_collected(self, pytester: Pytester) -> None: """Some plugins (testmon for example) might issue pytest_runtest_logreport without any tests being actually collected (#2971).""" - testdir.makeconftest( + pytester.makeconftest( """ def pytest_collection_modifyitems(items, config): from _pytest.runner import CollectReport @@ -1700,12 +1930,12 @@ def pytest_collection_modifyitems(items, config): config.hook.pytest_runtest_logreport(report=rep) """ ) - output = testdir.runpytest() + output = pytester.runpytest() output.stdout.no_fnmatch_line("*ZeroDivisionError*") output.stdout.fnmatch_lines(["=* 2 passed in *="]) - def test_normal(self, many_tests_files, testdir): - output = testdir.runpytest() + def test_normal(self, many_tests_files, pytester: Pytester) -> None: + output = pytester.runpytest() output.stdout.re_match_lines( [ r"test_bar.py \.{10} \s+ \[ 50%\]", @@ -1714,9 +1944,11 @@ def test_normal(self, many_tests_files, testdir): ] ) - def test_colored_progress(self, testdir, monkeypatch, color_mapping): + def test_colored_progress( + self, pytester: Pytester, monkeypatch, color_mapping + ) -> None: monkeypatch.setenv("PY_COLORS", "1") - testdir.makepyfile( + pytester.makepyfile( test_axfail=""" import pytest @pytest.mark.xfail @@ -1741,7 +1973,7 @@ def test_foo(i): def test_foobar(i): raise ValueError() """, ) - result = testdir.runpytest() + result = pytester.runpytest() result.stdout.re_match_lines( color_mapping.format_for_rematch( [ @@ -1754,7 +1986,7 @@ def test_foobar(i): raise ValueError() ) # Only xfail should have yellow progress indicator. - result = testdir.runpytest("test_axfail.py") + result = pytester.runpytest("test_axfail.py") result.stdout.re_match_lines( color_mapping.format_for_rematch( [ @@ -1764,14 +1996,14 @@ def test_foobar(i): raise ValueError() ) ) - def test_count(self, many_tests_files, testdir): - testdir.makeini( + def test_count(self, many_tests_files, pytester: Pytester) -> None: + pytester.makeini( """ [pytest] console_output_style = count """ ) - output = testdir.runpytest() + output = pytester.runpytest() output.stdout.re_match_lines( [ r"test_bar.py \.{10} \s+ \[10/20\]", @@ -1780,8 +2012,8 @@ def test_count(self, many_tests_files, testdir): ] ) - def test_verbose(self, many_tests_files, testdir): - output = testdir.runpytest("-v") + def test_verbose(self, many_tests_files, pytester: Pytester) -> None: + output = pytester.runpytest("-v") output.stdout.re_match_lines( [ r"test_bar.py::test_bar\[0\] PASSED \s+ \[ 5%\]", @@ -1790,14 +2022,14 @@ def test_verbose(self, many_tests_files, testdir): ] ) - def test_verbose_count(self, many_tests_files, testdir): - testdir.makeini( + def test_verbose_count(self, many_tests_files, pytester: Pytester) -> None: + pytester.makeini( """ [pytest] console_output_style = count """ ) - output = testdir.runpytest("-v") + output = pytester.runpytest("-v") output.stdout.re_match_lines( [ r"test_bar.py::test_bar\[0\] PASSED \s+ \[ 1/20\]", @@ -1806,28 +2038,34 @@ def test_verbose_count(self, many_tests_files, testdir): ] ) - def test_xdist_normal(self, many_tests_files, testdir, monkeypatch): + def test_xdist_normal( + self, many_tests_files, pytester: Pytester, monkeypatch + ) -> None: pytest.importorskip("xdist") monkeypatch.delenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD", raising=False) - output = testdir.runpytest("-n2") + output = pytester.runpytest("-n2") output.stdout.re_match_lines([r"\.{20} \s+ \[100%\]"]) - def test_xdist_normal_count(self, many_tests_files, testdir, monkeypatch): + def test_xdist_normal_count( + self, many_tests_files, pytester: Pytester, monkeypatch + ) -> None: pytest.importorskip("xdist") monkeypatch.delenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD", raising=False) - testdir.makeini( + pytester.makeini( """ [pytest] console_output_style = count """ ) - output = testdir.runpytest("-n2") + output = pytester.runpytest("-n2") output.stdout.re_match_lines([r"\.{20} \s+ \[20/20\]"]) - def test_xdist_verbose(self, many_tests_files, testdir, monkeypatch): + def test_xdist_verbose( + self, many_tests_files, pytester: Pytester, monkeypatch + ) -> None: pytest.importorskip("xdist") monkeypatch.delenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD", raising=False) - output = testdir.runpytest("-n2", "-v") + output = pytester.runpytest("-n2", "-v") output.stdout.re_match_lines_random( [ r"\[gw\d\] \[\s*\d+%\] PASSED test_bar.py::test_bar\[1\]", @@ -1852,13 +2090,13 @@ def test_xdist_verbose(self, many_tests_files, testdir, monkeypatch): ] ) - def test_capture_no(self, many_tests_files, testdir): - output = testdir.runpytest("-s") + def test_capture_no(self, many_tests_files, pytester: Pytester) -> None: + output = pytester.runpytest("-s") output.stdout.re_match_lines( [r"test_bar.py \.{10}", r"test_foo.py \.{5}", r"test_foobar.py \.{5}"] ) - output = testdir.runpytest("--capture=no") + output = pytester.runpytest("--capture=no") output.stdout.no_fnmatch_line("*%]*") @@ -1866,8 +2104,8 @@ class TestProgressWithTeardown: """Ensure we show the correct percentages for tests that fail during teardown (#3088)""" @pytest.fixture - def contest_with_teardown_fixture(self, testdir): - testdir.makeconftest( + def contest_with_teardown_fixture(self, pytester: Pytester) -> None: + pytester.makeconftest( """ import pytest @@ -1879,8 +2117,8 @@ def fail_teardown(): ) @pytest.fixture - def many_files(self, testdir, contest_with_teardown_fixture): - testdir.makepyfile( + def many_files(self, pytester: Pytester, contest_with_teardown_fixture) -> None: + pytester.makepyfile( test_bar=""" import pytest @pytest.mark.parametrize('i', range(5)) @@ -1895,26 +2133,28 @@ def test_foo(fail_teardown, i): """, ) - def test_teardown_simple(self, testdir, contest_with_teardown_fixture): - testdir.makepyfile( + def test_teardown_simple( + self, pytester: Pytester, contest_with_teardown_fixture + ) -> None: + pytester.makepyfile( """ def test_foo(fail_teardown): pass """ ) - output = testdir.runpytest() + output = pytester.runpytest() output.stdout.re_match_lines([r"test_teardown_simple.py \.E\s+\[100%\]"]) def test_teardown_with_test_also_failing( - self, testdir, contest_with_teardown_fixture - ): - testdir.makepyfile( + self, pytester: Pytester, contest_with_teardown_fixture + ) -> None: + pytester.makepyfile( """ def test_foo(fail_teardown): assert 0 """ ) - output = testdir.runpytest("-rfE") + output = pytester.runpytest("-rfE") output.stdout.re_match_lines( [ r"test_teardown_with_test_also_failing.py FE\s+\[100%\]", @@ -1923,16 +2163,16 @@ def test_foo(fail_teardown): ] ) - def test_teardown_many(self, testdir, many_files): - output = testdir.runpytest() + def test_teardown_many(self, pytester: Pytester, many_files) -> None: + output = pytester.runpytest() output.stdout.re_match_lines( [r"test_bar.py (\.E){5}\s+\[ 25%\]", r"test_foo.py (\.E){15}\s+\[100%\]"] ) def test_teardown_many_verbose( - self, testdir: Testdir, many_files, color_mapping + self, pytester: Pytester, many_files, color_mapping ) -> None: - result = testdir.runpytest("-v") + result = pytester.runpytest("-v") result.stdout.fnmatch_lines( color_mapping.format_for_fnmatch( [ @@ -1946,14 +2186,14 @@ def test_teardown_many_verbose( ) ) - def test_xdist_normal(self, many_files, testdir, monkeypatch): + def test_xdist_normal(self, many_files, pytester: Pytester, monkeypatch) -> None: pytest.importorskip("xdist") monkeypatch.delenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD", raising=False) - output = testdir.runpytest("-n2") + output = pytester.runpytest("-n2") output.stdout.re_match_lines([r"[\.E]{40} \s+ \[100%\]"]) -def test_skip_reasons_folding(): +def test_skip_reasons_folding() -> None: path = "xyz" lineno = 3 message = "justso" @@ -1962,35 +2202,32 @@ def test_skip_reasons_folding(): class X: pass - ev1 = X() + ev1 = cast(CollectReport, X()) ev1.when = "execute" ev1.skipped = True ev1.longrepr = longrepr - ev2 = X() + ev2 = cast(CollectReport, X()) ev2.when = "execute" ev2.longrepr = longrepr ev2.skipped = True # ev3 might be a collection report - ev3 = X() + ev3 = cast(CollectReport, X()) ev3.when = "collect" ev3.longrepr = longrepr ev3.skipped = True - values = _folded_skips([ev1, ev2, ev3]) + values = _folded_skips(Path.cwd(), [ev1, ev2, ev3]) assert len(values) == 1 - num, fspath, lineno, reason = values[0] + num, fspath, lineno_, reason = values[0] assert num == 3 assert fspath == path - assert lineno == lineno + assert lineno_ == lineno assert reason == message -def test_line_with_reprcrash(monkeypatch): - import _pytest.terminal - from wcwidth import wcswidth - +def test_line_with_reprcrash(monkeypatch: MonkeyPatch) -> None: mocked_verbose_word = "FAILED" mocked_pos = "some::nodeid" @@ -2014,11 +2251,11 @@ class reprcrash: def check(msg, width, expected): __tracebackhide__ = True if msg: - rep.longrepr.reprcrash.message = msg - actual = _get_line_with_reprcrash_message(config, rep(), width) + rep.longrepr.reprcrash.message = msg # type: ignore + actual = _get_line_with_reprcrash_message(config, rep(), width) # type: ignore assert actual == expected - if actual != "{} {}".format(mocked_verbose_word, mocked_pos): + if actual != f"{mocked_verbose_word} {mocked_pos}": assert len(actual) <= width assert wcswidth(actual) <= width @@ -2040,19 +2277,19 @@ def check(msg, width, expected): check("some\nmessage", 80, "FAILED some::nodeid - some") # Test unicode safety. - check("😄😄😄😄😄\n2nd line", 25, "FAILED some::nodeid - ...") - check("😄😄😄😄😄\n2nd line", 26, "FAILED some::nodeid - ...") - check("😄😄😄😄😄\n2nd line", 27, "FAILED some::nodeid - 😄...") - check("😄😄😄😄😄\n2nd line", 28, "FAILED some::nodeid - 😄...") - check("😄😄😄😄😄\n2nd line", 29, "FAILED some::nodeid - 😄😄...") + check("🉐🉐🉐🉐🉐\n2nd line", 25, "FAILED some::nodeid - ...") + check("🉐🉐🉐🉐🉐\n2nd line", 26, "FAILED some::nodeid - ...") + check("🉐🉐🉐🉐🉐\n2nd line", 27, "FAILED some::nodeid - 🉐...") + check("🉐🉐🉐🉐🉐\n2nd line", 28, "FAILED some::nodeid - 🉐...") + check("🉐🉐🉐🉐🉐\n2nd line", 29, "FAILED some::nodeid - 🉐🉐...") # NOTE: constructed, not sure if this is supported. - mocked_pos = "nodeid::😄::withunicode" - check("😄😄😄😄😄\n2nd line", 29, "FAILED nodeid::😄::withunicode") - check("😄😄😄😄😄\n2nd line", 40, "FAILED nodeid::😄::withunicode - 😄😄...") - check("😄😄😄😄😄\n2nd line", 41, "FAILED nodeid::😄::withunicode - 😄😄...") - check("😄😄😄😄😄\n2nd line", 42, "FAILED nodeid::😄::withunicode - 😄😄😄...") - check("😄😄😄😄😄\n2nd line", 80, "FAILED nodeid::😄::withunicode - 😄😄😄😄😄") + mocked_pos = "nodeid::🉐::withunicode" + check("🉐🉐🉐🉐🉐\n2nd line", 29, "FAILED nodeid::🉐::withunicode") + check("🉐🉐🉐🉐🉐\n2nd line", 40, "FAILED nodeid::🉐::withunicode - 🉐🉐...") + check("🉐🉐🉐🉐🉐\n2nd line", 41, "FAILED nodeid::🉐::withunicode - 🉐🉐...") + check("🉐🉐🉐🉐🉐\n2nd line", 42, "FAILED nodeid::🉐::withunicode - 🉐🉐🉐...") + check("🉐🉐🉐🉐🉐\n2nd line", 80, "FAILED nodeid::🉐::withunicode - 🉐🉐🉐🉐🉐") @pytest.mark.parametrize( @@ -2072,9 +2309,9 @@ def test_format_session_duration(seconds, expected): assert format_session_duration(seconds) == expected -def test_collecterror(testdir): - p1 = testdir.makepyfile("raise SyntaxError()") - result = testdir.runpytest("-ra", str(p1)) +def test_collecterror(pytester: Pytester) -> None: + p1 = pytester.makepyfile("raise SyntaxError()") + result = pytester.runpytest("-ra", str(p1)) result.stdout.fnmatch_lines( [ "collected 0 items / 1 error", @@ -2089,24 +2326,29 @@ def test_collecterror(testdir): ) -def test_via_exec(testdir: Testdir) -> None: - p1 = testdir.makepyfile("exec('def test_via_exec(): pass')") - result = testdir.runpytest(str(p1), "-vv") +def test_no_summary_collecterror(pytester: Pytester) -> None: + p1 = pytester.makepyfile("raise SyntaxError()") + result = pytester.runpytest("-ra", "--no-summary", str(p1)) + result.stdout.no_fnmatch_line("*= ERRORS =*") + + +def test_via_exec(pytester: Pytester) -> None: + p1 = pytester.makepyfile("exec('def test_via_exec(): pass')") + result = pytester.runpytest(str(p1), "-vv") result.stdout.fnmatch_lines( ["test_via_exec.py::test_via_exec <- PASSED*", "*= 1 passed in *"] ) class TestCodeHighlight: - def test_code_highlight_simple(self, testdir: Testdir, color_mapping) -> None: - testdir.makepyfile( + def test_code_highlight_simple(self, pytester: Pytester, color_mapping) -> None: + pytester.makepyfile( """ def test_foo(): assert 1 == 10 """ ) - result = testdir.runpytest("--color=yes") - color_mapping.requires_ordered_markup(result) + result = pytester.runpytest("--color=yes") result.stdout.fnmatch_lines( color_mapping.format_for_fnmatch( [ @@ -2117,16 +2359,17 @@ def test_foo(): ) ) - def test_code_highlight_continuation(self, testdir: Testdir, color_mapping) -> None: - testdir.makepyfile( + def test_code_highlight_continuation( + self, pytester: Pytester, color_mapping + ) -> None: + pytester.makepyfile( """ def test_foo(): print(''' '''); assert 0 """ ) - result = testdir.runpytest("--color=yes") - color_mapping.requires_ordered_markup(result) + result = pytester.runpytest("--color=yes") result.stdout.fnmatch_lines( color_mapping.format_for_fnmatch( @@ -2138,3 +2381,27 @@ def test_foo(): ] ) ) + + +def test_raw_skip_reason_skipped() -> None: + report = SimpleNamespace() + report.skipped = True + report.longrepr = ("xyz", 3, "Skipped: Just so") + + reason = _get_raw_skip_reason(cast(TestReport, report)) + assert reason == "Just so" + + +def test_raw_skip_reason_xfail() -> None: + report = SimpleNamespace() + report.wasxfail = "reason: To everything there is a season" + + reason = _get_raw_skip_reason(cast(TestReport, report)) + assert reason == "To everything there is a season" + + +def test_format_trimmed() -> None: + msg = "unconditional skip" + + assert _format_trimmed(" ({}) ", msg, len(msg) + 4) == " (unconditional skip) " + assert _format_trimmed(" ({}) ", msg, len(msg) + 3) == " (unconditional ...) " diff --git a/testing/test_threadexception.py b/testing/test_threadexception.py new file mode 100644 index 00000000000..399692bc963 --- /dev/null +++ b/testing/test_threadexception.py @@ -0,0 +1,137 @@ +import sys + +import pytest +from _pytest.pytester import Pytester + + +if sys.version_info < (3, 8): + pytest.skip("threadexception plugin needs Python>=3.8", allow_module_level=True) + + +@pytest.mark.filterwarnings("default") +def test_unhandled_thread_exception(pytester: Pytester) -> None: + pytester.makepyfile( + test_it=""" + import threading + + def test_it(): + def oops(): + raise ValueError("Oops") + + t = threading.Thread(target=oops, name="MyThread") + t.start() + t.join() + + def test_2(): pass + """ + ) + result = pytester.runpytest() + assert result.ret == 0 + assert result.parseoutcomes() == {"passed": 2, "warnings": 1} + result.stdout.fnmatch_lines( + [ + "*= warnings summary =*", + "test_it.py::test_it", + " * PytestUnhandledThreadExceptionWarning: Exception in thread MyThread", + " ", + " Traceback (most recent call last):", + " ValueError: Oops", + " ", + " warnings.warn(pytest.PytestUnhandledThreadExceptionWarning(msg))", + ] + ) + + +@pytest.mark.filterwarnings("default") +def test_unhandled_thread_exception_in_setup(pytester: Pytester) -> None: + pytester.makepyfile( + test_it=""" + import threading + import pytest + + @pytest.fixture + def threadexc(): + def oops(): + raise ValueError("Oops") + t = threading.Thread(target=oops, name="MyThread") + t.start() + t.join() + + def test_it(threadexc): pass + def test_2(): pass + """ + ) + result = pytester.runpytest() + assert result.ret == 0 + assert result.parseoutcomes() == {"passed": 2, "warnings": 1} + result.stdout.fnmatch_lines( + [ + "*= warnings summary =*", + "test_it.py::test_it", + " * PytestUnhandledThreadExceptionWarning: Exception in thread MyThread", + " ", + " Traceback (most recent call last):", + " ValueError: Oops", + " ", + " warnings.warn(pytest.PytestUnhandledThreadExceptionWarning(msg))", + ] + ) + + +@pytest.mark.filterwarnings("default") +def test_unhandled_thread_exception_in_teardown(pytester: Pytester) -> None: + pytester.makepyfile( + test_it=""" + import threading + import pytest + + @pytest.fixture + def threadexc(): + def oops(): + raise ValueError("Oops") + yield + t = threading.Thread(target=oops, name="MyThread") + t.start() + t.join() + + def test_it(threadexc): pass + def test_2(): pass + """ + ) + result = pytester.runpytest() + assert result.ret == 0 + assert result.parseoutcomes() == {"passed": 2, "warnings": 1} + result.stdout.fnmatch_lines( + [ + "*= warnings summary =*", + "test_it.py::test_it", + " * PytestUnhandledThreadExceptionWarning: Exception in thread MyThread", + " ", + " Traceback (most recent call last):", + " ValueError: Oops", + " ", + " warnings.warn(pytest.PytestUnhandledThreadExceptionWarning(msg))", + ] + ) + + +@pytest.mark.filterwarnings("error::pytest.PytestUnhandledThreadExceptionWarning") +def test_unhandled_thread_exception_warning_error(pytester: Pytester) -> None: + pytester.makepyfile( + test_it=""" + import threading + import pytest + + def test_it(): + def oops(): + raise ValueError("Oops") + t = threading.Thread(target=oops, name="MyThread") + t.start() + t.join() + + def test_2(): pass + """ + ) + result = pytester.runpytest() + assert result.ret == pytest.ExitCode.TESTS_FAILED + assert result.parseoutcomes() == {"passed": 1, "failed": 1} diff --git a/testing/test_tmpdir.py b/testing/test_tmpdir.py index b7cf8d2b5c6..d123287aa38 100644 --- a/testing/test_tmpdir.py +++ b/testing/test_tmpdir.py @@ -1,17 +1,32 @@ import os import stat import sys +from pathlib import Path +from typing import Callable +from typing import cast +from typing import List import attr import pytest from _pytest import pathlib -from _pytest.pathlib import Path - - -def test_tmpdir_fixture(testdir): - p = testdir.copy_example("tmpdir/tmpdir_fixture.py") - results = testdir.runpytest(p) +from _pytest.config import Config +from _pytest.pathlib import cleanup_numbered_dir +from _pytest.pathlib import create_cleanup_lock +from _pytest.pathlib import make_numbered_dir +from _pytest.pathlib import maybe_delete_a_numbered_dir +from _pytest.pathlib import on_rm_rf_error +from _pytest.pathlib import register_cleanup_lock_removal +from _pytest.pathlib import rm_rf +from _pytest.pytester import Pytester +from _pytest.tmpdir import get_user +from _pytest.tmpdir import TempdirFactory +from _pytest.tmpdir import TempPathFactory + + +def test_tmpdir_fixture(pytester: Pytester) -> None: + p = pytester.copy_example("tmpdir/tmpdir_fixture.py") + results = pytester.runpytest(p) results.stdout.fnmatch_lines(["*1 passed*"]) @@ -33,11 +48,10 @@ def option(self): class TestTempdirHandler: def test_mktemp(self, tmp_path): - - from _pytest.tmpdir import TempdirFactory, TempPathFactory - - config = FakeConfig(tmp_path) - t = TempdirFactory(TempPathFactory.from_config(config)) + config = cast(Config, FakeConfig(tmp_path)) + t = TempdirFactory( + TempPathFactory.from_config(config, _ispytest=True), _ispytest=True + ) tmp = t.mktemp("world") assert tmp.relto(t.getbasetemp()) == "world0" tmp = t.mktemp("this") @@ -48,30 +62,28 @@ def test_mktemp(self, tmp_path): def test_tmppath_relative_basetemp_absolute(self, tmp_path, monkeypatch): """#4425""" - from _pytest.tmpdir import TempPathFactory - monkeypatch.chdir(tmp_path) - config = FakeConfig("hello") - t = TempPathFactory.from_config(config) + config = cast(Config, FakeConfig("hello")) + t = TempPathFactory.from_config(config, _ispytest=True) assert t.getbasetemp().resolve() == (tmp_path / "hello").resolve() class TestConfigTmpdir: - def test_getbasetemp_custom_removes_old(self, testdir): - mytemp = testdir.tmpdir.join("xyz") - p = testdir.makepyfile( + def test_getbasetemp_custom_removes_old(self, pytester: Pytester) -> None: + mytemp = pytester.path.joinpath("xyz") + p = pytester.makepyfile( """ def test_1(tmpdir): pass """ ) - testdir.runpytest(p, "--basetemp=%s" % mytemp) - mytemp.check() - mytemp.ensure("hello") + pytester.runpytest(p, "--basetemp=%s" % mytemp) + assert mytemp.exists() + mytemp.joinpath("hello").touch() - testdir.runpytest(p, "--basetemp=%s" % mytemp) - mytemp.check() - assert not mytemp.join("hello").check() + pytester.runpytest(p, "--basetemp=%s" % mytemp) + assert mytemp.exists() + assert not mytemp.joinpath("hello").exists() testdata = [ @@ -87,11 +99,10 @@ def test_1(tmpdir): @pytest.mark.parametrize("basename, is_ok", testdata) -def test_mktemp(testdir, basename, is_ok): - mytemp = testdir.tmpdir.mkdir("mytemp") - p = testdir.makepyfile( +def test_mktemp(pytester: Pytester, basename: str, is_ok: bool) -> None: + mytemp = pytester.mkdir("mytemp") + p = pytester.makepyfile( """ - import pytest def test_abs_path(tmpdir_factory): tmpdir_factory.mktemp('{}', numbered=False) """.format( @@ -99,54 +110,54 @@ def test_abs_path(tmpdir_factory): ) ) - result = testdir.runpytest(p, "--basetemp=%s" % mytemp) + result = pytester.runpytest(p, "--basetemp=%s" % mytemp) if is_ok: assert result.ret == 0 - assert mytemp.join(basename).check() + assert mytemp.joinpath(basename).exists() else: assert result.ret == 1 result.stdout.fnmatch_lines("*ValueError*") -def test_tmpdir_always_is_realpath(testdir): +def test_tmpdir_always_is_realpath(pytester: Pytester) -> None: # the reason why tmpdir should be a realpath is that # when you cd to it and do "os.getcwd()" you will anyway # get the realpath. Using the symlinked path can thus # easily result in path-inequality # XXX if that proves to be a problem, consider using # os.environ["PWD"] - realtemp = testdir.tmpdir.mkdir("myrealtemp") - linktemp = testdir.tmpdir.join("symlinktemp") + realtemp = pytester.mkdir("myrealtemp") + linktemp = pytester.path.joinpath("symlinktemp") attempt_symlink_to(linktemp, str(realtemp)) - p = testdir.makepyfile( + p = pytester.makepyfile( """ def test_1(tmpdir): import os assert os.path.realpath(str(tmpdir)) == str(tmpdir) """ ) - result = testdir.runpytest("-s", p, "--basetemp=%s/bt" % linktemp) + result = pytester.runpytest("-s", p, "--basetemp=%s/bt" % linktemp) assert not result.ret -def test_tmp_path_always_is_realpath(testdir, monkeypatch): +def test_tmp_path_always_is_realpath(pytester: Pytester, monkeypatch) -> None: # for reasoning see: test_tmpdir_always_is_realpath test-case - realtemp = testdir.tmpdir.mkdir("myrealtemp") - linktemp = testdir.tmpdir.join("symlinktemp") + realtemp = pytester.mkdir("myrealtemp") + linktemp = pytester.path.joinpath("symlinktemp") attempt_symlink_to(linktemp, str(realtemp)) monkeypatch.setenv("PYTEST_DEBUG_TEMPROOT", str(linktemp)) - testdir.makepyfile( + pytester.makepyfile( """ def test_1(tmp_path): assert tmp_path.resolve() == tmp_path """ ) - reprec = testdir.inline_run() + reprec = pytester.inline_run() reprec.assertoutcome(passed=1) -def test_tmpdir_too_long_on_parametrization(testdir): - testdir.makepyfile( +def test_tmpdir_too_long_on_parametrization(pytester: Pytester) -> None: + pytester.makepyfile( """ import pytest @pytest.mark.parametrize("arg", ["1"*1000]) @@ -154,12 +165,12 @@ def test_some(arg, tmpdir): tmpdir.ensure("hello") """ ) - reprec = testdir.inline_run() + reprec = pytester.inline_run() reprec.assertoutcome(passed=1) -def test_tmpdir_factory(testdir): - testdir.makepyfile( +def test_tmpdir_factory(pytester: Pytester) -> None: + pytester.makepyfile( """ import pytest @pytest.fixture(scope='session') @@ -169,24 +180,23 @@ def test_some(session_dir): assert session_dir.isdir() """ ) - reprec = testdir.inline_run() + reprec = pytester.inline_run() reprec.assertoutcome(passed=1) -def test_tmpdir_fallback_tox_env(testdir, monkeypatch): +def test_tmpdir_fallback_tox_env(pytester: Pytester, monkeypatch) -> None: """Test that tmpdir works even if environment variables required by getpass module are missing (#1010). """ monkeypatch.delenv("USER", raising=False) monkeypatch.delenv("USERNAME", raising=False) - testdir.makepyfile( + pytester.makepyfile( """ - import pytest def test_some(tmpdir): assert tmpdir.isdir() """ ) - reprec = testdir.inline_run() + reprec = pytester.inline_run() reprec.assertoutcome(passed=1) @@ -200,19 +210,18 @@ def break_getuser(monkeypatch): @pytest.mark.usefixtures("break_getuser") @pytest.mark.skipif(sys.platform.startswith("win"), reason="no os.getuid on windows") -def test_tmpdir_fallback_uid_not_found(testdir): +def test_tmpdir_fallback_uid_not_found(pytester: Pytester) -> None: """Test that tmpdir works even if the current process's user id does not correspond to a valid user. """ - testdir.makepyfile( + pytester.makepyfile( """ - import pytest def test_some(tmpdir): assert tmpdir.isdir() """ ) - reprec = testdir.inline_run() + reprec = pytester.inline_run() reprec.assertoutcome(passed=1) @@ -223,8 +232,6 @@ def test_get_user_uid_not_found(): user id does not correspond to a valid user (e.g. running pytest in a Docker container with 'docker run -u'. """ - from _pytest.tmpdir import get_user - assert get_user() is None @@ -234,8 +241,6 @@ def test_get_user(monkeypatch): required by getpass module are missing from the environment on Windows (#1010). """ - from _pytest.tmpdir import get_user - monkeypatch.delenv("USER", raising=False) monkeypatch.delenv("USERNAME", raising=False) assert get_user() is None @@ -245,8 +250,6 @@ class TestNumberedDir: PREFIX = "fun-" def test_make(self, tmp_path): - from _pytest.pathlib import make_numbered_dir - for i in range(10): d = make_numbered_dir(root=tmp_path, prefix=self.PREFIX) assert d.name.startswith(self.PREFIX) @@ -261,20 +264,16 @@ def test_make(self, tmp_path): def test_cleanup_lock_create(self, tmp_path): d = tmp_path.joinpath("test") d.mkdir() - from _pytest.pathlib import create_cleanup_lock - lockfile = create_cleanup_lock(d) - with pytest.raises(EnvironmentError, match="cannot create lockfile in .*"): + with pytest.raises(OSError, match="cannot create lockfile in .*"): create_cleanup_lock(d) lockfile.unlink() - def test_lock_register_cleanup_removal(self, tmp_path): - from _pytest.pathlib import create_cleanup_lock, register_cleanup_lock_removal - + def test_lock_register_cleanup_removal(self, tmp_path: Path) -> None: lock = create_cleanup_lock(tmp_path) - registry = [] + registry: List[Callable[..., None]] = [] register_cleanup_lock_removal(lock, register=registry.append) (cleanup_func,) = registry @@ -293,10 +292,8 @@ def test_lock_register_cleanup_removal(self, tmp_path): assert not lock.exists() - def _do_cleanup(self, tmp_path): + def _do_cleanup(self, tmp_path: Path) -> None: self.test_make(tmp_path) - from _pytest.pathlib import cleanup_numbered_dir - cleanup_numbered_dir( root=tmp_path, prefix=self.PREFIX, @@ -310,12 +307,9 @@ def test_cleanup_keep(self, tmp_path): print(a, b) def test_cleanup_locked(self, tmp_path): + p = make_numbered_dir(root=tmp_path, prefix=self.PREFIX) - from _pytest import pathlib - - p = pathlib.make_numbered_dir(root=tmp_path, prefix=self.PREFIX) - - pathlib.create_cleanup_lock(p) + create_cleanup_lock(p) assert not pathlib.ensure_deletable( p, consider_lock_dead_if_created_before=p.stat().st_mtime - 1 @@ -330,16 +324,14 @@ def test_cleanup_ignores_symlink(self, tmp_path): self._do_cleanup(tmp_path) def test_removal_accepts_lock(self, tmp_path): - folder = pathlib.make_numbered_dir(root=tmp_path, prefix=self.PREFIX) - pathlib.create_cleanup_lock(folder) - pathlib.maybe_delete_a_numbered_dir(folder) + folder = make_numbered_dir(root=tmp_path, prefix=self.PREFIX) + create_cleanup_lock(folder) + maybe_delete_a_numbered_dir(folder) assert folder.is_dir() class TestRmRf: def test_rm_rf(self, tmp_path): - from _pytest.pathlib import rm_rf - adir = tmp_path / "adir" adir.mkdir() rm_rf(adir) @@ -355,8 +347,6 @@ def test_rm_rf(self, tmp_path): def test_rm_rf_with_read_only_file(self, tmp_path): """Ensure rm_rf can remove directories with read-only files in them (#5524)""" - from _pytest.pathlib import rm_rf - fn = tmp_path / "dir/foo.txt" fn.parent.mkdir() @@ -374,8 +364,6 @@ def chmod_r(self, path): def test_rm_rf_with_read_only_directory(self, tmp_path): """Ensure rm_rf can remove read-only directories (#5524)""" - from _pytest.pathlib import rm_rf - adir = tmp_path / "dir" adir.mkdir() @@ -386,9 +374,7 @@ def test_rm_rf_with_read_only_directory(self, tmp_path): assert not adir.is_dir() - def test_on_rm_rf_error(self, tmp_path): - from _pytest.pathlib import on_rm_rf_error - + def test_on_rm_rf_error(self, tmp_path: Path) -> None: adir = tmp_path / "dir" adir.mkdir() @@ -398,32 +384,32 @@ def test_on_rm_rf_error(self, tmp_path): # unknown exception with pytest.warns(pytest.PytestWarning): - exc_info = (None, RuntimeError(), None) - on_rm_rf_error(os.unlink, str(fn), exc_info, start_path=tmp_path) + exc_info1 = (None, RuntimeError(), None) + on_rm_rf_error(os.unlink, str(fn), exc_info1, start_path=tmp_path) assert fn.is_file() # we ignore FileNotFoundError - exc_info = (None, FileNotFoundError(), None) - assert not on_rm_rf_error(None, str(fn), exc_info, start_path=tmp_path) + exc_info2 = (None, FileNotFoundError(), None) + assert not on_rm_rf_error(None, str(fn), exc_info2, start_path=tmp_path) # unknown function with pytest.warns( pytest.PytestWarning, match=r"^\(rm_rf\) unknown function None when removing .*foo.txt:\nNone: ", ): - exc_info = (None, PermissionError(), None) - on_rm_rf_error(None, str(fn), exc_info, start_path=tmp_path) + exc_info3 = (None, PermissionError(), None) + on_rm_rf_error(None, str(fn), exc_info3, start_path=tmp_path) assert fn.is_file() # ignored function with pytest.warns(None) as warninfo: - exc_info = (None, PermissionError(), None) - on_rm_rf_error(os.open, str(fn), exc_info, start_path=tmp_path) + exc_info4 = (None, PermissionError(), None) + on_rm_rf_error(os.open, str(fn), exc_info4, start_path=tmp_path) assert fn.is_file() assert not [x.message for x in warninfo] - exc_info = (None, PermissionError(), None) - on_rm_rf_error(os.unlink, str(fn), exc_info, start_path=tmp_path) + exc_info5 = (None, PermissionError(), None) + on_rm_rf_error(os.unlink, str(fn), exc_info5, start_path=tmp_path) assert not fn.is_file() @@ -440,9 +426,9 @@ def test_tmpdir_equals_tmp_path(tmpdir, tmp_path): assert Path(tmpdir) == tmp_path -def test_basetemp_with_read_only_files(testdir): +def test_basetemp_with_read_only_files(pytester: Pytester) -> None: """Integration test for #5524""" - testdir.makepyfile( + pytester.makepyfile( """ import os import stat @@ -454,8 +440,8 @@ def test(tmp_path): os.chmod(str(fn), mode & ~stat.S_IREAD) """ ) - result = testdir.runpytest("--basetemp=tmp") + result = pytester.runpytest("--basetemp=tmp") assert result.ret == 0 # running a second time and ensure we don't crash - result = testdir.runpytest("--basetemp=tmp") + result = pytester.runpytest("--basetemp=tmp") assert result.ret == 0 diff --git a/testing/test_unittest.py b/testing/test_unittest.py index c5fc20239b1..8b00cb826ac 100644 --- a/testing/test_unittest.py +++ b/testing/test_unittest.py @@ -1,7 +1,10 @@ import gc +import sys +from typing import List import pytest from _pytest.config import ExitCode +from _pytest.pytester import Testdir def test_simple_unittest(testdir): @@ -530,35 +533,27 @@ def f(_): # will crash both at test time and at teardown """ ) - # Ignore DeprecationWarning (for `cmp`) from attrs through twisted, - # for stable test results. - result = testdir.runpytest( - "-vv", "-oconsole_output_style=classic", "-W", "ignore::DeprecationWarning" - ) + result = testdir.runpytest("-vv", "-oconsole_output_style=classic") result.stdout.fnmatch_lines( [ - "test_trial_error.py::TC::test_four SKIPPED", + "test_trial_error.py::TC::test_four FAILED", "test_trial_error.py::TC::test_four ERROR", "test_trial_error.py::TC::test_one FAILED", "test_trial_error.py::TC::test_three FAILED", - "test_trial_error.py::TC::test_two SKIPPED", - "test_trial_error.py::TC::test_two ERROR", + "test_trial_error.py::TC::test_two FAILED", "*ERRORS*", "*_ ERROR at teardown of TC.test_four _*", - "NOTE: Incompatible Exception Representation, displaying natively:", - "*DelayedCalls*", - "*_ ERROR at teardown of TC.test_two _*", - "NOTE: Incompatible Exception Representation, displaying natively:", "*DelayedCalls*", "*= FAILURES =*", - # "*_ TC.test_four _*", - # "*NameError*crash*", + "*_ TC.test_four _*", + "*NameError*crash*", "*_ TC.test_one _*", "*NameError*crash*", "*_ TC.test_three _*", - "NOTE: Incompatible Exception Representation, displaying natively:", "*DelayedCalls*", - "*= 2 failed, 2 skipped, 2 errors in *", + "*_ TC.test_two _*", + "*NameError*crash*", + "*= 4 failed, 1 error in *", ] ) @@ -787,20 +782,18 @@ def test_passing_test_is_fail(self): assert result.ret == 1 -@pytest.mark.parametrize( - "fix_type, stmt", [("fixture", "return"), ("yield_fixture", "yield")] -) -def test_unittest_setup_interaction(testdir, fix_type, stmt): +@pytest.mark.parametrize("stmt", ["return", "yield"]) +def test_unittest_setup_interaction(testdir: Testdir, stmt: str) -> None: testdir.makepyfile( """ import unittest import pytest class MyTestCase(unittest.TestCase): - @pytest.{fix_type}(scope="class", autouse=True) + @pytest.fixture(scope="class", autouse=True) def perclass(self, request): request.cls.hello = "world" {stmt} - @pytest.{fix_type}(scope="function", autouse=True) + @pytest.fixture(scope="function", autouse=True) def perfunction(self, request): request.instance.funcname = request.function.__name__ {stmt} @@ -815,7 +808,7 @@ def test_method2(self): def test_classattr(self): assert self.__class__.hello == "world" """.format( - fix_type=fix_type, stmt=stmt + stmt=stmt ) ) result = testdir.runpytest() @@ -876,6 +869,37 @@ def test_notTornDown(): reprec.assertoutcome(passed=1, failed=1) +def test_cleanup_functions(testdir): + """Ensure functions added with addCleanup are always called after each test ends (#6947)""" + testdir.makepyfile( + """ + import unittest + + cleanups = [] + + class Test(unittest.TestCase): + + def test_func_1(self): + self.addCleanup(cleanups.append, "test_func_1") + + def test_func_2(self): + self.addCleanup(cleanups.append, "test_func_2") + assert 0 + + def test_func_3_check_cleanups(self): + assert cleanups == ["test_func_1", "test_func_2"] + """ + ) + result = testdir.runpytest("-v") + result.stdout.fnmatch_lines( + [ + "*::test_func_1 PASSED *", + "*::test_func_2 FAILED *", + "*::test_func_3_check_cleanups PASSED *", + ] + ) + + def test_issue333_result_clearing(testdir): testdir.makeconftest( """ @@ -1061,9 +1085,9 @@ def test_error_message_with_parametrized_fixtures(testdir): ) def test_setup_inheritance_skipping(testdir, test_name, expected_outcome): """Issue #4700""" - testdir.copy_example("unittest/{}".format(test_name)) + testdir.copy_example(f"unittest/{test_name}") result = testdir.runpytest() - result.stdout.fnmatch_lines(["* {} in *".format(expected_outcome)]) + result.stdout.fnmatch_lines([f"* {expected_outcome} in *"]) def test_BdbQuit(testdir): @@ -1129,3 +1153,270 @@ def test(self): result = testdir.runpytest("--trace", str(p1)) assert len(calls) == 2 assert result.ret == 0 + + +def test_pdb_teardown_called(testdir, monkeypatch) -> None: + """Ensure tearDown() is always called when --pdb is given in the command-line. + + We delay the normal tearDown() calls when --pdb is given, so this ensures we are calling + tearDown() eventually to avoid memory leaks when using --pdb. + """ + teardowns: List[str] = [] + monkeypatch.setattr( + pytest, "test_pdb_teardown_called_teardowns", teardowns, raising=False + ) + + testdir.makepyfile( + """ + import unittest + import pytest + + class MyTestCase(unittest.TestCase): + + def tearDown(self): + pytest.test_pdb_teardown_called_teardowns.append(self.id()) + + def test_1(self): + pass + def test_2(self): + pass + """ + ) + result = testdir.runpytest_inprocess("--pdb") + result.stdout.fnmatch_lines("* 2 passed in *") + assert teardowns == [ + "test_pdb_teardown_called.MyTestCase.test_1", + "test_pdb_teardown_called.MyTestCase.test_2", + ] + + +@pytest.mark.parametrize("mark", ["@unittest.skip", "@pytest.mark.skip"]) +def test_pdb_teardown_skipped(testdir, monkeypatch, mark: str) -> None: + """With --pdb, setUp and tearDown should not be called for skipped tests.""" + tracked: List[str] = [] + monkeypatch.setattr(pytest, "test_pdb_teardown_skipped", tracked, raising=False) + + testdir.makepyfile( + """ + import unittest + import pytest + + class MyTestCase(unittest.TestCase): + + def setUp(self): + pytest.test_pdb_teardown_skipped.append("setUp:" + self.id()) + + def tearDown(self): + pytest.test_pdb_teardown_skipped.append("tearDown:" + self.id()) + + {mark}("skipped for reasons") + def test_1(self): + pass + + """.format( + mark=mark + ) + ) + result = testdir.runpytest_inprocess("--pdb") + result.stdout.fnmatch_lines("* 1 skipped in *") + assert tracked == [] + + +def test_async_support(testdir): + pytest.importorskip("unittest.async_case") + + testdir.copy_example("unittest/test_unittest_asyncio.py") + reprec = testdir.inline_run() + reprec.assertoutcome(failed=1, passed=2) + + +def test_asynctest_support(testdir): + """Check asynctest support (#7110)""" + pytest.importorskip("asynctest") + + testdir.copy_example("unittest/test_unittest_asynctest.py") + reprec = testdir.inline_run() + reprec.assertoutcome(failed=1, passed=2) + + +def test_plain_unittest_does_not_support_async(testdir): + """Async functions in plain unittest.TestCase subclasses are not supported without plugins. + + This test exists here to avoid introducing this support by accident, leading users + to expect that it works, rather than doing so intentionally as a feature. + + See https://github.com/pytest-dev/pytest-asyncio/issues/180 for more context. + """ + testdir.copy_example("unittest/test_unittest_plain_async.py") + result = testdir.runpytest_subprocess() + if hasattr(sys, "pypy_version_info"): + # in PyPy we can't reliable get the warning about the coroutine not being awaited, + # because it depends on the coroutine being garbage collected; given that + # we are running in a subprocess, that's difficult to enforce + expected_lines = ["*1 passed*"] + else: + expected_lines = [ + "*RuntimeWarning: coroutine * was never awaited", + "*1 passed*", + ] + result.stdout.fnmatch_lines(expected_lines) + + +@pytest.mark.skipif( + sys.version_info < (3, 8), reason="Feature introduced in Python 3.8" +) +def test_do_class_cleanups_on_success(testdir): + testpath = testdir.makepyfile( + """ + import unittest + class MyTestCase(unittest.TestCase): + values = [] + @classmethod + def setUpClass(cls): + def cleanup(): + cls.values.append(1) + cls.addClassCleanup(cleanup) + def test_one(self): + pass + def test_two(self): + pass + def test_cleanup_called_exactly_once(): + assert MyTestCase.values == [1] + """ + ) + reprec = testdir.inline_run(testpath) + passed, skipped, failed = reprec.countoutcomes() + assert failed == 0 + assert passed == 3 + + +@pytest.mark.skipif( + sys.version_info < (3, 8), reason="Feature introduced in Python 3.8" +) +def test_do_class_cleanups_on_setupclass_failure(testdir): + testpath = testdir.makepyfile( + """ + import unittest + class MyTestCase(unittest.TestCase): + values = [] + @classmethod + def setUpClass(cls): + def cleanup(): + cls.values.append(1) + cls.addClassCleanup(cleanup) + assert False + def test_one(self): + pass + def test_cleanup_called_exactly_once(): + assert MyTestCase.values == [1] + """ + ) + reprec = testdir.inline_run(testpath) + passed, skipped, failed = reprec.countoutcomes() + assert failed == 1 + assert passed == 1 + + +@pytest.mark.skipif( + sys.version_info < (3, 8), reason="Feature introduced in Python 3.8" +) +def test_do_class_cleanups_on_teardownclass_failure(testdir): + testpath = testdir.makepyfile( + """ + import unittest + class MyTestCase(unittest.TestCase): + values = [] + @classmethod + def setUpClass(cls): + def cleanup(): + cls.values.append(1) + cls.addClassCleanup(cleanup) + @classmethod + def tearDownClass(cls): + assert False + def test_one(self): + pass + def test_two(self): + pass + def test_cleanup_called_exactly_once(): + assert MyTestCase.values == [1] + """ + ) + reprec = testdir.inline_run(testpath) + passed, skipped, failed = reprec.countoutcomes() + assert passed == 3 + + +def test_do_cleanups_on_success(testdir): + testpath = testdir.makepyfile( + """ + import unittest + class MyTestCase(unittest.TestCase): + values = [] + def setUp(self): + def cleanup(): + self.values.append(1) + self.addCleanup(cleanup) + def test_one(self): + pass + def test_two(self): + pass + def test_cleanup_called_the_right_number_of_times(): + assert MyTestCase.values == [1, 1] + """ + ) + reprec = testdir.inline_run(testpath) + passed, skipped, failed = reprec.countoutcomes() + assert failed == 0 + assert passed == 3 + + +def test_do_cleanups_on_setup_failure(testdir): + testpath = testdir.makepyfile( + """ + import unittest + class MyTestCase(unittest.TestCase): + values = [] + def setUp(self): + def cleanup(): + self.values.append(1) + self.addCleanup(cleanup) + assert False + def test_one(self): + pass + def test_two(self): + pass + def test_cleanup_called_the_right_number_of_times(): + assert MyTestCase.values == [1, 1] + """ + ) + reprec = testdir.inline_run(testpath) + passed, skipped, failed = reprec.countoutcomes() + assert failed == 2 + assert passed == 1 + + +def test_do_cleanups_on_teardown_failure(testdir): + testpath = testdir.makepyfile( + """ + import unittest + class MyTestCase(unittest.TestCase): + values = [] + def setUp(self): + def cleanup(): + self.values.append(1) + self.addCleanup(cleanup) + def tearDown(self): + assert False + def test_one(self): + pass + def test_two(self): + pass + def test_cleanup_called_the_right_number_of_times(): + assert MyTestCase.values == [1, 1] + """ + ) + reprec = testdir.inline_run(testpath) + passed, skipped, failed = reprec.countoutcomes() + assert failed == 2 + assert passed == 1 diff --git a/testing/test_unraisableexception.py b/testing/test_unraisableexception.py new file mode 100644 index 00000000000..32f89033409 --- /dev/null +++ b/testing/test_unraisableexception.py @@ -0,0 +1,133 @@ +import sys + +import pytest +from _pytest.pytester import Pytester + + +if sys.version_info < (3, 8): + pytest.skip("unraisableexception plugin needs Python>=3.8", allow_module_level=True) + + +@pytest.mark.filterwarnings("default") +def test_unraisable(pytester: Pytester) -> None: + pytester.makepyfile( + test_it=""" + class BrokenDel: + def __del__(self): + raise ValueError("del is broken") + + def test_it(): + obj = BrokenDel() + del obj + + def test_2(): pass + """ + ) + result = pytester.runpytest() + assert result.ret == 0 + assert result.parseoutcomes() == {"passed": 2, "warnings": 1} + result.stdout.fnmatch_lines( + [ + "*= warnings summary =*", + "test_it.py::test_it", + " * PytestUnraisableExceptionWarning: Exception ignored in: ", + " ", + " Traceback (most recent call last):", + " ValueError: del is broken", + " ", + " warnings.warn(pytest.PytestUnraisableExceptionWarning(msg))", + ] + ) + + +@pytest.mark.filterwarnings("default") +def test_unraisable_in_setup(pytester: Pytester) -> None: + pytester.makepyfile( + test_it=""" + import pytest + + class BrokenDel: + def __del__(self): + raise ValueError("del is broken") + + @pytest.fixture + def broken_del(): + obj = BrokenDel() + del obj + + def test_it(broken_del): pass + def test_2(): pass + """ + ) + result = pytester.runpytest() + assert result.ret == 0 + assert result.parseoutcomes() == {"passed": 2, "warnings": 1} + result.stdout.fnmatch_lines( + [ + "*= warnings summary =*", + "test_it.py::test_it", + " * PytestUnraisableExceptionWarning: Exception ignored in: ", + " ", + " Traceback (most recent call last):", + " ValueError: del is broken", + " ", + " warnings.warn(pytest.PytestUnraisableExceptionWarning(msg))", + ] + ) + + +@pytest.mark.filterwarnings("default") +def test_unraisable_in_teardown(pytester: Pytester) -> None: + pytester.makepyfile( + test_it=""" + import pytest + + class BrokenDel: + def __del__(self): + raise ValueError("del is broken") + + @pytest.fixture + def broken_del(): + yield + obj = BrokenDel() + del obj + + def test_it(broken_del): pass + def test_2(): pass + """ + ) + result = pytester.runpytest() + assert result.ret == 0 + assert result.parseoutcomes() == {"passed": 2, "warnings": 1} + result.stdout.fnmatch_lines( + [ + "*= warnings summary =*", + "test_it.py::test_it", + " * PytestUnraisableExceptionWarning: Exception ignored in: ", + " ", + " Traceback (most recent call last):", + " ValueError: del is broken", + " ", + " warnings.warn(pytest.PytestUnraisableExceptionWarning(msg))", + ] + ) + + +@pytest.mark.filterwarnings("error::pytest.PytestUnraisableExceptionWarning") +def test_unraisable_warning_error(pytester: Pytester) -> None: + pytester.makepyfile( + test_it=""" + class BrokenDel: + def __del__(self) -> None: + raise ValueError("del is broken") + + def test_it() -> None: + obj = BrokenDel() + del obj + + def test_2(): pass + """ + ) + result = pytester.runpytest() + assert result.ret == pytest.ExitCode.TESTS_FAILED + assert result.parseoutcomes() == {"passed": 1, "failed": 1} diff --git a/testing/test_warning_types.py b/testing/test_warning_types.py index f16d7252a68..b49cc68f9c6 100644 --- a/testing/test_warning_types.py +++ b/testing/test_warning_types.py @@ -1,18 +1,19 @@ import inspect -import _pytest.warning_types import pytest +from _pytest import warning_types +from _pytest.pytester import Pytester @pytest.mark.parametrize( "warning_class", [ w - for n, w in vars(_pytest.warning_types).items() + for n, w in vars(warning_types).items() if inspect.isclass(w) and issubclass(w, Warning) ], ) -def test_warning_types(warning_class): +def test_warning_types(warning_class: UserWarning) -> None: """Make sure all warnings declared in _pytest.warning_types are displayed as coming from 'pytest' instead of the internal module (#5452). """ @@ -20,11 +21,11 @@ def test_warning_types(warning_class): @pytest.mark.filterwarnings("error::pytest.PytestWarning") -def test_pytest_warnings_repr_integration_test(testdir): +def test_pytest_warnings_repr_integration_test(pytester: Pytester) -> None: """Small integration test to ensure our small hack of setting the __module__ attribute of our warnings actually works (#5452). """ - testdir.makepyfile( + pytester.makepyfile( """ import pytest import warnings @@ -33,5 +34,5 @@ def test(): warnings.warn(pytest.PytestWarning("some warning")) """ ) - result = testdir.runpytest() + result = pytester.runpytest() result.stdout.fnmatch_lines(["E pytest.PytestWarning: some warning"]) diff --git a/testing/test_warnings.py b/testing/test_warnings.py index 5387c8d4423..66898041f08 100644 --- a/testing/test_warnings.py +++ b/testing/test_warnings.py @@ -1,20 +1,30 @@ import os import warnings +from typing import List +from typing import Optional +from typing import Tuple import pytest +from _pytest.fixtures import FixtureRequest +from _pytest.pytester import Testdir WARNINGS_SUMMARY_HEADER = "warnings summary" @pytest.fixture -def pyfile_with_warnings(testdir, request): - """ - Create a test file which calls a function in a module which generates warnings. - """ +def pyfile_with_warnings(testdir: Testdir, request: FixtureRequest) -> str: + """Create a test file which calls a function in a module which generates warnings.""" testdir.syspathinsert() test_name = request.function.__name__ module_name = test_name.lstrip("test_") + "_module" - testdir.makepyfile( + test_file = testdir.makepyfile( + """ + import {module_name} + def test_func(): + assert {module_name}.foo() == 1 + """.format( + module_name=module_name + ), **{ module_name: """ import warnings @@ -22,24 +32,16 @@ def foo(): warnings.warn(UserWarning("user warning")) warnings.warn(RuntimeWarning("runtime warning")) return 1 - """, - test_name: """ - import {module_name} - def test_func(): - assert {module_name}.foo() == 1 - """.format( - module_name=module_name - ), - } + """, + }, ) + return str(test_file) @pytest.mark.filterwarnings("default") def test_normal_flow(testdir, pyfile_with_warnings): - """ - Check that the warnings section is displayed. - """ - result = testdir.runpytest() + """Check that the warnings section is displayed.""" + result = testdir.runpytest(pyfile_with_warnings) result.stdout.fnmatch_lines( [ "*== %s ==*" % WARNINGS_SUMMARY_HEADER, @@ -54,7 +56,7 @@ def test_normal_flow(testdir, pyfile_with_warnings): @pytest.mark.filterwarnings("always") -def test_setup_teardown_warnings(testdir, pyfile_with_warnings): +def test_setup_teardown_warnings(testdir): testdir.makepyfile( """ import warnings @@ -95,7 +97,7 @@ def test_as_errors(testdir, pyfile_with_warnings, method): ) # Use a subprocess, since changing logging level affects other threads # (xdist). - result = testdir.runpytest_subprocess(*args) + result = testdir.runpytest_subprocess(*args, pyfile_with_warnings) result.stdout.fnmatch_lines( [ "E UserWarning: user warning", @@ -116,15 +118,15 @@ def test_ignore(testdir, pyfile_with_warnings, method): """ ) - result = testdir.runpytest(*args) + result = testdir.runpytest(*args, pyfile_with_warnings) result.stdout.fnmatch_lines(["* 1 passed in *"]) assert WARNINGS_SUMMARY_HEADER not in result.stdout.str() @pytest.mark.filterwarnings("always") -def test_unicode(testdir, pyfile_with_warnings): +def test_unicode(testdir): testdir.makepyfile( - """\ + """ import warnings import pytest @@ -174,9 +176,8 @@ def test_my_warning(self): @pytest.mark.parametrize("default_config", ["ini", "cmdline"]) def test_filterwarnings_mark(testdir, default_config): - """ - Test ``filterwarnings`` mark works and takes precedence over command line and ini options. - """ + """Test ``filterwarnings`` mark works and takes precedence over command + line and ini options.""" if default_config == "ini": testdir.makeini( """ @@ -239,9 +240,8 @@ def test_func(): def test_warning_captured_hook(testdir): testdir.makeconftest( """ - from _pytest.warnings import _issue_warning_captured def pytest_configure(config): - _issue_warning_captured(UserWarning("config warning"), config.hook, stacklevel=2) + config.issue_config_time_warning(UserWarning("config warning"), stacklevel=2) """ ) testdir.makepyfile( @@ -265,9 +265,8 @@ def test_func(fix): collected = [] class WarningCollector: - def pytest_warning_captured(self, warning_message, when, item): - imge_name = item.name if item is not None else "" - collected.append((str(warning_message.message), when, imge_name)) + def pytest_warning_recorded(self, warning_message, when, nodeid, location): + collected.append((str(warning_message.message), when, nodeid, location)) result = testdir.runpytest(plugins=[WarningCollector()]) result.stdout.fnmatch_lines(["*1 passed*"]) @@ -275,18 +274,32 @@ def pytest_warning_captured(self, warning_message, when, item): expected = [ ("config warning", "config", ""), ("collect warning", "collect", ""), - ("setup warning", "runtest", "test_func"), - ("call warning", "runtest", "test_func"), - ("teardown warning", "runtest", "test_func"), + ("setup warning", "runtest", "test_warning_captured_hook.py::test_func"), + ("call warning", "runtest", "test_warning_captured_hook.py::test_func"), + ("teardown warning", "runtest", "test_warning_captured_hook.py::test_func"), ] - assert collected == expected + for index in range(len(expected)): + collected_result = collected[index] + expected_result = expected[index] + + assert collected_result[0] == expected_result[0], str(collected) + assert collected_result[1] == expected_result[1], str(collected) + assert collected_result[2] == expected_result[2], str(collected) + + # NOTE: collected_result[3] is location, which differs based on the platform you are on + # thus, the best we can do here is assert the types of the paremeters match what we expect + # and not try and preload it in the expected array + if collected_result[3] is not None: + assert type(collected_result[3][0]) is str, str(collected) + assert type(collected_result[3][1]) is int, str(collected) + assert type(collected_result[3][2]) is str, str(collected) + else: + assert collected_result[3] is None, str(collected) @pytest.mark.filterwarnings("always") def test_collection_warnings(testdir): - """ - Check that we also capture warnings issued during test collection (#3251). - """ + """Check that we also capture warnings issued during test collection (#3251).""" testdir.makepyfile( """ import warnings @@ -366,7 +379,7 @@ def test_bar(): @pytest.mark.parametrize("ignore_on_cmdline", [True, False]) def test_option_precedence_cmdline_over_ini(testdir, ignore_on_cmdline): - """filters defined in the command-line should take precedence over filters in ini files (#3946).""" + """Filters defined in the command-line should take precedence over filters in ini files (#3946).""" testdir.makeini( """ [pytest] @@ -500,7 +513,7 @@ def test_hidden_by_system(self, testdir, monkeypatch): @pytest.mark.parametrize("change_default", [None, "ini", "cmdline"]) @pytest.mark.skip( - reason="This test should be enabled again before pytest 6.0 is released" + reason="This test should be enabled again before pytest 7.0 is released" ) def test_deprecation_warning_as_error(testdir, change_default): """This ensures that PytestDeprecationWarnings raised by pytest are turned into errors. @@ -571,35 +584,54 @@ def test_group_warnings_by_message(testdir): result = testdir.runpytest() result.stdout.fnmatch_lines( [ - "test_group_warnings_by_message.py::test_foo[0]", - "test_group_warnings_by_message.py::test_foo[1]", - "test_group_warnings_by_message.py::test_foo[2]", - "test_group_warnings_by_message.py::test_foo[3]", - "test_group_warnings_by_message.py::test_foo[4]", - "test_group_warnings_by_message.py::test_bar", - ] + "*== %s ==*" % WARNINGS_SUMMARY_HEADER, + "test_group_warnings_by_message.py::test_foo[[]0[]]", + "test_group_warnings_by_message.py::test_foo[[]1[]]", + "test_group_warnings_by_message.py::test_foo[[]2[]]", + "test_group_warnings_by_message.py::test_foo[[]3[]]", + "test_group_warnings_by_message.py::test_foo[[]4[]]", + "test_group_warnings_by_message.py::test_foo_1", + " */test_group_warnings_by_message.py:*: UserWarning: foo", + " warnings.warn(UserWarning(msg))", + "", + "test_group_warnings_by_message.py::test_bar[[]0[]]", + "test_group_warnings_by_message.py::test_bar[[]1[]]", + "test_group_warnings_by_message.py::test_bar[[]2[]]", + "test_group_warnings_by_message.py::test_bar[[]3[]]", + "test_group_warnings_by_message.py::test_bar[[]4[]]", + " */test_group_warnings_by_message.py:*: UserWarning: bar", + " warnings.warn(UserWarning(msg))", + "", + "-- Docs: *", + "*= 11 passed, 11 warnings *", + ], + consecutive=True, ) - warning_code = 'warnings.warn(UserWarning("foo"))' - assert warning_code in result.stdout.str() - assert result.stdout.str().count(warning_code) == 1 @pytest.mark.filterwarnings("ignore::pytest.PytestExperimentalApiWarning") @pytest.mark.filterwarnings("always") def test_group_warnings_by_message_summary(testdir): - testdir.copy_example("warnings/test_group_warnings_by_message_summary.py") + testdir.copy_example("warnings/test_group_warnings_by_message_summary") + testdir.syspathinsert() result = testdir.runpytest() result.stdout.fnmatch_lines( [ "*== %s ==*" % WARNINGS_SUMMARY_HEADER, - "test_group_warnings_by_message_summary.py: 120 tests with warnings", - "*test_group_warnings_by_message_summary.py:7: UserWarning: foo", + "test_1.py: 21 warnings", + "test_2.py: 1 warning", + " */test_1.py:7: UserWarning: foo", + " warnings.warn(UserWarning(msg))", + "", + "test_1.py: 20 warnings", + " */test_1.py:7: UserWarning: bar", + " warnings.warn(UserWarning(msg))", + "", + "-- Docs: *", + "*= 42 passed, 42 warnings *", ], consecutive=True, ) - warning_code = 'warnings.warn(UserWarning("foo"))' - assert warning_code in result.stdout.str() - assert result.stdout.str().count(warning_code) == 1 def test_pytest_configure_warning(testdir, recwarn): @@ -624,10 +656,12 @@ class TestStackLevel: @pytest.fixture def capwarn(self, testdir): class CapturedWarnings: - captured = [] + captured: List[ + Tuple[warnings.WarningMessage, Optional[Tuple[str, int, str]]] + ] = ([]) @classmethod - def pytest_warning_captured(cls, warning_message, when, item, location): + def pytest_warning_recorded(cls, warning_message, when, nodeid, location): cls.captured.append((warning_message, location)) testdir.plugins = [CapturedWarnings()] @@ -678,13 +712,25 @@ def test_issue4445_preparse(self, testdir, capwarn): file, _, func = location assert "could not load initial conftests" in str(warning.message) - assert "config{sep}__init__.py".format(sep=os.sep) in file + assert f"config{os.sep}__init__.py" in file assert func == "_preparse" + @pytest.mark.filterwarnings("default") + def test_conftest_warning_captured(self, testdir: Testdir) -> None: + """Warnings raised during importing of conftest.py files is captured (#2891).""" + testdir.makeconftest( + """ + import warnings + warnings.warn(UserWarning("my custom warning")) + """ + ) + result = testdir.runpytest() + result.stdout.fnmatch_lines( + ["conftest.py:2", "*UserWarning: my custom warning*"] + ) + def test_issue4445_import_plugin(self, testdir, capwarn): - """#4445: Make sure the warning points to a reasonable location - See origin of _issue_warning_captured at: _pytest.config.__init__.py:585 - """ + """#4445: Make sure the warning points to a reasonable location""" testdir.makepyfile( some_plugin=""" import pytest @@ -702,33 +748,8 @@ def test_issue4445_import_plugin(self, testdir, capwarn): file, _, func = location assert "skipped plugin 'some_plugin': thing" in str(warning.message) - assert "config{sep}__init__.py".format(sep=os.sep) in file - assert func == "import_plugin" - - def test_issue4445_resultlog(self, testdir, capwarn): - """#4445: Make sure the warning points to a reasonable location - See origin of _issue_warning_captured at: _pytest.resultlog.py:35 - """ - testdir.makepyfile( - """ - def test_dummy(): - pass - """ - ) - # Use parseconfigure() because the warning in resultlog.py is triggered in - # the pytest_configure hook - testdir.parseconfigure( - "--result-log={dir}".format(dir=testdir.tmpdir.join("result.log")) - ) - - # with stacklevel=2 the warning originates from resultlog.pytest_configure - # and is thrown when --result-log is used - warning, location = capwarn.captured.pop() - file, _, func = location - - assert "--result-log is deprecated" in str(warning.message) - assert "resultlog.py" in file - assert func == "pytest_configure" + assert f"config{os.sep}__init__.py" in file + assert func == "_warn_about_skipped_plugins" def test_issue4445_issue5928_mark_generator(self, testdir): """#4445 and #5928: Make sure the warning from an unknown mark points to diff --git a/testing/typing_checks.py b/testing/typing_checks.py new file mode 100644 index 00000000000..0a6b5ad2841 --- /dev/null +++ b/testing/typing_checks.py @@ -0,0 +1,24 @@ +"""File for checking typing issues. + +This file is not executed, it is only checked by mypy to ensure that +none of the code triggers any mypy errors. +""" +import pytest + + +# Issue #7488. +@pytest.mark.xfail(raises=RuntimeError) +def check_mark_xfail_raises() -> None: + pass + + +# Issue #7494. +@pytest.fixture(params=[(0, 0), (1, 1)], ids=lambda x: str(x[0])) +def check_fixture_ids_callable() -> None: + pass + + +# Issue #7494. +@pytest.mark.parametrize("func", [str, int], ids=lambda x: str(x.__name__)) +def check_parametrize_ids_callable(func) -> None: + pass diff --git a/tox.ini b/tox.ini index ee3466bac02..f0cfaa460fb 100644 --- a/tox.ini +++ b/tox.ini @@ -1,18 +1,18 @@ [tox] isolated_build = True -minversion = 3.5.3 +minversion = 3.20.0 distshare = {homedir}/.tox/distshare # make sure to update environment list in travis.yml and appveyor.yml envlist = linting - py35 py36 py37 py38 - pypy + py39 pypy3 - py37-{pexpect,xdist,twisted,numpy,pluggymaster} + py37-{pexpect,xdist,unittestextras,numpy,pluggymaster} doctesting + plugins py37-freeze docs docs-checklinks @@ -44,44 +44,30 @@ setenv = extras = testing deps = doctesting: PyYAML - oldattrs: attrs==17.4.0 - oldattrs: hypothesis<=4.38.1 - numpy: numpy - pexpect: pexpect + numpy: numpy>=1.19.4 + pexpect: pexpect>=4.8.0 pluggymaster: git+https://github.com/pytest-dev/pluggy.git@master - pygments - twisted: twisted - xdist: pytest-xdist>=1.13 + pygments>=2.7.2 + unittestextras: twisted + unittestextras: asynctest + xdist: pytest-xdist>=2.1.0 + xdist: -e . {env:_PYTEST_TOX_EXTRA_DEP:} [testenv:linting] skip_install = True basepython = python3 -deps = pre-commit>=1.11.0 +deps = pre-commit>=2.9.3 commands = pre-commit run --all-files --show-diff-on-failure {posargs:} -[testenv:mypy] -extras = checkqa-mypy, testing -commands = mypy {posargs:src testing} - -[testenv:mypy-diff] -extras = checkqa-mypy, testing -deps = - lxml - diff-cover -commands = - -mypy --cobertura-xml-report {envtmpdir} {posargs:src testing} - diff-cover --fail-under=100 --compare-branch={env:DIFF_BRANCH:origin/{env:GITHUB_BASE_REF:master}} {envtmpdir}/cobertura.xml - [testenv:docs] basepython = python3 usedevelop = True deps = -r{toxinidir}/doc/en/requirements.txt towncrier -whitelist_externals = sh commands = - sh -c 'towncrier --draft > doc/en/_changelog_towncrier_draft.rst' + python scripts/towncrier-draft-to-file.py # the '-t changelog_towncrier_draft' tags makes sphinx include the draft # changelog in the docs; this does not happen on ReadTheDocs because it uses # the standard sphinx command so the 'changelog_towncrier_draft' is never set there @@ -97,8 +83,11 @@ commands = [testenv:regen] changedir = doc/en -skipsdist = True basepython = python3 +passenv = SETUPTOOLS_SCM_PRETEND_VERSION +# TODO: When setuptools-scm 5.0.0 is released, use SETUPTOOLS_SCM_PRETEND_VERSION_FOR_PYTEST +# and remove the next line. +install_command=python -m pip --use-deprecated=legacy-resolver install {opts} {packages} deps = dataclasses PyYAML @@ -114,6 +103,32 @@ commands = rm -rf {envdir}/.pytest_cache make regen +[testenv:plugins] +# use latest versions of all plugins, including pre-releases +pip_pre=true +# use latest pip and new dependency resolver (#7783) +download=true +install_command=python -m pip --use-feature=2020-resolver install {opts} {packages} +changedir = testing/plugins_integration +deps = -rtesting/plugins_integration/requirements.txt +setenv = + PYTHONPATH=. + # due to pytest-rerunfailures requiring 6.2+; can be removed after 6.2.0 + SETUPTOOLS_SCM_PRETEND_VERSION=6.2.0a1 +commands = + pip check + pytest bdd_wallet.py + pytest --cov=. simple_integration.py + pytest --ds=django_settings simple_integration.py + pytest --html=simple.html simple_integration.py + pytest --reruns 5 simple_integration.py + pytest pytest_anyio_integration.py + pytest pytest_asyncio_integration.py + pytest pytest_mock_integration.py + pytest pytest_trio_integration.py + pytest pytest_twisted_integration.py + pytest simple_integration.py --force-sugar --flakes + [testenv:py37-freeze] changedir = testing/freeze deps = @@ -130,7 +145,7 @@ passenv = * deps = colorama github3.py - pre-commit>=1.11.0 + pre-commit>=2.9.3 wheel towncrier commands = python scripts/release.py {posargs} @@ -152,51 +167,19 @@ deps = pypandoc commands = python scripts/publish-gh-release-notes.py {posargs} - -[pytest] -minversion = 2.0 -addopts = -rfEX -p pytester --strict-markers -rsyncdirs = tox.ini doc src testing -python_files = test_*.py *_test.py testing/*/*.py -python_classes = Test Acceptance -python_functions = test -# NOTE: "doc" is not included here, but gets tested explicitly via "doctesting". -testpaths = testing -norecursedirs = testing/example_scripts -xfail_strict=true -filterwarnings = - error - default:Using or importing the ABCs:DeprecationWarning:unittest2.* - default:the imp module is deprecated in favour of importlib:DeprecationWarning:nose.* - ignore:Module already imported so cannot be rewritten:pytest.PytestWarning - # produced by python3.6/site.py itself (3.6.7 on Travis, could not trigger it with 3.6.8). - ignore:.*U.*mode is deprecated:DeprecationWarning:(?!(pytest|_pytest)) - # produced by pytest-xdist - ignore:.*type argument to addoption.*:DeprecationWarning - # produced by python >=3.5 on execnet (pytest-xdist) - ignore:.*inspect.getargspec.*deprecated, use inspect.signature.*:DeprecationWarning - # pytest's own futurewarnings - ignore::pytest.PytestExperimentalApiWarning - # Do not cause SyntaxError for invalid escape sequences in py37. - # Those are caught/handled by pyupgrade, and not easy to filter with the - # module being the filename (with .py removed). - default:invalid escape sequence:DeprecationWarning - # ignore use of unregistered marks, because we use many to test the implementation - ignore::_pytest.warning_types.PytestUnknownMarkWarning -pytester_example_dir = testing/example_scripts -markers = - # dummy markers for testing - foo - bar - baz - # conftest.py reorders tests moving slow ones to the end of the list - slow - # experimental mark for all tests using pexpect - uses_pexpect - [flake8] max-line-length = 120 -extend-ignore = E203 +extend-ignore = + ; whitespace before ':' + E203 + ; Missing Docstrings + D100,D101,D102,D103,D104,D105,D106,D107 + ; Whitespace Issues + D202,D203,D204,D205,D209,D213 + ; Quotes Issues + D302 + ; Docstring Content Issues + D400,D401,D401,D402,D405,D406,D407,D408,D409,D410,D411,D412,D413,D414,D415,D416,D417 [isort] ; This config mimics what reorder-python-imports does.