Merge remote-tracking branch 'origin/master' into issue_4497

This commit is contained in:
Gleb Nikonorov 2020-06-27 19:42:12 -04:00
commit a3b10bad31
177 changed files with 6707 additions and 3755 deletions

View File

@ -7,6 +7,10 @@ Here is a quick checklist that should be present in PRs.
- [ ] Include new tests or update existing tests when applicable. - [ ] 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. - [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: 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 `<ISSUE NUMBER>.<TYPE>.rst`. See [changelog/README.rst](https://github.com/pytest-dev/pytest/blob/master/changelog/README.rst) for details. - [ ] Create a new changelog file in the `changelog` folder, with a name like `<ISSUE NUMBER>.<TYPE>.rst`. See [changelog/README.rst](https://github.com/pytest-dev/pytest/blob/master/changelog/README.rst) for details.

View File

@ -39,7 +39,6 @@ jobs:
"macos-py37", "macos-py37",
"macos-py38", "macos-py38",
"linting",
"docs", "docs",
"doctesting", "doctesting",
] ]
@ -112,10 +111,6 @@ jobs:
tox_env: "py38-xdist" tox_env: "py38-xdist"
use_coverage: true use_coverage: true
- name: "linting"
python: "3.7"
os: ubuntu-latest
tox_env: "linting"
- name: "docs" - name: "docs"
python: "3.7" python: "3.7"
os: ubuntu-latest os: ubuntu-latest
@ -128,8 +123,8 @@ jobs:
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
# For setuptools-scm. with:
- run: git fetch --prune --unshallow fetch-depth: 0
- name: Set up Python ${{ matrix.python }} - name: Set up Python ${{ matrix.python }}
uses: actions/setup-python@v2 uses: actions/setup-python@v2
if: matrix.python != '3.9-dev' if: matrix.python != '3.9-dev'
@ -168,6 +163,20 @@ jobs:
CODECOV_NAME: ${{ matrix.name }} CODECOV_NAME: ${{ matrix.name }}
run: bash scripts/report-coverage.sh -F GHA,${{ runner.os }} 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 "::set-env name=PY::$(python -c 'import hashlib, sys;print(hashlib.sha256(sys.version.encode()+sys.executable.encode()).hexdigest())')"
- uses: actions/cache@v1
with:
path: ~/.cache/pre-commit
key: pre-commit|${{ env.PY }}|${{ hashFiles('.pre-commit-config.yaml') }}
- run: pip install tox
- run: tox -e linting
deploy: deploy:
if: github.event_name == 'push' && startsWith(github.event.ref, 'refs/tags') && github.repository == 'pytest-dev/pytest' if: github.event_name == 'push' && startsWith(github.event.ref, 'refs/tags') && github.repository == 'pytest-dev/pytest'
@ -177,8 +186,8 @@ jobs:
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
# For setuptools-scm. with:
- run: git fetch --prune --unshallow fetch-depth: 0
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v2 uses: actions/setup-python@v2
with: with:

View File

@ -15,8 +15,8 @@ jobs:
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
# For setuptools-scm. with:
- run: git fetch --prune --unshallow fetch-depth: 0
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v2 uses: actions/setup-python@v2

1
.gitignore vendored
View File

@ -29,6 +29,7 @@ doc/*/_changelog_towncrier_draft.rst
build/ build/
dist/ dist/
*.egg-info *.egg-info
htmlcov/
issue/ issue/
env/ env/
.env/ .env/

View File

@ -5,12 +5,12 @@ repos:
- id: black - id: black
args: [--safe, --quiet] args: [--safe, --quiet]
- repo: https://github.com/asottile/blacken-docs - repo: https://github.com/asottile/blacken-docs
rev: v1.6.0 rev: v1.7.0
hooks: hooks:
- id: blacken-docs - id: blacken-docs
additional_dependencies: [black==19.10b0] additional_dependencies: [black==19.10b0]
- repo: https://github.com/pre-commit/pre-commit-hooks - repo: https://github.com/pre-commit/pre-commit-hooks
rev: v2.5.0 rev: v3.1.0
hooks: hooks:
- id: trailing-whitespace - id: trailing-whitespace
- id: end-of-file-fixer - id: end-of-file-fixer
@ -18,26 +18,32 @@ repos:
args: [--remove] args: [--remove]
- id: check-yaml - id: check-yaml
- id: debug-statements - id: debug-statements
exclude: _pytest/debugging.py exclude: _pytest/(debugging|hookspec).py
language_version: python3 language_version: python3
- repo: https://gitlab.com/pycqa/flake8 - repo: https://gitlab.com/pycqa/flake8
rev: 3.8.1 rev: 3.8.2
hooks: hooks:
- id: flake8 - id: flake8
language_version: python3 language_version: python3
additional_dependencies: [flake8-typing-imports==1.9.0] additional_dependencies: [flake8-typing-imports==1.9.0]
- repo: https://github.com/asottile/reorder_python_imports - repo: https://github.com/asottile/reorder_python_imports
rev: v1.4.0 rev: v2.3.0
hooks: hooks:
- id: reorder-python-imports - id: reorder-python-imports
args: ['--application-directories=.:src', --py3-plus] args: ['--application-directories=.:src', --py3-plus]
- repo: https://github.com/asottile/pyupgrade - repo: https://github.com/asottile/pyupgrade
rev: v2.2.1 rev: v2.4.4
hooks: hooks:
- id: pyupgrade - id: pyupgrade
args: [--py3-plus] args: [--py3-plus]
- repo: https://github.com/asottile/setup-cfg-fmt
rev: v1.9.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/mirrors-mypy - repo: https://github.com/pre-commit/mirrors-mypy
rev: v0.770 # NOTE: keep this in sync with setup.py. rev: v0.780 # NOTE: keep this in sync with setup.cfg.
hooks: hooks:
- id: mypy - id: mypy
files: ^(src/|testing/) files: ^(src/|testing/)

View File

@ -109,6 +109,7 @@ Gabriel Reis
Gene Wood Gene Wood
George Kussumoto George Kussumoto
Georgy Dyuldin Georgy Dyuldin
Gleb Nikonorov
Graham Horler Graham Horler
Greg Price Greg Price
Gregory Lee Gregory Lee
@ -226,10 +227,13 @@ Pedro Algarvio
Philipp Loose Philipp Loose
Pieter Mulder Pieter Mulder
Piotr Banaszkiewicz Piotr Banaszkiewicz
Piotr Helm
Prashant Anand
Pulkit Goyal Pulkit Goyal
Punyashloka Biswal Punyashloka Biswal
Quentin Pradet Quentin Pradet
Ralf Schmitt Ralf Schmitt
Ram Rachum
Ralph Giles Ralph Giles
Ran Benita Ran Benita
Raphael Castaneda Raphael Castaneda
@ -243,6 +247,7 @@ Romain Dorgueil
Roman Bolshakov Roman Bolshakov
Ronny Pfannschmidt Ronny Pfannschmidt
Ross Lawley Ross Lawley
Ruaridh Williamson
Russel Winder Russel Winder
Ryan Wooden Ryan Wooden
Samuel Dion-Girardeau Samuel Dion-Girardeau
@ -277,6 +282,7 @@ Tom Dalton
Tom Viner Tom Viner
Tomáš Gavenčiak Tomáš Gavenčiak
Tomer Keren Tomer Keren
Tor Colvin
Trevor Bekolay Trevor Bekolay
Tyler Goodlet Tyler Goodlet
Tzu-ping Chung Tzu-ping Chung

View File

@ -173,8 +173,10 @@ Short version
The test environments above are usually enough to cover most cases locally. The test environments above are usually enough to cover most cases locally.
#. Write a ``changelog`` entry: ``changelog/2574.bugfix.rst``, use issue id number #. Write a ``changelog`` entry: ``changelog/2574.bugfix.rst``, use issue id number
and one of ``bugfix``, ``removal``, ``feature``, ``vendor``, ``doc`` or and one of ``feature``, ``improvement``, ``bugfix``, ``doc``, ``deprecation``,
``trivial`` for the issue type. ``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 #. 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. add yourself to the ``AUTHORS`` file, in alphabetical order.
@ -274,8 +276,9 @@ Here is a simple overview, with pytest-specific bits:
#. Create a new changelog entry in ``changelog``. The file should be named ``<issueid>.<type>.rst``, #. Create a new changelog entry in ``changelog``. The file should be named ``<issueid>.<type>.rst``,
where *issueid* is the number of the issue related to the change and *type* is one of 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 ``feature``, ``improvement``, ``bugfix``, ``doc``, ``deprecation``, ``breaking``, ``vendor``
changelog entry if the change doesn't affect the documented behaviour of Pytest. 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. #. Add yourself to ``AUTHORS`` file if not there yet, in alphabetical order.
@ -289,7 +292,7 @@ Here is a simple overview, with pytest-specific bits:
Writing Tests Writing Tests
---------------------------- ~~~~~~~~~~~~~
Writing tests for plugins or for pytest itself is often done using the `testdir fixture <https://docs.pytest.org/en/latest/reference.html#testdir>`_, as a "black-box" test. Writing tests for plugins or for pytest itself is often done using the `testdir fixture <https://docs.pytest.org/en/latest/reference.html#testdir>`_, as a "black-box" test.
@ -328,6 +331,19 @@ 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``. 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. 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 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 Backporting bug fixes for the next patch release
------------------------------------------------ ------------------------------------------------
@ -359,15 +375,56 @@ actual latest release). The procedure for this is:
* Delete the PR body, it usually contains a duplicate commit message. * Delete the PR body, it usually contains a duplicate commit message.
Joining the Development Team Handling stale issues/PRs
---------------------------- -------------------------
Anyone who has successfully seen through a pull request which did not Stale issues/PRs are those where pytest contributors have asked for questions/changes
require any extra work from the development team to merge will and the authors didn't get around to answer/implement them yet after a somewhat long time, or
themselves gain commit access if they so wish (if we forget to ask please send a friendly the discussion simply died because people seemed to lose interest.
reminder). This does not mean there is any change in your contribution workflow:
everyone goes through the same pull-request-and-review process and There are many reasons why people don't answer questions or implement requested changes:
no-one merges their own pull requests unless already approved. It does however mean you can they might get busy, lose interest, or just forget about it,
participate in the development process more fully since you can merge but the fact is that this is very common in open source software.
pull requests from other contributors yourself after having reviewed
them. 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 <contributor>,
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!
<bye>
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 <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.
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.

View File

@ -0,0 +1 @@
Fix issue where directories from tmpdir are not removed properly when multiple instances of pytest are running in parallel.

View File

@ -0,0 +1,17 @@
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 <https://docs.pytest.org/en/stable/customize.html#configuration-file-formats>`__.

View File

@ -0,0 +1 @@
Rich comparison for dataclasses and `attrs`-classes is now recursive.

View File

@ -0,0 +1,2 @@
Fix a possible race condition when trying to remove lock files used to control access to folders
created by ``tmp_path`` and ``tmpdir``.

View File

@ -0,0 +1,9 @@
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 <https://github.com/pytest-dev/pytest/pull/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.

View File

@ -0,0 +1,4 @@
New command-line flags:
* `--no-header`: disables the initial header, including platform, version, and plugins.
* `--no-summary`: disables the final test summary, including warnings.

View File

@ -0,0 +1,20 @@
``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)

View File

@ -0,0 +1 @@
Support deleting paths longer than 260 characters on windows created inside tmpdir.

View File

@ -0,0 +1,3 @@
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.

View File

@ -0,0 +1 @@
Added `--code-highlight` command line option to enable/disable code highlighting in terminal output.

View File

@ -0,0 +1,2 @@
Exit with an error if the ``--basetemp`` argument is empty, the current working directory or parent directory of it.
This is done to protect against accidental data loss, as any directory passed to this argument is cleared.

View File

@ -0,0 +1 @@
``caplog.set_level()`` will now override any :confval:`log_level` set via the CLI or ``.ini``.

View File

@ -0,0 +1,14 @@
New ``--import-mode=importlib`` option that uses `importlib <https://docs.python.org/3/library/importlib.html>`__ 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 <https://github.com/pytest-dev/pytest/issues/7245>`__.
You can read more about this option in `the documentation <https://docs.pytest.org/en/latest/pythonpath.html#import-modes>`__.

View File

@ -0,0 +1,3 @@
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.

View File

@ -0,0 +1 @@
Replaced ``py.iniconfig`` with `iniconfig <https://pypi.org/project/iniconfig/>`__.

View File

@ -0,0 +1 @@
New ``required_plugins`` configuration option allows the user to specify a list of plugins required for pytest to run. An error is raised if any required plugins are not found when running pytest.

1
changelog/7345.doc.rst Normal file
View File

@ -0,0 +1 @@
Explain indirect parametrization and markers for fixtures

View File

@ -0,0 +1 @@
Version information as defined by `PEP 440 <https://www.python.org/dev/peps/pep-0440/#version-specifiers>`_ may now be included when providing plugins to the ``required_plugins`` configuration option.

View File

@ -0,0 +1 @@
Remove last internal uses of deprecated "slave" term from old pytest-xdist.

View File

@ -0,0 +1 @@
py>=1.8.2 is now required.

View File

@ -0,0 +1,2 @@
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.

View File

@ -0,0 +1 @@
Fixed exception causes all over the codebase, i.e. use `raise new_exception from old_exception` when wrapping an exception.

View File

@ -0,0 +1,13 @@
``--junitxml`` now includes the exception cause in the ``message`` XML attribute for failures during setup and teardown.
Previously:
.. code-block:: xml
<error message="test setup failure">
Now:
.. code-block:: xml
<error message="failed on setup with &quot;ValueError: Some error during setup&quot;">

View File

@ -0,0 +1,2 @@
Remove the `pytest_doctest_prepare_content` hook specification. This hook
hasn't been triggered by pytest for at least 10 years.

View File

@ -1 +1,6 @@
comment: off # reference: https://docs.codecov.io/docs/codecovyml-reference
coverage:
status:
patch: true
project: false
comment: false

View File

@ -1,3 +0,0 @@
*.pyc
*.pyo
.DS_Store

View File

@ -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.

View File

@ -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

View File

@ -1,24 +0,0 @@
{%- extends "basic/layout.html" %}
{%- block extrahead %}
{{ super() }}
{% if theme_touch_icon %}
<link rel="apple-touch-icon" href="{{ pathto('_static/' ~ theme_touch_icon, 1) }}" />
{% endif %}
<meta name="viewport" content="width=device-width, initial-scale=0.9, maximum-scale=0.9">
{% endblock %}
{%- block relbar2 %}{% endblock %}
{% block header %}
{{ super() }}
{% if pagename == 'index' %}
<div class=indexwrapper>
{% endif %}
{% endblock %}
{%- block footer %}
<div class="footer">
&copy; Copyright {{ copyright }}.
Created using <a href="https://www.sphinx-doc.org/">Sphinx</a> {{ sphinx_version }}.
</div>
{% if pagename == 'index' %}
</div>
{% endif %}
{%- endblock %}

View File

@ -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;
}

View File

@ -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 =

View File

@ -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'
}

View File

@ -6,6 +6,7 @@ Release announcements
:maxdepth: 2 :maxdepth: 2
release-5.4.3
release-5.4.2 release-5.4.2
release-5.4.1 release-5.4.1
release-5.4.0 release-5.4.0

View File

@ -46,7 +46,7 @@ Changes between 2.3.4 and 2.3.5
- Issue 265 - integrate nose setup/teardown with setupstate - Issue 265 - integrate nose setup/teardown with setupstate
so it doesn't try to teardown if it did not setup 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 - Issue 274 - don't try to show full doctest example
when doctest does not know the example location when doctest does not know the example location

View File

@ -32,7 +32,7 @@ Changes 2.6.1
purely the nodeid. The line number is still shown in failure reports. purely the nodeid. The line number is still shown in failure reports.
Thanks Floris Bruynooghe. 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. to collect different tests. Thanks Bruno Oliveira.
- fix issue555: add "errors" attribute to capture-streams to satisfy - fix issue555: add "errors" attribute to capture-streams to satisfy

View File

@ -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/latest/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

View File

@ -98,7 +98,7 @@ and if you need to have access to the actual exception info you may use:
f() f()
assert "maximum recursion" in str(excinfo.value) 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 the actual exception raised. The main attributes of interest are
``.type``, ``.value`` and ``.traceback``. ``.type``, ``.value`` and ``.traceback``.

View File

@ -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 If the code under test writes non-textual data, you can capture this using
the ``capsysbinary`` fixture which instead returns ``bytes`` from the ``capsysbinary`` fixture which instead returns ``bytes`` from
the ``readouterr`` method. The ``capfsysbinary`` fixture is currently only the ``readouterr`` method.
available in python 3.

View File

@ -28,6 +28,29 @@ with advance notice in the **Deprecations** section of releases.
.. towncrier release notes start .. towncrier release notes start
pytest 5.4.3 (2020-06-02)
=========================
Bug Fixes
---------
- `#6428 <https://github.com/pytest-dev/pytest/issues/6428>`_: Paths appearing in error messages are now correct in case the current working directory has
changed since the start of the session.
- `#6755 <https://github.com/pytest-dev/pytest/issues/6755>`_: Support deleting paths longer than 260 characters on windows created inside tmpdir.
- `#6956 <https://github.com/pytest-dev/pytest/issues/6956>`_: Prevent pytest from printing ConftestImportFailure traceback to stdout.
- `#7150 <https://github.com/pytest-dev/pytest/issues/7150>`_: Prevent hiding the underlying exception when ``ConfTestImportFailure`` is raised.
- `#7215 <https://github.com/pytest-dev/pytest/issues/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) pytest 5.4.2 (2020-05-08)
========================= =========================
@ -264,7 +287,7 @@ Bug Fixes
- `#6646 <https://github.com/pytest-dev/pytest/issues/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. - `#6646 <https://github.com/pytest-dev/pytest/issues/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 <https://github.com/pytest-dev/pytest/issues/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 <https://github.com/pytest-dev/pytest/issues/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 <https://github.com/pytest-dev/pytest/issues/6752>`_: When :py:func:`pytest.raises` is used as a function (as opposed to a context manager), - `#6752 <https://github.com/pytest-dev/pytest/issues/6752>`_: When :py:func:`pytest.raises` is used as a function (as opposed to a context manager),
@ -376,7 +399,7 @@ Improvements
- `#6231 <https://github.com/pytest-dev/pytest/issues/6231>`_: Improve check for misspelling of :ref:`pytest.mark.parametrize ref`. - `#6231 <https://github.com/pytest-dev/pytest/issues/6231>`_: Improve check for misspelling of :ref:`pytest.mark.parametrize ref`.
- `#6257 <https://github.com/pytest-dev/pytest/issues/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 <https://github.com/pytest-dev/pytest/issues/6257>`_: Handle :py:func:`pytest.exit` being used via :py:func:`~_pytest.hookspec.pytest_internalerror`, e.g. when quitting pdb from post mortem.
@ -6136,7 +6159,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. purely the nodeid. The line number is still shown in failure reports.
Thanks Floris Bruynooghe. 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. to collect different tests. Thanks Bruno Oliveira.
- fix issue555: add "errors" attribute to capture-streams to satisfy - fix issue555: add "errors" attribute to capture-streams to satisfy
@ -6683,7 +6706,7 @@ Bug fixes:
- Issue 265 - integrate nose setup/teardown with setupstate - Issue 265 - integrate nose setup/teardown with setupstate
so it doesn't try to teardown if it did not setup 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 - Issue 274 - don't try to show full doctest example
when doctest does not know the example location when doctest does not know the example location
@ -7565,7 +7588,7 @@ Bug fixes:
- fix assert reinterpreation that sees a call containing "keyword=..." - fix assert reinterpreation that sees a call containing "keyword=..."
- fix issue66: invoke pytest_sessionstart and pytest_sessionfinish - 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. hooks correctly.
- fix issue65: properly handle dist-testing if no - fix issue65: properly handle dist-testing if no

View File

@ -43,6 +43,7 @@ todo_include_todos = 1
# Add any Sphinx extension module names here, as strings. They can be extensions # Add any Sphinx extension module names here, as strings. They can be extensions
# coming with Sphinx (named 'sphinx.ext.*') or your custom ones. # coming with Sphinx (named 'sphinx.ext.*') or your custom ones.
extensions = [ extensions = [
"pallets_sphinx_themes",
"pygments_pytest", "pygments_pytest",
"sphinx.ext.autodoc", "sphinx.ext.autodoc",
"sphinx.ext.autosummary", "sphinx.ext.autosummary",
@ -142,7 +143,7 @@ html_theme = "flask"
# Theme options are theme-specific and customize the look and feel of a theme # 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 # further. For a list of options available for each theme, see the
# documentation. # 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. # Add any paths that contain custom themes here, relative to this directory.
# html_theme_path = [] # html_theme_path = []

View File

@ -14,15 +14,112 @@ configurations files by using the general help option:
This will display command line and configuration file settings This will display command line and configuration file settings
which were registered by installed plugins. which were registered by installed plugins.
.. _rootdir: .. _`config file formats`:
.. _inifiles:
Initialization: determining rootdir and inifile Configuration file formats
----------------------------------------------- --------------------------
Many :ref:`pytest settings <ini options ref>` 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 <https://tox.readthedocs.io>`__ 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 <https://docs.python.org/3/distutils/configfile.html>`__, 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:
.. _configfiles:
Initialization: determining rootdir and configfile
--------------------------------------------------
pytest determines a ``rootdir`` for each test run which depends on pytest determines a ``rootdir`` for each test run which depends on
the command line arguments (specified test files, paths) and 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. printed as part of the pytest header during startup.
Here's a summary what ``pytest`` uses ``rootdir`` for: Here's a summary what ``pytest`` uses ``rootdir`` for:
@ -39,56 +136,56 @@ Here's a summary what ``pytest`` uses ``rootdir`` for:
influence how modules are imported. See :ref:`pythonpath` for more details. 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 ``--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 Note that contrary to other command-line options, ``--rootdir`` cannot be used with
with ``addopts`` in a ``pytest.ini`` file. :confval:`addopts` inside ``pytest.ini`` because the ``rootdir`` is used to *find* ``pytest.ini``
already.
Finding the ``rootdir`` Finding the ``rootdir``
~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~
Here is the algorithm which finds the rootdir from ``args``: 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 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. 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 - 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 ini-file and its directory and upwards. If one is matched, it becomes the ``configfile`` and its
directory becomes the rootdir. 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``. 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 ``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 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 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 Files will only be matched for configuration if:
``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 <https://github.com/pytest-dev/pytest/issues/1435>`_).
A dot ``.`` for referencing to the current working directory is also
possible.
Note that an existing ``pytest.ini`` file will always be considered a match, * ``pytest.ini``: will always match and take precedence, even if empty.
whereas ``tox.ini`` and ``setup.cfg`` will only match if they contain a * ``pyproject.toml``: contains a ``[tool.pytest.ini_options]`` table.
``[pytest]`` or ``[tool:pytest]`` section, respectively. Options from multiple ini-files candidates are never * ``tox.ini``: contains a ``[pytest]`` section.
merged - the first one wins (``pytest.ini`` always wins, even if it does not * ``setup.cfg``: contains a ``[tool:pytest]`` section.
contain a ``[pytest]`` section).
The ``config`` object will subsequently carry these attributes: The files are considered in the order above. Options from multiple ``configfiles`` candidates
are never merged - the first match wins.
The internal :class:`Config <_pytest.config.Config>` object (accessible via hooks or through the :fixture:`pytestconfig` fixture)
will subsequently carry these attributes:
- ``config.rootdir``: the determined root directory, guaranteed to exist. - ``config.rootdir``: the determined root directory, guaranteed to exist.
- ``config.inifile``: the determined ini-file, may be ``None``. - ``config.inifile``: the determined ``configfile``, may be ``None`` (it is named ``inifile``
for historical reasons).
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 addresses ("nodeids") and can be used also by plugins for storing
per-testrun information. per-testrun information.
@ -99,75 +196,38 @@ Example:
pytest path/to/testdir path/other/ pytest path/to/testdir path/other/
will determine the common ancestor as ``path`` and then will determine the common ancestor as ``path`` and then
check for ini-files as follows: check for configuration files as follows:
.. code-block:: text .. code-block:: text
# first look for pytest.ini files # first look for pytest.ini files
path/pytest.ini path/pytest.ini
path/tox.ini # must also contain [pytest] section to match path/pyproject.toml # must contain a [tool.pytest.ini_options] table to match
path/setup.cfg # must also contain [tool:pytest] section to match path/tox.ini # must contain [pytest] section to match
path/setup.cfg # must contain [tool:pytest] section to match
pytest.ini pytest.ini
... # all the way down to the root ... # all the way up to the root
# now look for setup.py # now look for setup.py
path/setup.py path/setup.py
setup.py setup.py
... # all the way down to the root ... # all the way up to the root
.. 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 <https://github.com/pytest-dev/pytest/issues/1435>`_).
A dot ``.`` for referencing to the current working directory is also
possible.
.. _`how to change command line options defaults`: .. _`how to change command line options defaults`:
.. _`adding default options`: .. _`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.ini:addopts> $PYTEST_ADDOPTS <extra command-line arguments>
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``.
Builtin configuration file options Builtin configuration file options
---------------------------------------------- ----------------------------------------------

View File

@ -20,6 +20,17 @@ Below is a complete list of all pytest features which are considered deprecated.
:ref:`standard warning filters <warnings>`. :ref:`standard warning filters <warnings>`.
The ``pytest_warning_captured`` hook
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
.. deprecated:: 6.0
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._fillfuncargs`` function The ``pytest._fillfuncargs`` function
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

View File

@ -639,7 +639,7 @@ Automatically adding markers based on test names
.. regendoc:wipe .. 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 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 markers so that you can use the ``-m`` option with it. Let's look
at this test module: at this test module:

View File

@ -351,6 +351,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. 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 .. regendoc:wipe
Apply indirect on particular arguments Apply indirect on particular arguments
@ -482,11 +506,10 @@ Running it results in some skips if we don't have all the python interpreters in
.. code-block:: pytest .. code-block:: pytest
. $ pytest -rs -q multipython.py . $ pytest -rs -q multipython.py
ssssssssssss...ssssssssssss [100%] ssssssssssss......sss...... [100%]
========================= short test summary info ========================== ========================= short test summary info ==========================
SKIPPED [12] $REGENDOC_TMPDIR/CWD/multipython.py:29: 'python3.5' not found SKIPPED [15] $REGENDOC_TMPDIR/CWD/multipython.py:29: 'python3.5' not found
SKIPPED [12] $REGENDOC_TMPDIR/CWD/multipython.py:29: 'python3.7' not found 12 passed, 15 skipped in 0.12s
3 passed, 24 skipped in 0.12s
Indirect parametrization of optional implementations/imports Indirect parametrization of optional implementations/imports
-------------------------------------------------------------------- --------------------------------------------------------------------

View File

@ -115,15 +115,13 @@ Changing naming conventions
You can configure different naming conventions by setting You can configure different naming conventions by setting
the :confval:`python_files`, :confval:`python_classes` and the :confval:`python_files`, :confval:`python_classes` and
:confval:`python_functions` configuration options. :confval:`python_functions` in your :ref:`configuration file <config file formats>`.
Here is an example: Here is an example:
.. code-block:: ini .. code-block:: ini
# content of pytest.ini # content of pytest.ini
# Example 1: have pytest look for "check" instead of "test" # 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] [pytest]
python_files = check_*.py python_files = check_*.py
python_classes = Check python_classes = Check
@ -165,8 +163,7 @@ You can check for multiple glob patterns by adding a space between the patterns:
.. code-block:: ini .. code-block:: ini
# Example 2: have pytest look for files with "test" and "example" # Example 2: have pytest look for files with "test" and "example"
# content of pytest.ini, tox.ini, or setup.cfg file (replace "pytest" # content of pytest.ini
# with "tool:pytest" for setup.cfg)
[pytest] [pytest]
python_files = test_*.py example_*.py python_files = test_*.py example_*.py

View File

@ -3,6 +3,50 @@
Basic patterns and examples 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.ini:addopts> $PYTEST_ADDOPTS <extra command-line arguments>
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: .. _request example:
Pass different values to a test function, depending on command line options Pass different values to a test function, depending on command line options

View File

@ -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 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: the exact protocol used by ``pytest`` to call the test function this way:
1. pytest :ref:`finds <test discovery>` the ``test_ehlo`` because 1. pytest :ref:`finds <test discovery>` the test ``test_ehlo`` because
of the ``test_`` prefix. The test function needs a function argument of the ``test_`` prefix. The test function needs a function argument
named ``smtp_connection``. A matching fixture function is discovered by named ``smtp_connection``. A matching fixture function is discovered by
looking for a fixture-marked function named ``smtp_connection``. looking for a fixture-marked function named ``smtp_connection``.
@ -665,6 +665,37 @@ Running it:
voila! The ``smtp_connection`` fixture function picked up our mail server name voila! The ``smtp_connection`` fixture function picked up our mail server name
from the module namespace. from the module namespace.
.. _`using-markers`:
Using markers to pass data to fixtures
-------------------------------------------------------------
Using the :py:class:`request <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`: .. _`fixture-factory`:
Factories as fixtures Factories as fixtures
@ -828,7 +859,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 also identify the specific case when one is failing. Running pytest
with ``--collect-only`` will show the generated IDs. 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 representation used in the test ID. For other objects, pytest will
make a string based on the argument name. It is possible to customise 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 the string used in a test ID for a certain fixture value by using the
@ -867,7 +898,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 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 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 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: Running the above tests results in the following test IDs being used:

View File

@ -170,7 +170,7 @@ several problems:
1. in distributed testing the master process would setup test resources 1. in distributed testing the master process would setup test resources
that are never needed because it only co-ordinates the test run 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") 2. if you only perform a collection (with "--collect-only")
resource-setup will still be executed. resource-setup will still be executed.

View File

@ -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 See :ref:`pytest vs python -m pytest` for more information about the difference between calling ``pytest`` and
``python -m pytest``. ``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 <import-modes>`
(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 ``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 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 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 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 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. ``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, 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. 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 In this situation, it is **strongly** suggested to use a ``src`` layout where application root package resides in a
sub-directory of your root: 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 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ș <https://blog.ionelmc.ro/2014/05/25/python-packaging/#the-structure>`_. `blog post by Ionel Cristian Mărieș <https://blog.ionelmc.ro/2014/05/25/python-packaging/#the-structure>`_.
.. 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 Tests as part of application code
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
@ -190,8 +203,8 @@ Note that this layout also works in conjunction with the ``src`` layout mentione
.. note:: .. note::
If ``pytest`` finds an "a/b/test_module.py" test file while In ``prepend`` and ``append`` import-modes, if pytest finds a ``"a/b/test_module.py"``
recursing into the filesystem it determines the import name test file while recursing into the filesystem it determines the import name
as follows: as follows:
* determine ``basedir``: this is the first "upward" (towards the root) * 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 from each other and thus deriving a canonical import name helps
to avoid surprises such as a test module getting imported twice. 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/ .. _`virtualenv`: https://pypi.org/project/virtualenv/
.. _`buildout`: http://www.buildout.org/ .. _`buildout`: http://www.buildout.org/

View File

@ -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 * 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. 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 <live_logs>` is now disabled by default and can be enabled setting the * :ref:`Live Logs <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 :confval:`log_cli` configuration option to ``true``. When enabled, the verbosity is increased so logging for each
test is visible. test is visible.

View File

@ -4,14 +4,19 @@ Marking test functions with attributes
====================================== ======================================
By using the ``pytest.mark`` helper you can easily set By using the ``pytest.mark`` helper you can easily set
metadata on your test functions. There are metadata on your test functions. You can find the full list of builtin markers
some builtin markers, for example: in the :ref:`API Reference<marks ref>`. 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 <usefixtures>` - use fixtures on a test function or class
* :ref:`filterwarnings <filterwarnings>` - filter certain warnings of a test function
* :ref:`skip <skip>` - always skip a test function * :ref:`skip <skip>` - always skip a test function
* :ref:`skipif <skipif>` - skip a test function if a certain condition is met * :ref:`skipif <skipif>` - skip a test function if a certain condition is met
* :ref:`xfail <xfail>` - produce an "expected failure" outcome if a certain * :ref:`xfail <xfail>` - produce an "expected failure" outcome if a certain
condition is met condition is met
* :ref:`parametrize <parametrizemark>` to perform multiple calls * :ref:`parametrize <parametrizemark>` - perform multiple calls
to the same test function. to the same test function.
It's easy to create custom markers or to apply markers It's easy to create custom markers or to apply markers

View File

@ -133,7 +133,7 @@ Let's run this:
======================= 2 passed, 1 xfailed in 0.12s ======================= ======================= 2 passed, 1 xfailed in 0.12s =======================
The one parameter set which caused a failure previously now 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 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 example, if they're dynamically generated by some function - the behaviour of

View File

@ -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. 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 <https://legacy.python.org/dev/peps/pep-0373/#id4>`__, with Python 2.7 EOL has been reached `in 2020 <https://legacy.python.org/dev/peps/pep-0373/#id4>`__, 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 <https://www.python.org/dev/peps/pep-0429/#release-schedule>`__, with the last release made in March, 2019. Python 3.4 EOL has been reached `in 2019 <https://www.python.org/dev/peps/pep-0429/#release-schedule>`__, with the last release made in March, 2019.

View File

@ -3,11 +3,65 @@
pytest import mechanisms and ``sys.path``/``PYTHONPATH`` pytest import mechanisms and ``sys.path``/``PYTHONPATH``
======================================================== ========================================================
Here's a list of scenarios where pytest may need to change ``sys.path`` in order .. _`import-modes`:
to import test modules or ``conftest.py`` files.
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 <https://docs.python.org/3/library/sys.html#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__ <https://docs.python.org/3/library/functions.html#__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 <src-layout>` 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 <https://docs.python.org/3/library/importlib.html>`__ 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 Test modules / ``conftest.py`` files inside packages
---------------------------------------------------- ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Consider this file and directory layout:: Consider this file and directory layout::
@ -28,8 +82,6 @@ When executing:
pytest root/ pytest root/
pytest will find ``foo/bar/tests/test_foo.py`` and realize it is part of a package given that 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 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 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`. :ref:`test discovery`.
Standalone test modules / ``conftest.py`` files Standalone test modules / ``conftest.py`` files
----------------------------------------------- ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Consider this file and directory layout:: Consider this file and directory layout::

View File

@ -15,41 +15,41 @@ Functions
pytest.approx pytest.approx
~~~~~~~~~~~~~ ~~~~~~~~~~~~~
.. autofunction:: _pytest.python_api.approx .. autofunction:: pytest.approx
pytest.fail pytest.fail
~~~~~~~~~~~ ~~~~~~~~~~~
**Tutorial**: :ref:`skipping` **Tutorial**: :ref:`skipping`
.. autofunction:: _pytest.outcomes.fail .. autofunction:: pytest.fail
pytest.skip pytest.skip
~~~~~~~~~~~ ~~~~~~~~~~~
.. autofunction:: _pytest.outcomes.skip(msg, [allow_module_level=False]) .. autofunction:: pytest.skip(msg, [allow_module_level=False])
.. _`pytest.importorskip ref`: .. _`pytest.importorskip ref`:
pytest.importorskip pytest.importorskip
~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~
.. autofunction:: _pytest.outcomes.importorskip .. autofunction:: pytest.importorskip
pytest.xfail pytest.xfail
~~~~~~~~~~~~ ~~~~~~~~~~~~
.. autofunction:: _pytest.outcomes.xfail .. autofunction:: pytest.xfail
pytest.exit pytest.exit
~~~~~~~~~~~ ~~~~~~~~~~~
.. autofunction:: _pytest.outcomes.exit .. autofunction:: pytest.exit
pytest.main pytest.main
~~~~~~~~~~~ ~~~~~~~~~~~
.. autofunction:: _pytest.config.main .. autofunction:: pytest.main
pytest.param pytest.param
~~~~~~~~~~~~ ~~~~~~~~~~~~
@ -644,31 +644,6 @@ Initialization hooks called for plugins and ``conftest.py`` files.
.. autofunction:: pytest_plugin_registered .. 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 Collection hooks
~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~
@ -694,6 +669,28 @@ items, delete or otherwise amend the test items:
.. autofunction:: pytest_collection_finish .. autofunction:: pytest_collection_finish
Test running (runtest) 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.
.. autofunction:: pytest_pyfunc_call
Reporting hooks Reporting hooks
~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~
@ -762,6 +759,14 @@ Collector
:members: :members:
:show-inheritance: :show-inheritance:
CollectReport
~~~~~~~~~~~~~
.. autoclass:: _pytest.reports.CollectReport()
:members:
:show-inheritance:
:inherited-members:
Config Config
~~~~~~ ~~~~~~
@ -881,7 +886,7 @@ Session
TestReport TestReport
~~~~~~~~~~ ~~~~~~~~~~
.. autoclass:: _pytest.runner.TestReport() .. autoclass:: _pytest.reports.TestReport()
:members: :members:
:show-inheritance: :show-inheritance:
:inherited-members: :inherited-members:
@ -1019,17 +1024,17 @@ UsageError
Configuration Options Configuration Options
--------------------- ---------------------
Here is a list of builtin configuration options that may be written in a ``pytest.ini``, ``tox.ini`` or ``setup.cfg`` 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. All options must be under a ``[pytest]`` section file, usually located at the root of your repository. To see each file format in details, see
(``[tool:pytest]`` for ``setup.cfg`` files). :ref:`config file formats`.
.. warning:: .. 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 files use a different parser than ``pytest.ini`` and ``tox.ini`` which might cause hard to track
down problems. 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-ini``, 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:: passed multiple times. The expected format is ``name=value``. For example::
pytest -o console_output_style=classic -o cache_dir=/tmp/mycache pytest -o console_output_style=classic -o cache_dir=/tmp/mycache
@ -1057,8 +1062,6 @@ passed multiple times. The expected format is ``name=value``. For example::
.. confval:: cache_dir .. confval:: cache_dir
Sets a directory where stores content of cache plugin. Default directory is Sets a directory where stores content of cache plugin. Default directory is
``.pytest_cache`` which is created in :ref:`rootdir <rootdir>`. Directory may be ``.pytest_cache`` which is created in :ref:`rootdir <rootdir>`. Directory may be
relative or absolute path. If setting relative path, then directory is created relative or absolute path. If setting relative path, then directory is created
@ -1561,6 +1564,19 @@ passed multiple times. The expected format is ``name=value``. For example::
See :ref:`change naming conventions` for more detailed examples. 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 .. confval:: testpaths

View File

@ -1,4 +1,5 @@
pallets-sphinx-themes
pygments-pytest>=1.1.0 pygments-pytest>=1.1.0
sphinx-removed-in>=0.2.0
sphinx>=1.8.2,<2.1 sphinx>=1.8.2,<2.1
sphinxcontrib-trio sphinxcontrib-trio
sphinx-removed-in>=0.2.0

View File

@ -2,6 +2,11 @@
Talks and Tutorials Talks and Tutorials
========================== ==========================
.. sidebar:: Next Open Trainings
- `Free 1h webinar: "pytest: Test Driven Development für Python" <https://mylearning.ch/kurse/online-kurse/tech-webinar/>`_ (German), online, August 18 2020.
- `"pytest: Test Driven Development (nicht nur) für Python" <https://workshoptage.ch/workshops/2020/pytest-test-driven-development-nicht-nur-fuer-python/>`_ (German) at the `CH Open Workshoptage <https://workshoptage.ch/>`_, September 8 2020, HSLU Campus Rotkreuz (ZG), Switzerland.
.. _`funcargs`: funcargs.html .. _`funcargs`: funcargs.html
Books Books

View File

@ -7,6 +7,49 @@ requires = [
] ]
build-backend = "setuptools.build_meta" build-backend = "setuptools.build_meta"
[tool.pytest.ini_options]
minversion = "2.0"
addopts = "-rfEX -p pytester --strict-markers"
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",
]
[tool.towncrier] [tool.towncrier]
package = "pytest" package = "pytest"
package_dir = "src" package_dir = "src"

View File

@ -2,35 +2,35 @@
name = pytest name = pytest
description = pytest: simple powerful testing with Python description = pytest: simple powerful testing with Python
long_description = file: README.rst long_description = file: README.rst
long_description_content_type = text/x-rst
url = https://docs.pytest.org/en/latest/ 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 author = Holger Krekel, Bruno Oliveira, Ronny Pfannschmidt, Floris Bruynooghe, Brianna Laugher, Florian Bruhin and others
license = MIT
license = MIT license license_file = LICENSE
keywords = test, unittest platforms = unix, linux, osx, cygwin, win32
classifiers = classifiers =
Development Status :: 6 - Mature Development Status :: 6 - Mature
Intended Audience :: Developers Intended Audience :: Developers
License :: OSI Approved :: MIT License License :: OSI Approved :: MIT License
Operating System :: POSIX
Operating System :: Microsoft :: Windows
Operating System :: MacOS :: MacOS X Operating System :: MacOS :: MacOS X
Topic :: Software Development :: Testing Operating System :: Microsoft :: Windows
Topic :: Software Development :: Libraries Operating System :: POSIX
Topic :: Utilities Programming Language :: Python :: 3
Programming Language :: Python :: 3 :: Only Programming Language :: Python :: 3 :: Only
Programming Language :: Python :: 3.5 Programming Language :: Python :: 3.5
Programming Language :: Python :: 3.6 Programming Language :: Python :: 3.6
Programming Language :: Python :: 3.7 Programming Language :: Python :: 3.7
Programming Language :: Python :: 3.8 Programming Language :: Python :: 3.8
Programming Language :: Python :: 3.9 Programming Language :: Python :: 3.9
platforms = unix, linux, osx, cygwin, win32 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] [options]
zip_safe = no
packages = packages =
_pytest _pytest
_pytest._code _pytest._code
@ -39,13 +39,41 @@ packages =
_pytest.config _pytest.config
_pytest.mark _pytest.mark
pytest pytest
install_requires =
attrs>=17.4.0
iniconfig
more-itertools>=4.0.0
packaging
pluggy>=0.12,<1.0
py>=1.8.2
toml
atomicwrites>=1.0;sys_platform=="win32"
colorama;sys_platform=="win32"
importlib-metadata>=0.12;python_version<"3.8"
pathlib2>=2.2.0;python_version<"3.6"
python_requires = >=3.5 python_requires = >=3.5
package_dir =
=src
setup_requires =
setuptools>=40.0
setuptools-scm
zip_safe = no
[options.entry_points] [options.entry_points]
console_scripts = console_scripts =
pytest=pytest:console_main pytest=pytest:console_main
py.test=pytest:console_main py.test=pytest:console_main
[options.extras_require]
checkqa-mypy =
mypy==0.780
testing =
argcomplete
hypothesis>=3.56
mock
nose
requests
xmlschema
[build_sphinx] [build_sphinx]
source-dir = doc/en/ source-dir = doc/en/
@ -57,13 +85,14 @@ upload-dir = doc/en/build/html
[check-manifest] [check-manifest]
ignore = ignore =
src/_pytest/_version.py src/_pytest/_version.py
[devpi:upload] [devpi:upload]
formats = sdist.tgz,bdist_wheel formats = sdist.tgz,bdist_wheel
[mypy] [mypy]
mypy_path = src mypy_path = src
check_untyped_defs = True
ignore_missing_imports = True ignore_missing_imports = True
no_implicit_optional = True no_implicit_optional = True
show_error_codes = True show_error_codes = True

View File

@ -1,40 +1,8 @@
from setuptools import setup 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"',
]
def main(): def main():
setup( setup(use_scm_version={"write_to": "src/_pytest/_version.py"})
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.770", # keep this in sync with .pre-commit-config.yaml.
],
},
install_requires=INSTALL_REQUIRES,
)
if __name__ == "__main__": if __name__ == "__main__":

View File

@ -6,6 +6,7 @@ from .code import Frame
from .code import getfslineno from .code import getfslineno
from .code import getrawcode from .code import getrawcode
from .code import Traceback from .code import Traceback
from .code import TracebackEntry
from .source import compile_ as compile from .source import compile_ as compile
from .source import Source from .source import Source
@ -17,6 +18,7 @@ __all__ = [
"getfslineno", "getfslineno",
"getrawcode", "getrawcode",
"Traceback", "Traceback",
"TracebackEntry",
"compile", "compile",
"Source", "Source",
] ]

View File

@ -15,6 +15,7 @@ from typing import Dict
from typing import Generic from typing import Generic
from typing import Iterable from typing import Iterable
from typing import List from typing import List
from typing import Mapping
from typing import Optional from typing import Optional
from typing import Pattern from typing import Pattern
from typing import Sequence from typing import Sequence
@ -46,7 +47,7 @@ if TYPE_CHECKING:
from typing_extensions import Literal from typing_extensions import Literal
from weakref import ReferenceType from weakref import ReferenceType
_TracebackStyle = Literal["long", "short", "line", "no", "native", "value"] _TracebackStyle = Literal["long", "short", "line", "no", "native", "value", "auto"]
class Code: class Code:
@ -212,7 +213,7 @@ class TracebackEntry:
return source.getstatement(self.lineno) return source.getstatement(self.lineno)
@property @property
def path(self): def path(self) -> Union[py.path.local, str]:
""" path to the source code """ """ path to the source code """
return self.frame.code.path return self.frame.code.path
@ -728,7 +729,7 @@ class FormattedExcinfo:
failindent = indentstr failindent = indentstr
return lines 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: if self.showlocals:
lines = [] lines = []
keys = [loc for loc in locals if loc[0] != "@"] keys = [loc for loc in locals if loc[0] != "@"]
@ -927,8 +928,13 @@ class TerminalRepr:
raise NotImplementedError() raise NotImplementedError()
# This class is abstract -- only subclasses are instantiated.
@attr.s(**{ATTRS_EQ_FIELD: False}) # type: ignore @attr.s(**{ATTRS_EQ_FIELD: False}) # type: ignore
class ExceptionRepr(TerminalRepr): class ExceptionRepr(TerminalRepr):
# Provided by in subclasses.
reprcrash = None # type: Optional[ReprFileLocation]
reprtraceback = None # type: ReprTraceback
def __attrs_post_init__(self): def __attrs_post_init__(self):
self.sections = [] # type: List[Tuple[str, str, str]] self.sections = [] # type: List[Tuple[str, str, str]]
@ -1198,7 +1204,10 @@ _PY_DIR = py.path.local(py.__file__).dirpath()
def filter_traceback(entry: TracebackEntry) -> bool: 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); * dynamically generated code (no code to show up for it);
* internal traceback from pytest or its internal libraries, py and pluggy. * internal traceback from pytest or its internal libraries, py and pluggy.
""" """

View File

@ -215,7 +215,7 @@ class Source:
newex.offset = ex.offset newex.offset = ex.offset
newex.lineno = ex.lineno newex.lineno = ex.lineno
newex.text = ex.text newex.text = ex.text
raise newex raise newex from ex
else: else:
if flag & ast.PyCF_ONLY_AST: if flag & ast.PyCF_ONLY_AST:
assert isinstance(co, ast.AST) assert isinstance(co, ast.AST)

View File

@ -1,9 +1,12 @@
import pprint import pprint
import reprlib import reprlib
from typing import Any 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: try:
return repr(obj) return repr(obj)
except (KeyboardInterrupt, SystemExit): except (KeyboardInterrupt, SystemExit):
@ -12,7 +15,7 @@ def _try_repr_or_str(obj):
return '{}("{}")'.format(type(obj).__name__, 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: try:
exc_info = _try_repr_or_str(exc) exc_info = _try_repr_or_str(exc)
except (KeyboardInterrupt, SystemExit): except (KeyboardInterrupt, SystemExit):
@ -42,7 +45,7 @@ class SafeRepr(reprlib.Repr):
self.maxstring = maxsize self.maxstring = maxsize
self.maxsize = maxsize self.maxsize = maxsize
def repr(self, x: Any) -> str: def repr(self, x: object) -> str:
try: try:
s = super().repr(x) s = super().repr(x)
except (KeyboardInterrupt, SystemExit): except (KeyboardInterrupt, SystemExit):
@ -51,7 +54,7 @@ class SafeRepr(reprlib.Repr):
s = _format_repr_exception(exc, x) s = _format_repr_exception(exc, x)
return _ellipsize(s, self.maxsize) return _ellipsize(s, self.maxsize)
def repr_instance(self, x: Any, level: int) -> str: def repr_instance(self, x: object, level: int) -> str:
try: try:
s = repr(x) s = repr(x)
except (KeyboardInterrupt, SystemExit): except (KeyboardInterrupt, SystemExit):
@ -61,7 +64,7 @@ class SafeRepr(reprlib.Repr):
return _ellipsize(s, self.maxsize) return _ellipsize(s, self.maxsize)
def safeformat(obj: Any) -> str: def safeformat(obj: object) -> str:
"""return a pretty printed string for the given object. """return a pretty printed string for the given object.
Failing __repr__ functions of user instances will be represented Failing __repr__ functions of user instances will be represented
with a short exception info. with a short exception info.
@ -72,7 +75,7 @@ def safeformat(obj: Any) -> str:
return _format_repr_exception(exc, obj) return _format_repr_exception(exc, obj)
def saferepr(obj: Any, maxsize: int = 240) -> str: def saferepr(obj: object, maxsize: int = 240) -> str:
"""return a size-limited safe repr-string for the given object. """return a size-limited safe repr-string for the given object.
Failing __repr__ functions of user instances will be represented Failing __repr__ functions of user instances will be represented
with a short exception info and 'saferepr' generally takes with a short exception info and 'saferepr' generally takes
@ -85,19 +88,39 @@ def saferepr(obj: Any, maxsize: int = 240) -> str:
class AlwaysDispatchingPrettyPrinter(pprint.PrettyPrinter): class AlwaysDispatchingPrettyPrinter(pprint.PrettyPrinter):
"""PrettyPrinter that always dispatches (regardless of width).""" """PrettyPrinter that always dispatches (regardless of width)."""
def _format(self, object, stream, indent, allowance, context, level): def _format(
p = self._dispatch.get(type(object).__repr__, None) 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] # noqa: F821
objid = id(object) objid = id(object)
if objid in context or p is None: 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] # noqa: F821
object, stream, indent, allowance, context, level,
)
return
context[objid] = 1 context[objid] = 1
p(self, object, stream, indent, allowance, context, level + 1) p(self, object, stream, indent, allowance, context, level + 1)
del context[objid] 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( return AlwaysDispatchingPrettyPrinter(
indent=indent, width=width, depth=depth, compact=compact indent=indent, width=width, depth=depth, compact=compact
).pformat(object) ).pformat(object)

View File

@ -74,6 +74,7 @@ class TerminalWriter:
self.hasmarkup = should_do_markup(file) self.hasmarkup = should_do_markup(file)
self._current_line = "" self._current_line = ""
self._terminal_width = None # type: Optional[int] self._terminal_width = None # type: Optional[int]
self.code_highlight = True
@property @property
def fullwidth(self) -> int: def fullwidth(self) -> int:
@ -180,7 +181,7 @@ class TerminalWriter:
def _highlight(self, source: str) -> str: def _highlight(self, source: str) -> str:
"""Highlight the given source code if we have markup support.""" """Highlight the given source code if we have markup support."""
if not self.hasmarkup: if not self.hasmarkup or not self.code_highlight:
return source return source
try: try:
from pygments.formatters.terminal import TerminalFormatter from pygments.formatters.terminal import TerminalFormatter

View File

@ -3,6 +3,7 @@ support for presenting detailed information in failing assertions.
""" """
import sys import sys
from typing import Any from typing import Any
from typing import Generator
from typing import List from typing import List
from typing import Optional from typing import Optional
@ -13,12 +14,14 @@ from _pytest.assertion.rewrite import assertstate_key
from _pytest.compat import TYPE_CHECKING from _pytest.compat import TYPE_CHECKING
from _pytest.config import Config from _pytest.config import Config
from _pytest.config import hookimpl from _pytest.config import hookimpl
from _pytest.config.argparsing import Parser
from _pytest.nodes import Item
if TYPE_CHECKING: if TYPE_CHECKING:
from _pytest.main import Session from _pytest.main import Session
def pytest_addoption(parser): def pytest_addoption(parser: Parser) -> None:
group = parser.getgroup("debugconfig") group = parser.getgroup("debugconfig")
group.addoption( group.addoption(
"--assert", "--assert",
@ -43,7 +46,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. """Register one or more module names to be rewritten on import.
This function will make sure that this module or all modules inside This function will make sure that this module or all modules inside
@ -72,27 +75,27 @@ def register_assert_rewrite(*names) -> None:
class DummyRewriteHook: class DummyRewriteHook:
"""A no-op import hook for when rewriting is disabled.""" """A no-op import hook for when rewriting is disabled."""
def mark_rewrite(self, *names): def mark_rewrite(self, *names: str) -> None:
pass pass
class AssertionState: class AssertionState:
"""State for the assertion plugin.""" """State for the assertion plugin."""
def __init__(self, config, mode): def __init__(self, config: Config, mode) -> None:
self.mode = mode self.mode = mode
self.trace = config.trace.root.get("assertion") self.trace = config.trace.root.get("assertion")
self.hook = None # type: Optional[rewrite.AssertionRewritingHook] self.hook = None # type: Optional[rewrite.AssertionRewritingHook]
def install_importhook(config): def install_importhook(config: Config) -> rewrite.AssertionRewritingHook:
"""Try to install the rewrite hook, raise SystemError if it fails.""" """Try to install the rewrite hook, raise SystemError if it fails."""
config._store[assertstate_key] = AssertionState(config, "rewrite") config._store[assertstate_key] = AssertionState(config, "rewrite")
config._store[assertstate_key].hook = hook = rewrite.AssertionRewritingHook(config) config._store[assertstate_key].hook = hook = rewrite.AssertionRewritingHook(config)
sys.meta_path.insert(0, hook) sys.meta_path.insert(0, hook)
config._store[assertstate_key].trace("installed rewrite import hook") config._store[assertstate_key].trace("installed rewrite import hook")
def undo(): def undo() -> None:
hook = config._store[assertstate_key].hook hook = config._store[assertstate_key].hook
if hook is not None and hook in sys.meta_path: if hook is not None and hook in sys.meta_path:
sys.meta_path.remove(hook) sys.meta_path.remove(hook)
@ -112,7 +115,7 @@ def pytest_collection(session: "Session") -> None:
@hookimpl(tryfirst=True, hookwrapper=True) @hookimpl(tryfirst=True, hookwrapper=True)
def pytest_runtest_protocol(item): def pytest_runtest_protocol(item: Item) -> Generator[None, None, None]:
"""Setup the pytest_assertrepr_compare and pytest_assertion_pass hooks """Setup the pytest_assertrepr_compare and pytest_assertion_pass hooks
The rewrite module will use util._reprcompare if The rewrite module will use util._reprcompare if
@ -121,8 +124,7 @@ def pytest_runtest_protocol(item):
comparison for the test. comparison for the test.
""" """
def callbinrepr(op, left, right): def callbinrepr(op, left: object, right: object) -> Optional[str]:
# type: (str, object, object) -> Optional[str]
"""Call the pytest_assertrepr_compare hook and prepare the result """Call the pytest_assertrepr_compare hook and prepare the result
This uses the first result from the hook and then ensures the This uses the first result from the hook and then ensures the
@ -155,7 +157,7 @@ def pytest_runtest_protocol(item):
if item.ihook.pytest_assertion_pass.get_hookimpls(): if item.ihook.pytest_assertion_pass.get_hookimpls():
def call_assertion_pass_hook(lineno, orig, expl): def call_assertion_pass_hook(lineno: int, orig: str, expl: str) -> None:
item.ihook.pytest_assertion_pass( item.ihook.pytest_assertion_pass(
item=item, lineno=lineno, orig=orig, expl=expl item=item, lineno=lineno, orig=orig, expl=expl
) )
@ -167,7 +169,7 @@ def pytest_runtest_protocol(item):
util._reprcompare, util._assertion_pass = saved_assert_hooks 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) assertstate = session.config._store.get(assertstate_key, None)
if assertstate: if assertstate:
if assertstate.hook is not None: if assertstate.hook is not None:

View File

@ -13,11 +13,17 @@ import struct
import sys import sys
import tokenize import tokenize
import types import types
from typing import Callable
from typing import Dict from typing import Dict
from typing import IO
from typing import List from typing import List
from typing import Optional from typing import Optional
from typing import Sequence
from typing import Set from typing import Set
from typing import Tuple from typing import Tuple
from typing import Union
import py
from _pytest._io.saferepr import saferepr from _pytest._io.saferepr import saferepr
from _pytest._version import version from _pytest._version import version
@ -27,6 +33,8 @@ from _pytest.assertion.util import ( # noqa: F401
) )
from _pytest.compat import fspath from _pytest.compat import fspath
from _pytest.compat import TYPE_CHECKING 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 fnmatch_ex
from _pytest.pathlib import Path from _pytest.pathlib import Path
from _pytest.pathlib import PurePath from _pytest.pathlib import PurePath
@ -48,13 +56,13 @@ PYC_TAIL = "." + PYTEST_TAG + PYC_EXT
class AssertionRewritingHook(importlib.abc.MetaPathFinder, importlib.abc.Loader): class AssertionRewritingHook(importlib.abc.MetaPathFinder, importlib.abc.Loader):
"""PEP302/PEP451 import hook which rewrites asserts.""" """PEP302/PEP451 import hook which rewrites asserts."""
def __init__(self, config): def __init__(self, config: Config) -> None:
self.config = config self.config = config
try: try:
self.fnpats = config.getini("python_files") self.fnpats = config.getini("python_files")
except ValueError: except ValueError:
self.fnpats = ["test_*.py", "*_test.py"] self.fnpats = ["test_*.py", "*_test.py"]
self.session = None self.session = None # type: Optional[Session]
self._rewritten_names = set() # type: Set[str] self._rewritten_names = set() # type: Set[str]
self._must_rewrite = set() # type: Set[str] self._must_rewrite = set() # type: Set[str]
# flag to guard against trying to rewrite a pyc file while we are already writing another pyc file, # flag to guard against trying to rewrite a pyc file while we are already writing another pyc file,
@ -64,14 +72,19 @@ class AssertionRewritingHook(importlib.abc.MetaPathFinder, importlib.abc.Loader)
self._marked_for_rewrite_cache = {} # type: Dict[str, bool] self._marked_for_rewrite_cache = {} # type: Dict[str, bool]
self._session_paths_checked = False self._session_paths_checked = False
def set_session(self, session): def set_session(self, session: Optional[Session]) -> None:
self.session = session self.session = session
self._session_paths_checked = False self._session_paths_checked = False
# Indirection so we can mock calls to find_spec originated from the hook during testing # Indirection so we can mock calls to find_spec originated from the hook during testing
_find_spec = importlib.machinery.PathFinder.find_spec _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: if self._writing_pyc:
return None return None
state = self.config._store[assertstate_key] state = self.config._store[assertstate_key]
@ -79,7 +92,8 @@ class AssertionRewritingHook(importlib.abc.MetaPathFinder, importlib.abc.Loader)
return None return None
state.trace("find_module called for: %s" % name) 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 ( if (
# the import machinery could not find a file to import # the import machinery could not find a file to import
spec is None spec is None
@ -108,10 +122,14 @@ class AssertionRewritingHook(importlib.abc.MetaPathFinder, importlib.abc.Loader)
submodule_search_locations=spec.submodule_search_locations, 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 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) fn = Path(module.__spec__.origin)
state = self.config._store[assertstate_key] state = self.config._store[assertstate_key]
@ -151,7 +169,7 @@ class AssertionRewritingHook(importlib.abc.MetaPathFinder, importlib.abc.Loader)
state.trace("found cached rewritten pyc for {}".format(fn)) state.trace("found cached rewritten pyc for {}".format(fn))
exec(co, module.__dict__) exec(co, module.__dict__)
def _early_rewrite_bailout(self, name, state): def _early_rewrite_bailout(self, name: str, state: "AssertionState") -> bool:
"""This is a fast way to get out of rewriting modules. """This is a fast way to get out of rewriting modules.
Profiling has shown that the call to PathFinder.find_spec (inside of Profiling has shown that the call to PathFinder.find_spec (inside of
@ -161,10 +179,10 @@ class AssertionRewritingHook(importlib.abc.MetaPathFinder, importlib.abc.Loader)
""" """
if self.session is not None and not self._session_paths_checked: if self.session is not None and not self._session_paths_checked:
self._session_paths_checked = True 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 -> # Make something as c:/projects/my_project/path.py ->
# ['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. # add 'path' to basenames to be checked.
self._basenames_to_check_rewrite.add(os.path.splitext(parts[-1])[0]) self._basenames_to_check_rewrite.add(os.path.splitext(parts[-1])[0])
@ -190,14 +208,14 @@ class AssertionRewritingHook(importlib.abc.MetaPathFinder, importlib.abc.Loader)
state.trace("early skip of rewriting module: {}".format(name)) state.trace("early skip of rewriting module: {}".format(name))
return True return True
def _should_rewrite(self, name, fn, state): def _should_rewrite(self, name: str, fn: str, state: "AssertionState") -> bool:
# always rewrite conftest files # always rewrite conftest files
if os.path.basename(fn) == "conftest.py": if os.path.basename(fn) == "conftest.py":
state.trace("rewriting conftest file: {!r}".format(fn)) state.trace("rewriting conftest file: {!r}".format(fn))
return True return True
if self.session is not None: if self.session is not None:
if self.session.isinitpath(fn): if self.session.isinitpath(py.path.local(fn)):
state.trace( state.trace(
"matched test file (was specified on cmdline): {!r}".format(fn) "matched test file (was specified on cmdline): {!r}".format(fn)
) )
@ -213,7 +231,7 @@ class AssertionRewritingHook(importlib.abc.MetaPathFinder, importlib.abc.Loader)
return self._is_marked_for_rewrite(name, state) 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: try:
return self._marked_for_rewrite_cache[name] return self._marked_for_rewrite_cache[name]
except KeyError: except KeyError:
@ -246,7 +264,7 @@ class AssertionRewritingHook(importlib.abc.MetaPathFinder, importlib.abc.Loader)
self._must_rewrite.update(names) self._must_rewrite.update(names)
self._marked_for_rewrite_cache.clear() 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.warning_types import PytestAssertRewriteWarning
from _pytest.warnings import _issue_warning_captured from _pytest.warnings import _issue_warning_captured
@ -258,13 +276,15 @@ class AssertionRewritingHook(importlib.abc.MetaPathFinder, importlib.abc.Loader)
stacklevel=5, stacklevel=5,
) )
def get_data(self, pathname): def get_data(self, pathname: Union[str, bytes]) -> bytes:
"""Optional PEP302 get_data API.""" """Optional PEP302 get_data API."""
with open(pathname, "rb") as f: with open(pathname, "rb") as f:
return f.read() 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 # Technically, we don't have to have the same pyc format as
# (C)Python, since these "pycs" should never be seen by builtin # (C)Python, since these "pycs" should never be seen by builtin
# import. However, there's little reason deviate. # import. However, there's little reason deviate.
@ -280,7 +300,12 @@ def _write_pyc_fp(fp, source_stat, co):
if sys.platform == "win32": if sys.platform == "win32":
from atomicwrites import atomic_write from atomicwrites import atomic_write
def _write_pyc(state, co, source_stat, pyc): def _write_pyc(
state: "AssertionState",
co: types.CodeType,
source_stat: os.stat_result,
pyc: Path,
) -> bool:
try: try:
with atomic_write(fspath(pyc), mode="wb", overwrite=True) as fp: with atomic_write(fspath(pyc), mode="wb", overwrite=True) as fp:
_write_pyc_fp(fp, source_stat, co) _write_pyc_fp(fp, source_stat, co)
@ -295,7 +320,12 @@ if sys.platform == "win32":
else: else:
def _write_pyc(state, co, source_stat, pyc): def _write_pyc(
state: "AssertionState",
co: types.CodeType,
source_stat: os.stat_result,
pyc: Path,
) -> bool:
proc_pyc = "{}.{}".format(pyc, os.getpid()) proc_pyc = "{}.{}".format(pyc, os.getpid())
try: try:
fp = open(proc_pyc, "wb") fp = open(proc_pyc, "wb")
@ -319,19 +349,21 @@ else:
return True return True
def _rewrite_test(fn, config): def _rewrite_test(fn: Path, config: Config) -> Tuple[os.stat_result, types.CodeType]:
"""read and rewrite *fn* and return the code object.""" """read and rewrite *fn* and return the code object."""
fn = fspath(fn) fn_ = fspath(fn)
stat = os.stat(fn) stat = os.stat(fn_)
with open(fn, "rb") as f: with open(fn_, "rb") as f:
source = f.read() source = f.read()
tree = ast.parse(source, filename=fn) tree = ast.parse(source, filename=fn_)
rewrite_asserts(tree, source, fn, config) rewrite_asserts(tree, source, fn_, config)
co = compile(tree, fn, "exec", dont_inherit=True) co = compile(tree, fn_, "exec", dont_inherit=True)
return stat, co 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. """Possibly read a pytest pyc containing rewritten code.
Return rewritten code if successful or None if not. Return rewritten code if successful or None if not.
@ -368,12 +400,17 @@ def _read_pyc(source, pyc, trace=lambda x: None):
return co return co
def rewrite_asserts(mod, source, module_path=None, config=None): def rewrite_asserts(
mod: ast.Module,
source: bytes,
module_path: Optional[str] = None,
config: Optional[Config] = None,
) -> None:
"""Rewrite the assert statements in mod.""" """Rewrite the assert statements in mod."""
AssertionRewriter(module_path, config, source).run(mod) AssertionRewriter(module_path, config, source).run(mod)
def _saferepr(obj): def _saferepr(obj: object) -> str:
"""Get a safe repr of an object for assertion error messages. """Get a safe repr of an object for assertion error messages.
The assertion formatting (util.format_explanation()) requires The assertion formatting (util.format_explanation()) requires
@ -387,7 +424,7 @@ def _saferepr(obj):
return saferepr(obj).replace("\n", "\\n") return saferepr(obj).replace("\n", "\\n")
def _format_assertmsg(obj): def _format_assertmsg(obj: object) -> str:
"""Format the custom assertion message given. """Format the custom assertion message given.
For strings this simply replaces newlines with '\n~' so that For strings this simply replaces newlines with '\n~' so that
@ -410,7 +447,7 @@ def _format_assertmsg(obj):
return obj return obj
def _should_repr_global_name(obj): def _should_repr_global_name(obj: object) -> bool:
if callable(obj): if callable(obj):
return False return False
@ -420,7 +457,7 @@ def _should_repr_global_name(obj):
return True return True
def _format_boolop(explanations, is_or): def _format_boolop(explanations, is_or: bool):
explanation = "(" + (is_or and " or " or " and ").join(explanations) + ")" explanation = "(" + (is_or and " or " or " and ").join(explanations) + ")"
if isinstance(explanation, str): if isinstance(explanation, str):
return explanation.replace("%", "%%") return explanation.replace("%", "%%")
@ -428,8 +465,12 @@ def _format_boolop(explanations, is_or):
return explanation.replace(b"%", b"%%") return explanation.replace(b"%", b"%%")
def _call_reprcompare(ops, results, expls, each_obj): def _call_reprcompare(
# type: (Tuple[str, ...], Tuple[bool, ...], Tuple[str, ...], Tuple[object, ...]) -> str 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): for i, res, expl in zip(range(len(ops)), results, expls):
try: try:
done = not res done = not res
@ -444,14 +485,12 @@ def _call_reprcompare(ops, results, expls, each_obj):
return expl return expl
def _call_assertion_pass(lineno, orig, expl): def _call_assertion_pass(lineno: int, orig: str, expl: str) -> None:
# type: (int, str, str) -> None
if util._assertion_pass is not None: if util._assertion_pass is not None:
util._assertion_pass(lineno, orig, expl) util._assertion_pass(lineno, orig, expl)
def _check_if_assertion_pass_impl(): def _check_if_assertion_pass_impl() -> bool:
# type: () -> bool
"""Checks if any plugins implement the pytest_assertion_pass hook """Checks if any plugins implement the pytest_assertion_pass hook
in order not to generate explanation unecessarily (might be expensive)""" in order not to generate explanation unecessarily (might be expensive)"""
return True if util._assertion_pass else False return True if util._assertion_pass else False
@ -609,7 +648,9 @@ class AssertionRewriter(ast.NodeVisitor):
""" """
def __init__(self, module_path, config, source): def __init__(
self, module_path: Optional[str], config: Optional[Config], source: bytes
) -> None:
super().__init__() super().__init__()
self.module_path = module_path self.module_path = module_path
self.config = config self.config = config
@ -622,7 +663,7 @@ class AssertionRewriter(ast.NodeVisitor):
self.source = source self.source = source
@functools.lru_cache(maxsize=1) @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) return _get_assertion_exprs(self.source)
def run(self, mod: ast.Module) -> None: def run(self, mod: ast.Module) -> None:
@ -691,38 +732,38 @@ class AssertionRewriter(ast.NodeVisitor):
nodes.append(field) nodes.append(field)
@staticmethod @staticmethod
def is_rewrite_disabled(docstring): def is_rewrite_disabled(docstring: str) -> bool:
return "PYTEST_DONT_REWRITE" in docstring return "PYTEST_DONT_REWRITE" in docstring
def variable(self): def variable(self) -> str:
"""Get a new variable.""" """Get a new variable."""
# Use a character invalid in python identifiers to avoid clashing. # Use a character invalid in python identifiers to avoid clashing.
name = "@py_assert" + str(next(self.variable_counter)) name = "@py_assert" + str(next(self.variable_counter))
self.variables.append(name) self.variables.append(name)
return name return name
def assign(self, expr): def assign(self, expr: ast.expr) -> ast.Name:
"""Give *expr* a name.""" """Give *expr* a name."""
name = self.variable() name = self.variable()
self.statements.append(ast.Assign([ast.Name(name, ast.Store())], expr)) self.statements.append(ast.Assign([ast.Name(name, ast.Store())], expr))
return ast.Name(name, ast.Load()) return ast.Name(name, ast.Load())
def display(self, expr): def display(self, expr: ast.expr) -> ast.expr:
"""Call saferepr on the expression.""" """Call saferepr on the expression."""
return self.helper("_saferepr", expr) 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.""" """Call a helper in this module."""
py_name = ast.Name("@pytest_ar", ast.Load()) py_name = ast.Name("@pytest_ar", ast.Load())
attr = ast.Attribute(py_name, name, ast.Load()) attr = ast.Attribute(py_name, name, ast.Load())
return ast.Call(attr, list(args), []) return ast.Call(attr, list(args), [])
def builtin(self, name): def builtin(self, name: str) -> ast.Attribute:
"""Return the builtin called *name*.""" """Return the builtin called *name*."""
builtin_name = ast.Name("@py_builtins", ast.Load()) builtin_name = ast.Name("@py_builtins", ast.Load())
return ast.Attribute(builtin_name, name, 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. """Return a new named %-formatting placeholder for expr.
This creates a %-formatting placeholder for expr in the This creates a %-formatting placeholder for expr in the
@ -735,7 +776,7 @@ class AssertionRewriter(ast.NodeVisitor):
self.explanation_specifiers[specifier] = expr self.explanation_specifiers[specifier] = expr
return "%(" + specifier + ")s" return "%(" + specifier + ")s"
def push_format_context(self): def push_format_context(self) -> None:
"""Create a new formatting context. """Create a new formatting context.
The format context is used for when an explanation wants to The format context is used for when an explanation wants to
@ -749,10 +790,10 @@ class AssertionRewriter(ast.NodeVisitor):
self.explanation_specifiers = {} # type: Dict[str, ast.expr] self.explanation_specifiers = {} # type: Dict[str, ast.expr]
self.stack.append(self.explanation_specifiers) 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. """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 the %-placeholders created by .explanation_param(). This will
add the required code to format said string to .expl_stmts and add the required code to format said string to .expl_stmts and
return the ast.Name instance of the formatted string. return the ast.Name instance of the formatted string.
@ -770,13 +811,13 @@ class AssertionRewriter(ast.NodeVisitor):
self.expl_stmts.append(ast.Assign([ast.Name(name, ast.Store())], form)) self.expl_stmts.append(ast.Assign([ast.Name(name, ast.Store())], form))
return ast.Name(name, ast.Load()) 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.""" """Handle expressions we don't have custom code for."""
assert isinstance(node, ast.expr) assert isinstance(node, ast.expr)
res = self.assign(node) res = self.assign(node)
return res, self.explanation_param(self.display(res)) 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. """Return the AST statements to replace the ast.Assert instance.
This rewrites the test of an assertion to provide This rewrites the test of an assertion to provide
@ -789,6 +830,8 @@ class AssertionRewriter(ast.NodeVisitor):
from _pytest.warning_types import PytestAssertRewriteWarning from _pytest.warning_types import PytestAssertRewriteWarning
import warnings import warnings
# TODO: This assert should not be needed.
assert self.module_path is not None
warnings.warn_explicit( warnings.warn_explicit(
PytestAssertRewriteWarning( PytestAssertRewriteWarning(
"assertion is always true, perhaps remove parentheses?" "assertion is always true, perhaps remove parentheses?"
@ -891,7 +934,7 @@ class AssertionRewriter(ast.NodeVisitor):
set_location(stmt, assert_.lineno, assert_.col_offset) set_location(stmt, assert_.lineno, assert_.col_offset)
return self.statements 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 # Display the repr of the name if it's a local variable or
# _should_repr_global_name() thinks it's acceptable. # _should_repr_global_name() thinks it's acceptable.
locs = ast.Call(self.builtin("locals"), [], []) locs = ast.Call(self.builtin("locals"), [], [])
@ -901,7 +944,7 @@ class AssertionRewriter(ast.NodeVisitor):
expr = ast.IfExp(test, self.display(name), ast.Str(name.id)) expr = ast.IfExp(test, self.display(name), ast.Str(name.id))
return name, self.explanation_param(expr) 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() res_var = self.variable()
expl_list = self.assign(ast.List([], ast.Load())) expl_list = self.assign(ast.List([], ast.Load()))
app = ast.Attribute(expl_list, "append", ast.Load()) app = ast.Attribute(expl_list, "append", ast.Load())
@ -936,13 +979,13 @@ class AssertionRewriter(ast.NodeVisitor):
expl = self.pop_format_context(expl_template) expl = self.pop_format_context(expl_template)
return ast.Name(res_var, ast.Load()), self.explanation_param(expl) 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__] pattern = UNARY_MAP[unary.op.__class__]
operand_res, operand_expl = self.visit(unary.operand) operand_res, operand_expl = self.visit(unary.operand)
res = self.assign(ast.UnaryOp(unary.op, operand_res)) res = self.assign(ast.UnaryOp(unary.op, operand_res))
return res, pattern % (operand_expl,) 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__] symbol = BINOP_MAP[binop.op.__class__]
left_expr, left_expl = self.visit(binop.left) left_expr, left_expl = self.visit(binop.left)
right_expr, right_expl = self.visit(binop.right) right_expr, right_expl = self.visit(binop.right)
@ -950,7 +993,7 @@ class AssertionRewriter(ast.NodeVisitor):
res = self.assign(ast.BinOp(left_expr, binop.op, right_expr)) res = self.assign(ast.BinOp(left_expr, binop.op, right_expr))
return res, explanation return res, explanation
def visit_Call(self, call): def visit_Call(self, call: ast.Call) -> Tuple[ast.Name, str]:
""" """
visit `ast.Call` nodes visit `ast.Call` nodes
""" """
@ -977,13 +1020,13 @@ class AssertionRewriter(ast.NodeVisitor):
outer_expl = "{}\n{{{} = {}\n}}".format(res_expl, res_expl, expl) outer_expl = "{}\n{{{} = {}\n}}".format(res_expl, res_expl, expl)
return res, outer_expl return res, outer_expl
def visit_Starred(self, starred): def visit_Starred(self, starred: ast.Starred) -> Tuple[ast.Starred, str]:
# From Python 3.5, a Starred node can appear in a function call # From Python 3.5, a Starred node can appear in a function call
res, expl = self.visit(starred.value) res, expl = self.visit(starred.value)
new_starred = ast.Starred(res, starred.ctx) new_starred = ast.Starred(res, starred.ctx)
return new_starred, "*" + expl 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): if not isinstance(attr.ctx, ast.Load):
return self.generic_visit(attr) return self.generic_visit(attr)
value, value_expl = self.visit(attr.value) value, value_expl = self.visit(attr.value)
@ -993,7 +1036,7 @@ class AssertionRewriter(ast.NodeVisitor):
expl = pat % (res_expl, res_expl, value_expl, attr.attr) expl = pat % (res_expl, res_expl, value_expl, attr.attr)
return res, expl 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() self.push_format_context()
left_res, left_expl = self.visit(comp.left) left_res, left_expl = self.visit(comp.left)
if isinstance(comp.left, (ast.Compare, ast.BoolOp)): if isinstance(comp.left, (ast.Compare, ast.BoolOp)):
@ -1032,7 +1075,7 @@ class AssertionRewriter(ast.NodeVisitor):
return res, self.explanation_param(self.pop_format_context(expl_call)) return res, self.explanation_param(self.pop_format_context(expl_call))
def try_makedirs(cache_dir) -> bool: def try_makedirs(cache_dir: Path) -> bool:
"""Attempts to create the given directory and sub-directories exist, returns True if """Attempts to create the given directory and sub-directories exist, returns True if
successful or it already exists""" successful or it already exists"""
try: try:

View File

@ -5,13 +5,20 @@ Current default behaviour is to truncate assertion explanations at
~8 terminal lines, unless running in "-vv" mode or running on CI. ~8 terminal lines, unless running in "-vv" mode or running on CI.
""" """
import os import os
from typing import List
from typing import Optional
from _pytest.nodes import Item
DEFAULT_MAX_LINES = 8 DEFAULT_MAX_LINES = 8
DEFAULT_MAX_CHARS = 8 * 80 DEFAULT_MAX_CHARS = 8 * 80
USAGE_MSG = "use '-vv' to show" USAGE_MSG = "use '-vv' to show"
def truncate_if_required(explanation, item, max_length=None): 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. Truncate this assertion explanation if the given test item is eligible.
""" """
@ -20,7 +27,7 @@ def truncate_if_required(explanation, item, max_length=None):
return explanation return explanation
def _should_truncate_item(item): def _should_truncate_item(item: Item) -> bool:
""" """
Whether or not this test item is eligible for truncation. Whether or not this test item is eligible for truncation.
""" """
@ -28,13 +35,17 @@ def _should_truncate_item(item):
return verbose < 2 and not _running_on_ci() 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.""" """Check if we're currently running on a CI system."""
env_vars = ["CI", "BUILD_NUMBER"] env_vars = ["CI", "BUILD_NUMBER"]
return any(var in os.environ for var in env_vars) return any(var in os.environ for var in env_vars)
def _truncate_explanation(input_lines, max_lines=None, max_chars=None): 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. Truncate given list of strings that makes up the assertion explanation.
@ -73,7 +84,7 @@ def _truncate_explanation(input_lines, max_lines=None, max_chars=None):
return truncated_explanation 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 # Check if truncation required
if len("".join(input_lines)) <= max_chars: if len("".join(input_lines)) <= max_chars:
return input_lines return input_lines

View File

@ -148,26 +148,7 @@ def assertrepr_compare(config, op: str, left: Any, right: Any) -> Optional[List[
explanation = None explanation = None
try: try:
if op == "==": if op == "==":
if istext(left) and istext(right): explanation = _compare_eq_any(left, right, verbose)
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
elif op == "not in": elif op == "not in":
if istext(left) and istext(right): if istext(left) and istext(right):
explanation = _notin_text(left, right, verbose) explanation = _notin_text(left, right, verbose)
@ -187,6 +168,28 @@ def assertrepr_compare(config, op: str, left: Any, right: Any) -> Optional[List[
return [summary] + explanation return [summary] + explanation
def _compare_eq_any(left: Any, right: Any, verbose: int = 0) -> List[str]:
explanation = [] # type: List[str]
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)
explanation.extend(expl)
return explanation
def _diff_text(left: str, right: str, verbose: int = 0) -> List[str]: def _diff_text(left: str, right: str, verbose: int = 0) -> List[str]:
"""Return the explanation for the diff between text. """Return the explanation for the diff between text.
@ -439,7 +442,10 @@ def _compare_eq_cls(
explanation += ["Differing attributes:"] explanation += ["Differing attributes:"]
for field in diff: for field in diff:
explanation += [ explanation += [
("%s: %r != %r") % (field, getattr(left, field), getattr(right, field)) ("%s: %r != %r") % (field, getattr(left, field), getattr(right, field)),
"",
"Drill down into differing attribute %s:" % field,
*_compare_eq_any(getattr(left, field), getattr(right, field), verbose),
] ]
return explanation return explanation

View File

@ -8,9 +8,11 @@ import json
import os import os
from typing import Dict from typing import Dict
from typing import Generator from typing import Generator
from typing import Iterable
from typing import List from typing import List
from typing import Optional from typing import Optional
from typing import Set from typing import Set
from typing import Union
import attr import attr
import py import py
@ -24,8 +26,13 @@ from _pytest import nodes
from _pytest._io import TerminalWriter from _pytest._io import TerminalWriter
from _pytest.compat import order_preserving_dict from _pytest.compat import order_preserving_dict
from _pytest.config import Config from _pytest.config import Config
from _pytest.config import ExitCode
from _pytest.config.argparsing import Parser
from _pytest.fixtures import FixtureRequest
from _pytest.main import Session from _pytest.main import Session
from _pytest.python import Module from _pytest.python import Module
from _pytest.reports import TestReport
README_CONTENT = """\ README_CONTENT = """\
# pytest cache directory # # pytest cache directory #
@ -48,8 +55,8 @@ Signature: 8a477f597d28d172789f06886806bc55
@attr.s @attr.s
class Cache: class Cache:
_cachedir = attr.ib(repr=False) _cachedir = attr.ib(type=Path, repr=False)
_config = attr.ib(repr=False) _config = attr.ib(type=Config, repr=False)
# sub-directory under cache-dir for directories created by "makedir" # sub-directory under cache-dir for directories created by "makedir"
_CACHE_PREFIX_DIRS = "d" _CACHE_PREFIX_DIRS = "d"
@ -58,14 +65,14 @@ class Cache:
_CACHE_PREFIX_VALUES = "v" _CACHE_PREFIX_VALUES = "v"
@classmethod @classmethod
def for_config(cls, config): def for_config(cls, config: Config) -> "Cache":
cachedir = cls.cache_dir_from_config(config) cachedir = cls.cache_dir_from_config(config)
if config.getoption("cacheclear") and cachedir.is_dir(): if config.getoption("cacheclear") and cachedir.is_dir():
cls.clear_cache(cachedir) cls.clear_cache(cachedir)
return cls(cachedir, config) return cls(cachedir, config)
@classmethod @classmethod
def clear_cache(cls, cachedir: Path): def clear_cache(cls, cachedir: Path) -> None:
"""Clears the sub-directories used to hold cached directories and values.""" """Clears the sub-directories used to hold cached directories and values."""
for prefix in (cls._CACHE_PREFIX_DIRS, cls._CACHE_PREFIX_VALUES): for prefix in (cls._CACHE_PREFIX_DIRS, cls._CACHE_PREFIX_VALUES):
d = cachedir / prefix d = cachedir / prefix
@ -73,10 +80,10 @@ class Cache:
rm_rf(d) rm_rf(d)
@staticmethod @staticmethod
def cache_dir_from_config(config): def cache_dir_from_config(config: Config):
return resolve_from_str(config.getini("cache_dir"), config.rootdir) return resolve_from_str(config.getini("cache_dir"), config.rootdir)
def warn(self, fmt, **args): def warn(self, fmt: str, **args: object) -> None:
import warnings import warnings
from _pytest.warning_types import PytestCacheWarning from _pytest.warning_types import PytestCacheWarning
@ -86,7 +93,7 @@ class Cache:
stacklevel=3, stacklevel=3,
) )
def makedir(self, name): def makedir(self, name: str) -> py.path.local:
""" return a directory path object with the given name. If the """ return a directory path object with the given name. If the
directory does not yet exist, it will be created. You can use it directory does not yet exist, it will be created. You can use it
to manage files likes e. g. store/retrieve database to manage files likes e. g. store/retrieve database
@ -96,14 +103,14 @@ class Cache:
Make sure the name contains your plugin or application Make sure the name contains your plugin or application
identifiers to prevent clashes with other cache users. identifiers to prevent clashes with other cache users.
""" """
name = Path(name) path = Path(name)
if len(name.parts) > 1: if len(path.parts) > 1:
raise ValueError("name is not allowed to contain path separators") 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) res.mkdir(exist_ok=True, parents=True)
return py.path.local(res) 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)) return self._cachedir.joinpath(self._CACHE_PREFIX_VALUES, Path(key))
def get(self, key, default): def get(self, key, default):
@ -124,7 +131,7 @@ class Cache:
except (ValueError, OSError): except (ValueError, OSError):
return default return default
def set(self, key, value): def set(self, key, value) -> None:
""" save value for the given key. """ save value for the given key.
:param key: must be a ``/`` separated value. Usually the first :param key: must be a ``/`` separated value. Usually the first
@ -154,7 +161,7 @@ class Cache:
with f: with f:
f.write(data) 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.""" """Create supporting files in the cache dir that are not really part of the cache."""
readme_path = self._cachedir / "README.md" readme_path = self._cachedir / "README.md"
readme_path.write_text(README_CONTENT) readme_path.write_text(README_CONTENT)
@ -168,12 +175,12 @@ class Cache:
class LFPluginCollWrapper: class LFPluginCollWrapper:
def __init__(self, lfplugin: "LFPlugin"): def __init__(self, lfplugin: "LFPlugin") -> None:
self.lfplugin = lfplugin self.lfplugin = lfplugin
self._collected_at_least_one_failure = False self._collected_at_least_one_failure = False
@pytest.hookimpl(hookwrapper=True) @pytest.hookimpl(hookwrapper=True)
def pytest_make_collect_report(self, collector) -> Generator: def pytest_make_collect_report(self, collector: nodes.Collector) -> Generator:
if isinstance(collector, Session): if isinstance(collector, Session):
out = yield out = yield
res = out.get_result() # type: CollectReport res = out.get_result() # type: CollectReport
@ -216,11 +223,13 @@ class LFPluginCollWrapper:
class LFPluginCollSkipfiles: class LFPluginCollSkipfiles:
def __init__(self, lfplugin: "LFPlugin"): def __init__(self, lfplugin: "LFPlugin") -> None:
self.lfplugin = lfplugin self.lfplugin = lfplugin
@pytest.hookimpl @pytest.hookimpl
def pytest_make_collect_report(self, collector) -> Optional[CollectReport]: def pytest_make_collect_report(
self, collector: nodes.Collector
) -> Optional[CollectReport]:
if isinstance(collector, Module): if isinstance(collector, Module):
if Path(str(collector.fspath)) not in self.lfplugin._last_failed_paths: if Path(str(collector.fspath)) not in self.lfplugin._last_failed_paths:
self.lfplugin._skipped_files += 1 self.lfplugin._skipped_files += 1
@ -258,17 +267,18 @@ class LFPlugin:
result = {rootpath / nodeid.split("::")[0] for nodeid in self.lastfailed} result = {rootpath / nodeid.split("::")[0] for nodeid in self.lastfailed}
return {x for x in result if x.exists()} 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: if self.active and self.config.getoption("verbose") >= 0:
return "run-last-failure: %s" % self._report_status 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: if (report.when == "call" and report.passed) or report.skipped:
self.lastfailed.pop(report.nodeid, None) self.lastfailed.pop(report.nodeid, None)
elif report.failed: elif report.failed:
self.lastfailed[report.nodeid] = True self.lastfailed[report.nodeid] = True
def pytest_collectreport(self, report): def pytest_collectreport(self, report: CollectReport) -> None:
passed = report.outcome in ("passed", "skipped") passed = report.outcome in ("passed", "skipped")
if passed: if passed:
if report.nodeid in self.lastfailed: if report.nodeid in self.lastfailed:
@ -329,11 +339,12 @@ class LFPlugin:
else: else:
self._report_status += "not deselecting items." self._report_status += "not deselecting items."
def pytest_sessionfinish(self, session): def pytest_sessionfinish(self, session: Session) -> None:
config = self.config config = self.config
if config.getoption("cacheshow") or hasattr(config, "slaveinput"): if config.getoption("cacheshow") or hasattr(config, "workerinput"):
return return
assert config.cache is not None
saved_lastfailed = config.cache.get("cache/lastfailed", {}) saved_lastfailed = config.cache.get("cache/lastfailed", {})
if saved_lastfailed != self.lastfailed: if saved_lastfailed != self.lastfailed:
config.cache.set("cache/lastfailed", self.lastfailed) config.cache.set("cache/lastfailed", self.lastfailed)
@ -342,9 +353,10 @@ class LFPlugin:
class NFPlugin: 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.config = config
self.active = config.option.newfirst self.active = config.option.newfirst
assert config.cache is not None
self.cached_nodeids = set(config.cache.get("cache/nodeids", [])) self.cached_nodeids = set(config.cache.get("cache/nodeids", []))
@pytest.hookimpl(hookwrapper=True, tryfirst=True) @pytest.hookimpl(hookwrapper=True, tryfirst=True)
@ -369,20 +381,22 @@ class NFPlugin:
else: else:
self.cached_nodeids.update(item.nodeid for item in items) self.cached_nodeids.update(item.nodeid for item in items)
def _get_increasing_order(self, items): def _get_increasing_order(self, items: Iterable[nodes.Item]) -> List[nodes.Item]:
return sorted(items, key=lambda item: item.fspath.mtime(), reverse=True) return sorted(items, key=lambda item: item.fspath.mtime(), reverse=True)
def pytest_sessionfinish(self) -> None: def pytest_sessionfinish(self) -> None:
config = self.config config = self.config
if config.getoption("cacheshow") or hasattr(config, "slaveinput"): if config.getoption("cacheshow") or hasattr(config, "workerinput"):
return return
if config.getoption("collectonly"): if config.getoption("collectonly"):
return return
assert config.cache is not None
config.cache.set("cache/nodeids", sorted(self.cached_nodeids)) config.cache.set("cache/nodeids", sorted(self.cached_nodeids))
def pytest_addoption(parser): def pytest_addoption(parser: Parser) -> None:
group = parser.getgroup("general") group = parser.getgroup("general")
group.addoption( group.addoption(
"--lf", "--lf",
@ -440,11 +454,12 @@ def pytest_addoption(parser):
) )
def pytest_cmdline_main(config): def pytest_cmdline_main(config: Config) -> Optional[Union[int, ExitCode]]:
if config.option.cacheshow: if config.option.cacheshow:
from _pytest.main import wrap_session from _pytest.main import wrap_session
return wrap_session(config, cacheshow) return wrap_session(config, cacheshow)
return None
@pytest.hookimpl(tryfirst=True) @pytest.hookimpl(tryfirst=True)
@ -455,7 +470,7 @@ def pytest_configure(config: Config) -> None:
@pytest.fixture @pytest.fixture
def cache(request): def cache(request: FixtureRequest) -> Cache:
""" """
Return a cache object that can persist state between testing sessions. Return a cache object that can persist state between testing sessions.
@ -467,26 +482,31 @@ def cache(request):
Values can be any object handled by the json stdlib module. Values can be any object handled by the json stdlib module.
""" """
assert request.config.cache is not None
return request.config.cache 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.""" """Display cachedir with --cache-show and if non-default."""
if config.option.verbose > 0 or config.getini("cache_dir") != ".pytest_cache": if config.option.verbose > 0 or config.getini("cache_dir") != ".pytest_cache":
assert config.cache is not None
cachedir = config.cache._cachedir cachedir = config.cache._cachedir
# TODO: evaluate generating upward relative paths # TODO: evaluate generating upward relative paths
# starting with .., ../.. if sensible # starting with .., ../.. if sensible
try: try:
displaypath = cachedir.relative_to(config.rootdir) displaypath = cachedir.relative_to(str(config.rootdir))
except ValueError: except ValueError:
displaypath = cachedir displaypath = cachedir
return "cachedir: {}".format(displaypath) return "cachedir: {}".format(displaypath)
return None
def cacheshow(config, session): def cacheshow(config: Config, session: Session) -> int:
from pprint import pformat from pprint import pformat
assert config.cache is not None
tw = TerminalWriter() tw = TerminalWriter()
tw.line("cachedir: " + str(config.cache._cachedir)) tw.line("cachedir: " + str(config.cache._cachedir))
if not config.cache._cachedir.is_dir(): if not config.cache._cachedir.is_dir():

View File

@ -9,13 +9,19 @@ import os
import sys import sys
from io import UnsupportedOperation from io import UnsupportedOperation
from tempfile import TemporaryFile from tempfile import TemporaryFile
from typing import Generator
from typing import Optional from typing import Optional
from typing import TextIO from typing import TextIO
from typing import Tuple from typing import Tuple
from typing import Union
import pytest import pytest
from _pytest.compat import TYPE_CHECKING from _pytest.compat import TYPE_CHECKING
from _pytest.config import Config from _pytest.config import Config
from _pytest.config.argparsing import Parser
from _pytest.fixtures import SubRequest
from _pytest.nodes import Collector
from _pytest.nodes import Item
if TYPE_CHECKING: if TYPE_CHECKING:
from typing_extensions import Literal from typing_extensions import Literal
@ -23,7 +29,7 @@ if TYPE_CHECKING:
_CaptureMethod = Literal["fd", "sys", "no", "tee-sys"] _CaptureMethod = Literal["fd", "sys", "no", "tee-sys"]
def pytest_addoption(parser): def pytest_addoption(parser: Parser) -> None:
group = parser.getgroup("general") group = parser.getgroup("general")
group._addoption( group._addoption(
"--capture", "--capture",
@ -42,7 +48,7 @@ def pytest_addoption(parser):
) )
def _colorama_workaround(): def _colorama_workaround() -> None:
""" """
Ensure colorama is imported so that it attaches to the correct stdio Ensure colorama is imported so that it attaches to the correct stdio
handles on Windows. handles on Windows.
@ -58,7 +64,7 @@ def _colorama_workaround():
pass pass
def _readline_workaround(): def _readline_workaround() -> None:
""" """
Ensure readline is imported so that it attaches to the correct stdio Ensure readline is imported so that it attaches to the correct stdio
handles on Windows. handles on Windows.
@ -83,7 +89,7 @@ def _readline_workaround():
pass pass
def _py36_windowsconsoleio_workaround(stream): def _py36_windowsconsoleio_workaround(stream: TextIO) -> None:
""" """
Python 3.6 implemented unicode console handling for Windows. This works Python 3.6 implemented unicode console handling for Windows. This works
by reading/writing to the raw console handle using by reading/writing to the raw console handle using
@ -117,9 +123,9 @@ def _py36_windowsconsoleio_workaround(stream):
return return
buffered = hasattr(stream.buffer, "raw") buffered = hasattr(stream.buffer, "raw")
raw_stdout = stream.buffer.raw if buffered else stream.buffer raw_stdout = stream.buffer.raw if buffered else stream.buffer # type: ignore[attr-defined]
if not isinstance(raw_stdout, io._WindowsConsoleIO): if not isinstance(raw_stdout, io._WindowsConsoleIO): # type: ignore[attr-defined]
return return
def _reopen_stdio(f, mode): def _reopen_stdio(f, mode):
@ -129,7 +135,7 @@ def _py36_windowsconsoleio_workaround(stream):
buffering = -1 buffering = -1
return io.TextIOWrapper( return io.TextIOWrapper(
open(os.dup(f.fileno()), mode, buffering), open(os.dup(f.fileno()), mode, buffering), # type: ignore[arg-type]
f.encoding, f.encoding,
f.errors, f.errors,
f.newlines, f.newlines,
@ -198,7 +204,7 @@ class TeeCaptureIO(CaptureIO):
self._other = other self._other = other
super().__init__() super().__init__()
def write(self, s) -> int: def write(self, s: str) -> int:
super().write(s) super().write(s)
return self._other.write(s) return self._other.write(s)
@ -218,13 +224,13 @@ class DontReadFromInput:
def __iter__(self): def __iter__(self):
return self return self
def fileno(self): def fileno(self) -> int:
raise UnsupportedOperation("redirected stdin is pseudofile, has no fileno()") raise UnsupportedOperation("redirected stdin is pseudofile, has no fileno()")
def isatty(self): def isatty(self) -> bool:
return False return False
def close(self): def close(self) -> None:
pass pass
@property @property
@ -247,7 +253,7 @@ class SysCaptureBinary:
EMPTY_BUFFER = b"" EMPTY_BUFFER = b""
def __init__(self, fd, tmpfile=None, *, tee=False): def __init__(self, fd: int, tmpfile=None, *, tee: bool = False) -> None:
name = patchsysdict[fd] name = patchsysdict[fd]
self._old = getattr(sys, name) self._old = getattr(sys, name)
self.name = name self.name = name
@ -284,7 +290,7 @@ class SysCaptureBinary:
op, self._state, ", ".join(states) op, self._state, ", ".join(states)
) )
def start(self): def start(self) -> None:
self._assert_state("start", ("initialized",)) self._assert_state("start", ("initialized",))
setattr(sys, self.name, self.tmpfile) setattr(sys, self.name, self.tmpfile)
self._state = "started" self._state = "started"
@ -297,7 +303,7 @@ class SysCaptureBinary:
self.tmpfile.truncate() self.tmpfile.truncate()
return res return res
def done(self): def done(self) -> None:
self._assert_state("done", ("initialized", "started", "suspended", "done")) self._assert_state("done", ("initialized", "started", "suspended", "done"))
if self._state == "done": if self._state == "done":
return return
@ -306,19 +312,19 @@ class SysCaptureBinary:
self.tmpfile.close() self.tmpfile.close()
self._state = "done" self._state = "done"
def suspend(self): def suspend(self) -> None:
self._assert_state("suspend", ("started", "suspended")) self._assert_state("suspend", ("started", "suspended"))
setattr(sys, self.name, self._old) setattr(sys, self.name, self._old)
self._state = "suspended" self._state = "suspended"
def resume(self): def resume(self) -> None:
self._assert_state("resume", ("started", "suspended")) self._assert_state("resume", ("started", "suspended"))
if self._state == "started": if self._state == "started":
return return
setattr(sys, self.name, self.tmpfile) setattr(sys, self.name, self.tmpfile)
self._state = "started" self._state = "started"
def writeorg(self, data): def writeorg(self, data) -> None:
self._assert_state("writeorg", ("started", "suspended")) self._assert_state("writeorg", ("started", "suspended"))
self._old.flush() self._old.flush()
self._old.buffer.write(data) self._old.buffer.write(data)
@ -348,7 +354,7 @@ class FDCaptureBinary:
EMPTY_BUFFER = b"" EMPTY_BUFFER = b""
def __init__(self, targetfd): def __init__(self, targetfd: int) -> None:
self.targetfd = targetfd self.targetfd = targetfd
try: try:
@ -365,7 +371,9 @@ class FDCaptureBinary:
# Further complications are the need to support suspend() and the # Further complications are the need to support suspend() and the
# possibility of FD reuse (e.g. the tmpfile getting the very same # possibility of FD reuse (e.g. the tmpfile getting the very same
# target FD). The following approach is robust, I believe. # target FD). The following approach is robust, I believe.
self.targetfd_invalid = os.open(os.devnull, os.O_RDWR) self.targetfd_invalid = os.open(
os.devnull, os.O_RDWR
) # type: Optional[int]
os.dup2(self.targetfd_invalid, targetfd) os.dup2(self.targetfd_invalid, targetfd)
else: else:
self.targetfd_invalid = None self.targetfd_invalid = None
@ -376,7 +384,8 @@ class FDCaptureBinary:
self.syscapture = SysCapture(targetfd) self.syscapture = SysCapture(targetfd)
else: else:
self.tmpfile = EncodedFile( self.tmpfile = EncodedFile(
TemporaryFile(buffering=0), # TODO: Remove type ignore, fixed in next mypy release.
TemporaryFile(buffering=0), # type: ignore[arg-type]
encoding="utf-8", encoding="utf-8",
errors="replace", errors="replace",
write_through=True, write_through=True,
@ -388,7 +397,7 @@ class FDCaptureBinary:
self._state = "initialized" self._state = "initialized"
def __repr__(self): def __repr__(self) -> str:
return "<{} {} oldfd={} _state={!r} tmpfile={!r}>".format( return "<{} {} oldfd={} _state={!r} tmpfile={!r}>".format(
self.__class__.__name__, self.__class__.__name__,
self.targetfd, self.targetfd,
@ -404,7 +413,7 @@ class FDCaptureBinary:
op, self._state, ", ".join(states) op, self._state, ", ".join(states)
) )
def start(self): def start(self) -> None:
""" Start capturing on targetfd using memorized tmpfile. """ """ Start capturing on targetfd using memorized tmpfile. """
self._assert_state("start", ("initialized",)) self._assert_state("start", ("initialized",))
os.dup2(self.tmpfile.fileno(), self.targetfd) os.dup2(self.tmpfile.fileno(), self.targetfd)
@ -419,7 +428,7 @@ class FDCaptureBinary:
self.tmpfile.truncate() self.tmpfile.truncate()
return res return res
def done(self): def done(self) -> None:
""" stop capturing, restore streams, return original capture file, """ stop capturing, restore streams, return original capture file,
seeked to position zero. """ seeked to position zero. """
self._assert_state("done", ("initialized", "started", "suspended", "done")) self._assert_state("done", ("initialized", "started", "suspended", "done"))
@ -435,7 +444,7 @@ class FDCaptureBinary:
self.tmpfile.close() self.tmpfile.close()
self._state = "done" self._state = "done"
def suspend(self): def suspend(self) -> None:
self._assert_state("suspend", ("started", "suspended")) self._assert_state("suspend", ("started", "suspended"))
if self._state == "suspended": if self._state == "suspended":
return return
@ -443,7 +452,7 @@ class FDCaptureBinary:
os.dup2(self.targetfd_save, self.targetfd) os.dup2(self.targetfd_save, self.targetfd)
self._state = "suspended" self._state = "suspended"
def resume(self): def resume(self) -> None:
self._assert_state("resume", ("started", "suspended")) self._assert_state("resume", ("started", "suspended"))
if self._state == "started": if self._state == "started":
return return
@ -493,12 +502,12 @@ class MultiCapture:
self.out = out self.out = out
self.err = err self.err = err
def __repr__(self): def __repr__(self) -> str:
return "<MultiCapture out={!r} err={!r} in_={!r} _state={!r} _in_suspended={!r}>".format( return "<MultiCapture out={!r} err={!r} in_={!r} _state={!r} _in_suspended={!r}>".format(
self.out, self.err, self.in_, self._state, self._in_suspended, self.out, self.err, self.in_, self._state, self._in_suspended,
) )
def start_capturing(self): def start_capturing(self) -> None:
self._state = "started" self._state = "started"
if self.in_: if self.in_:
self.in_.start() self.in_.start()
@ -516,7 +525,7 @@ class MultiCapture:
self.err.writeorg(err) self.err.writeorg(err)
return out, err return out, err
def suspend_capturing(self, in_=False): def suspend_capturing(self, in_: bool = False) -> None:
self._state = "suspended" self._state = "suspended"
if self.out: if self.out:
self.out.suspend() self.out.suspend()
@ -526,7 +535,7 @@ class MultiCapture:
self.in_.suspend() self.in_.suspend()
self._in_suspended = True self._in_suspended = True
def resume_capturing(self): def resume_capturing(self) -> None:
self._state = "resumed" self._state = "resumed"
if self.out: if self.out:
self.out.resume() self.out.resume()
@ -536,7 +545,7 @@ class MultiCapture:
self.in_.resume() self.in_.resume()
self._in_suspended = False self._in_suspended = False
def stop_capturing(self): def stop_capturing(self) -> None:
""" stop capturing and reset capturing streams """ """ stop capturing and reset capturing streams """
if self._state == "stopped": if self._state == "stopped":
raise ValueError("was already stopped") raise ValueError("was already stopped")
@ -592,15 +601,15 @@ class CaptureManager:
def __init__(self, method: "_CaptureMethod") -> None: def __init__(self, method: "_CaptureMethod") -> None:
self._method = method self._method = method
self._global_capturing = None self._global_capturing = None # type: Optional[MultiCapture]
self._capture_fixture = None # type: Optional[CaptureFixture] self._capture_fixture = None # type: Optional[CaptureFixture]
def __repr__(self): def __repr__(self) -> str:
return "<CaptureManager _method={!r} _global_capturing={!r} _capture_fixture={!r}>".format( return "<CaptureManager _method={!r} _global_capturing={!r} _capture_fixture={!r}>".format(
self._method, self._global_capturing, self._capture_fixture self._method, self._global_capturing, self._capture_fixture
) )
def is_capturing(self): def is_capturing(self) -> Union[str, bool]:
if self.is_globally_capturing(): if self.is_globally_capturing():
return "global" return "global"
if self._capture_fixture: if self._capture_fixture:
@ -609,40 +618,41 @@ class CaptureManager:
# Global capturing control # Global capturing control
def is_globally_capturing(self): def is_globally_capturing(self) -> bool:
return self._method != "no" return self._method != "no"
def start_global_capturing(self): def start_global_capturing(self) -> None:
assert self._global_capturing is None assert self._global_capturing is None
self._global_capturing = _get_multicapture(self._method) self._global_capturing = _get_multicapture(self._method)
self._global_capturing.start_capturing() self._global_capturing.start_capturing()
def stop_global_capturing(self): def stop_global_capturing(self) -> None:
if self._global_capturing is not None: if self._global_capturing is not None:
self._global_capturing.pop_outerr_to_orig() self._global_capturing.pop_outerr_to_orig()
self._global_capturing.stop_capturing() self._global_capturing.stop_capturing()
self._global_capturing = None self._global_capturing = None
def resume_global_capture(self): def resume_global_capture(self) -> None:
# During teardown of the python process, and on rare occasions, capture # During teardown of the python process, and on rare occasions, capture
# attributes can be `None` while trying to resume global capture. # attributes can be `None` while trying to resume global capture.
if self._global_capturing is not None: if self._global_capturing is not None:
self._global_capturing.resume_capturing() self._global_capturing.resume_capturing()
def suspend_global_capture(self, in_=False): def suspend_global_capture(self, in_: bool = False) -> None:
if self._global_capturing is not None: if self._global_capturing is not None:
self._global_capturing.suspend_capturing(in_=in_) self._global_capturing.suspend_capturing(in_=in_)
def suspend(self, in_=False): def suspend(self, in_: bool = False) -> None:
# Need to undo local capsys-et-al if it exists before disabling global capture. # Need to undo local capsys-et-al if it exists before disabling global capture.
self.suspend_fixture() self.suspend_fixture()
self.suspend_global_capture(in_) self.suspend_global_capture(in_)
def resume(self): def resume(self) -> None:
self.resume_global_capture() self.resume_global_capture()
self.resume_fixture() self.resume_fixture()
def read_global_capture(self): def read_global_capture(self):
assert self._global_capturing is not None
return self._global_capturing.readouterr() return self._global_capturing.readouterr()
# Fixture Control # Fixture Control
@ -661,30 +671,30 @@ class CaptureManager:
def unset_fixture(self) -> None: def unset_fixture(self) -> None:
self._capture_fixture = None self._capture_fixture = None
def activate_fixture(self): def activate_fixture(self) -> None:
"""If the current item is using ``capsys`` or ``capfd``, activate them so they take precedence over """If the current item is using ``capsys`` or ``capfd``, activate them so they take precedence over
the global capture. the global capture.
""" """
if self._capture_fixture: if self._capture_fixture:
self._capture_fixture._start() self._capture_fixture._start()
def deactivate_fixture(self): def deactivate_fixture(self) -> None:
"""Deactivates the ``capsys`` or ``capfd`` fixture of this item, if any.""" """Deactivates the ``capsys`` or ``capfd`` fixture of this item, if any."""
if self._capture_fixture: if self._capture_fixture:
self._capture_fixture.close() self._capture_fixture.close()
def suspend_fixture(self): def suspend_fixture(self) -> None:
if self._capture_fixture: if self._capture_fixture:
self._capture_fixture._suspend() self._capture_fixture._suspend()
def resume_fixture(self): def resume_fixture(self) -> None:
if self._capture_fixture: if self._capture_fixture:
self._capture_fixture._resume() self._capture_fixture._resume()
# Helper context managers # Helper context managers
@contextlib.contextmanager @contextlib.contextmanager
def global_and_fixture_disabled(self): def global_and_fixture_disabled(self) -> Generator[None, None, None]:
"""Context manager to temporarily disable global and current fixture capturing.""" """Context manager to temporarily disable global and current fixture capturing."""
self.suspend() self.suspend()
try: try:
@ -693,7 +703,7 @@ class CaptureManager:
self.resume() self.resume()
@contextlib.contextmanager @contextlib.contextmanager
def item_capture(self, when, item): def item_capture(self, when: str, item: Item) -> Generator[None, None, None]:
self.resume_global_capture() self.resume_global_capture()
self.activate_fixture() self.activate_fixture()
try: try:
@ -709,7 +719,7 @@ class CaptureManager:
# Hooks # Hooks
@pytest.hookimpl(hookwrapper=True) @pytest.hookimpl(hookwrapper=True)
def pytest_make_collect_report(self, collector): def pytest_make_collect_report(self, collector: Collector):
if isinstance(collector, pytest.File): if isinstance(collector, pytest.File):
self.resume_global_capture() self.resume_global_capture()
outcome = yield outcome = yield
@ -724,26 +734,26 @@ class CaptureManager:
yield yield
@pytest.hookimpl(hookwrapper=True) @pytest.hookimpl(hookwrapper=True)
def pytest_runtest_setup(self, item): def pytest_runtest_setup(self, item: Item) -> Generator[None, None, None]:
with self.item_capture("setup", item): with self.item_capture("setup", item):
yield yield
@pytest.hookimpl(hookwrapper=True) @pytest.hookimpl(hookwrapper=True)
def pytest_runtest_call(self, item): def pytest_runtest_call(self, item: Item) -> Generator[None, None, None]:
with self.item_capture("call", item): with self.item_capture("call", item):
yield yield
@pytest.hookimpl(hookwrapper=True) @pytest.hookimpl(hookwrapper=True)
def pytest_runtest_teardown(self, item): def pytest_runtest_teardown(self, item: Item) -> Generator[None, None, None]:
with self.item_capture("teardown", item): with self.item_capture("teardown", item):
yield yield
@pytest.hookimpl(tryfirst=True) @pytest.hookimpl(tryfirst=True)
def pytest_keyboard_interrupt(self, excinfo): def pytest_keyboard_interrupt(self) -> None:
self.stop_global_capturing() self.stop_global_capturing()
@pytest.hookimpl(tryfirst=True) @pytest.hookimpl(tryfirst=True)
def pytest_internalerror(self, excinfo): def pytest_internalerror(self) -> None:
self.stop_global_capturing() self.stop_global_capturing()
@ -753,21 +763,21 @@ class CaptureFixture:
fixtures. fixtures.
""" """
def __init__(self, captureclass, request): def __init__(self, captureclass, request: SubRequest) -> None:
self.captureclass = captureclass self.captureclass = captureclass
self.request = request self.request = request
self._capture = None self._capture = None # type: Optional[MultiCapture]
self._captured_out = self.captureclass.EMPTY_BUFFER self._captured_out = self.captureclass.EMPTY_BUFFER
self._captured_err = self.captureclass.EMPTY_BUFFER self._captured_err = self.captureclass.EMPTY_BUFFER
def _start(self): def _start(self) -> None:
if self._capture is None: if self._capture is None:
self._capture = MultiCapture( self._capture = MultiCapture(
in_=None, out=self.captureclass(1), err=self.captureclass(2), in_=None, out=self.captureclass(1), err=self.captureclass(2),
) )
self._capture.start_capturing() self._capture.start_capturing()
def close(self): def close(self) -> None:
if self._capture is not None: if self._capture is not None:
out, err = self._capture.pop_outerr_to_orig() out, err = self._capture.pop_outerr_to_orig()
self._captured_out += out self._captured_out += out
@ -789,18 +799,18 @@ class CaptureFixture:
self._captured_err = self.captureclass.EMPTY_BUFFER self._captured_err = self.captureclass.EMPTY_BUFFER
return CaptureResult(captured_out, captured_err) return CaptureResult(captured_out, captured_err)
def _suspend(self): def _suspend(self) -> None:
"""Suspends this fixture's own capturing temporarily.""" """Suspends this fixture's own capturing temporarily."""
if self._capture is not None: if self._capture is not None:
self._capture.suspend_capturing() self._capture.suspend_capturing()
def _resume(self): def _resume(self) -> None:
"""Resumes this fixture's own capturing temporarily.""" """Resumes this fixture's own capturing temporarily."""
if self._capture is not None: if self._capture is not None:
self._capture.resume_capturing() self._capture.resume_capturing()
@contextlib.contextmanager @contextlib.contextmanager
def disabled(self): def disabled(self) -> Generator[None, None, None]:
"""Temporarily disables capture while inside the 'with' block.""" """Temporarily disables capture while inside the 'with' block."""
capmanager = self.request.config.pluginmanager.getplugin("capturemanager") capmanager = self.request.config.pluginmanager.getplugin("capturemanager")
with capmanager.global_and_fixture_disabled(): with capmanager.global_and_fixture_disabled():
@ -811,7 +821,7 @@ class CaptureFixture:
@pytest.fixture @pytest.fixture
def capsys(request): def capsys(request: SubRequest):
"""Enable text capturing of writes to ``sys.stdout`` and ``sys.stderr``. """Enable text capturing of writes to ``sys.stdout`` and ``sys.stderr``.
The captured output is made available via ``capsys.readouterr()`` method The captured output is made available via ``capsys.readouterr()`` method
@ -828,7 +838,7 @@ def capsys(request):
@pytest.fixture @pytest.fixture
def capsysbinary(request): def capsysbinary(request: SubRequest):
"""Enable bytes capturing of writes to ``sys.stdout`` and ``sys.stderr``. """Enable bytes capturing of writes to ``sys.stdout`` and ``sys.stderr``.
The captured output is made available via ``capsysbinary.readouterr()`` The captured output is made available via ``capsysbinary.readouterr()``
@ -845,7 +855,7 @@ def capsysbinary(request):
@pytest.fixture @pytest.fixture
def capfd(request): def capfd(request: SubRequest):
"""Enable text capturing of writes to file descriptors ``1`` and ``2``. """Enable text capturing of writes to file descriptors ``1`` and ``2``.
The captured output is made available via ``capfd.readouterr()`` method The captured output is made available via ``capfd.readouterr()`` method
@ -862,7 +872,7 @@ def capfd(request):
@pytest.fixture @pytest.fixture
def capfdbinary(request): def capfdbinary(request: SubRequest):
"""Enable bytes capturing of writes to file descriptors ``1`` and ``2``. """Enable bytes capturing of writes to file descriptors ``1`` and ``2``.
The captured output is made available via ``capfd.readouterr()`` method The captured output is made available via ``capfd.readouterr()`` method

View File

@ -1,6 +1,7 @@
""" """
python version compatibility code python version compatibility code
""" """
import enum
import functools import functools
import inspect import inspect
import os import os
@ -32,14 +33,22 @@ else:
if TYPE_CHECKING: if TYPE_CHECKING:
from typing import NoReturn
from typing import Type from typing import Type
from typing_extensions import Final
_T = TypeVar("_T") _T = TypeVar("_T")
_S = TypeVar("_S") _S = TypeVar("_S")
NOTSET = object() # 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 = NotSetType.token # type: Final # noqa: E305
# fmt: on
MODULE_NOT_FOUND_ERROR = ( MODULE_NOT_FOUND_ERROR = (
"ModuleNotFoundError" if sys.version_info[:2] >= (3, 6) else "ImportError" "ModuleNotFoundError" if sys.version_info[:2] >= (3, 6) else "ImportError"
@ -393,3 +402,38 @@ else:
from collections import OrderedDict from collections import OrderedDict
order_preserving_dict = OrderedDict order_preserving_dict = OrderedDict
# 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:
#
# // throw new Error('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__)

View File

@ -1,5 +1,6 @@
""" command line options, ini-file and conftest.py processing. """ """ command line options, ini-file and conftest.py processing. """
import argparse import argparse
import collections.abc
import contextlib import contextlib
import copy import copy
import enum import enum
@ -14,10 +15,14 @@ from types import TracebackType
from typing import Any from typing import Any
from typing import Callable from typing import Callable
from typing import Dict from typing import Dict
from typing import IO
from typing import Iterable
from typing import Iterator
from typing import List from typing import List
from typing import Optional from typing import Optional
from typing import Sequence from typing import Sequence
from typing import Set from typing import Set
from typing import TextIO
from typing import Tuple from typing import Tuple
from typing import Union from typing import Union
@ -33,7 +38,6 @@ import _pytest.hookspec # the extension point definitions
from .exceptions import PrintHelp from .exceptions import PrintHelp
from .exceptions import UsageError from .exceptions import UsageError
from .findpaths import determine_setup from .findpaths import determine_setup
from .findpaths import exists
from _pytest._code import ExceptionInfo from _pytest._code import ExceptionInfo
from _pytest._code import filter_traceback from _pytest._code import filter_traceback
from _pytest._io import TerminalWriter from _pytest._io import TerminalWriter
@ -41,6 +45,8 @@ from _pytest.compat import importlib_metadata
from _pytest.compat import TYPE_CHECKING from _pytest.compat import TYPE_CHECKING
from _pytest.outcomes import fail from _pytest.outcomes import fail
from _pytest.outcomes import Skipped from _pytest.outcomes import Skipped
from _pytest.pathlib import import_path
from _pytest.pathlib import ImportMode
from _pytest.pathlib import Path from _pytest.pathlib import Path
from _pytest.store import Store from _pytest.store import Store
from _pytest.warning_types import PytestConfigWarning from _pytest.warning_types import PytestConfigWarning
@ -48,6 +54,8 @@ from _pytest.warning_types import PytestConfigWarning
if TYPE_CHECKING: if TYPE_CHECKING:
from typing import Type from typing import Type
from _pytest._code.code import _TracebackStyle
from _pytest.terminal import TerminalReporter
from .argparsing import Argument from .argparsing import Argument
@ -86,18 +94,36 @@ class ExitCode(enum.IntEnum):
class ConftestImportFailure(Exception): class ConftestImportFailure(Exception):
def __init__(self, path, excinfo): def __init__(
self,
path: py.path.local,
excinfo: Tuple["Type[Exception]", Exception, TracebackType],
) -> None:
super().__init__(path, excinfo) super().__init__(path, excinfo)
self.path = path self.path = path
self.excinfo = excinfo # type: Tuple[Type[Exception], Exception, TracebackType] self.excinfo = excinfo
def __str__(self): def __str__(self) -> str:
return "{}: {} (from {})".format( return "{}: {} (from {})".format(
self.excinfo[0].__name__, self.excinfo[1], self.path self.excinfo[0].__name__, self.excinfo[1], self.path
) )
def main(args=None, plugins=None) -> Union[int, ExitCode]: def filter_traceback_for_conftest_import_failure(
entry: _pytest._code.TracebackEntry,
) -> bool:
"""filters tracebacks entries which point to pytest internals or importlib.
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[List[str]] = None,
plugins: Optional[Sequence[Union[str, _PluggyPlugin]]] = None,
) -> Union[int, ExitCode]:
""" return exit code, after performing an in-process test run. """ return exit code, after performing an in-process test run.
:arg args: list of command line arguments. :arg args: list of command line arguments.
@ -114,7 +140,9 @@ def main(args=None, plugins=None) -> Union[int, ExitCode]:
tw.line( tw.line(
"ImportError while loading conftest '{e.path}'.".format(e=e), red=True "ImportError while loading conftest '{e.path}'.".format(e=e), red=True
) )
exc_info.traceback = exc_info.traceback.filter(filter_traceback) exc_info.traceback = exc_info.traceback.filter(
filter_traceback_for_conftest_import_failure
)
exc_repr = ( exc_repr = (
exc_info.getrepr(style="short", chain=False) exc_info.getrepr(style="short", chain=False)
if exc_info.traceback if exc_info.traceback
@ -164,7 +192,7 @@ class cmdline: # compatibility namespace
main = staticmethod(main) main = staticmethod(main)
def filename_arg(path, optname): def filename_arg(path: str, optname: str) -> str:
""" Argparse type validator for filename arguments. """ Argparse type validator for filename arguments.
:path: path of filename :path: path of filename
@ -175,7 +203,7 @@ def filename_arg(path, optname):
return path return path
def directory_arg(path, optname): def directory_arg(path: str, optname: str) -> str:
"""Argparse type validator for directory arguments. """Argparse type validator for directory arguments.
:path: path of directory :path: path of directory
@ -226,13 +254,16 @@ builtin_plugins = set(default_plugins)
builtin_plugins.add("pytester") builtin_plugins.add("pytester")
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 # subsequent calls to main will create a fresh instance
pluginmanager = PytestPluginManager() pluginmanager = PytestPluginManager()
config = Config( config = Config(
pluginmanager, pluginmanager,
invocation_params=Config.InvocationParams( invocation_params=Config.InvocationParams(
args=args or (), plugins=plugins, dir=Path().resolve() args=args or (), plugins=plugins, dir=Path.cwd(),
), ),
) )
@ -242,10 +273,11 @@ def get_config(args=None, plugins=None):
for spec in default_plugins: for spec in default_plugins:
pluginmanager.import_plugin(spec) pluginmanager.import_plugin(spec)
return config return config
def get_plugin_manager(): def get_plugin_manager() -> "PytestPluginManager":
""" """
Obtain a new instance of the Obtain a new instance of the
:py:class:`_pytest.config.PytestPluginManager`, with default plugins :py:class:`_pytest.config.PytestPluginManager`, with default plugins
@ -258,8 +290,9 @@ def get_plugin_manager():
def _prepareconfig( 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: if args is None:
args = sys.argv[1:] args = sys.argv[1:]
elif isinstance(args, py.path.local): elif isinstance(args, py.path.local):
@ -277,9 +310,10 @@ def _prepareconfig(
pluginmanager.consider_pluginarg(plugin) pluginmanager.consider_pluginarg(plugin)
else: else:
pluginmanager.register(plugin) pluginmanager.register(plugin)
return pluginmanager.hook.pytest_cmdline_parse( config = pluginmanager.hook.pytest_cmdline_parse(
pluginmanager=pluginmanager, args=args pluginmanager=pluginmanager, args=args
) )
return config
except BaseException: except BaseException:
config._ensure_unconfigure() config._ensure_unconfigure()
raise raise
@ -295,28 +329,25 @@ class PytestPluginManager(PluginManager):
* ``conftest.py`` loading during start-up; * ``conftest.py`` loading during start-up;
""" """
def __init__(self): def __init__(self) -> None:
import _pytest.assertion import _pytest.assertion
super().__init__("pytest") super().__init__("pytest")
# The objects are module objects, only used generically. # The objects are module objects, only used generically.
self._conftest_plugins = set() # type: Set[object] self._conftest_plugins = set() # type: Set[types.ModuleType]
# state related to local conftest plugins # State related to local conftest plugins.
# Maps a py.path.local to a list of module objects. self._dirpath2confmods = {} # type: Dict[py.path.local, List[types.ModuleType]]
self._dirpath2confmods = {} # type: Dict[Any, List[object]] self._conftestpath2mod = {} # type: Dict[Path, types.ModuleType]
# Maps a py.path.local to a module object. self._confcutdir = None # type: Optional[py.path.local]
self._conftestpath2mod = {} # type: Dict[Any, object]
self._confcutdir = None
self._noconftest = False self._noconftest = False
# Set of py.path.local's. self._duplicatepaths = set() # type: Set[py.path.local]
self._duplicatepaths = set() # type: Set[Any]
self.add_hookspecs(_pytest.hookspec) self.add_hookspecs(_pytest.hookspec)
self.register(self) self.register(self)
if os.environ.get("PYTEST_DEBUG"): if os.environ.get("PYTEST_DEBUG"):
err = sys.stderr err = sys.stderr # type: IO[str]
encoding = getattr(err, "encoding", "utf8") encoding = getattr(err, "encoding", "utf8") # type: str
try: try:
err = open( err = open(
os.dup(err.fileno()), mode=err.mode, buffering=1, encoding=encoding, os.dup(err.fileno()), mode=err.mode, buffering=1, encoding=encoding,
@ -331,7 +362,7 @@ class PytestPluginManager(PluginManager):
# 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 self._configured = False
def parse_hookimpl_opts(self, plugin, name): def parse_hookimpl_opts(self, plugin: _PluggyPlugin, name: str):
# pytest hooks are always prefixed with pytest_ # pytest hooks are always prefixed with pytest_
# so we avoid accessing possibly non-readable attributes # so we avoid accessing possibly non-readable attributes
# (see issue #1073) # (see issue #1073)
@ -360,7 +391,7 @@ class PytestPluginManager(PluginManager):
opts.setdefault(name, hasattr(method, name) or name in known_marks) opts.setdefault(name, hasattr(method, name) or name in known_marks)
return opts 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) opts = super().parse_hookspec_opts(module_or_class, name)
if opts is None: if opts is None:
method = getattr(module_or_class, name) method = getattr(module_or_class, name)
@ -377,7 +408,9 @@ class PytestPluginManager(PluginManager):
} }
return opts 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: if name in _pytest.deprecated.DEPRECATED_EXTERNAL_PLUGINS:
warnings.warn( warnings.warn(
PytestConfigWarning( PytestConfigWarning(
@ -387,8 +420,8 @@ class PytestPluginManager(PluginManager):
) )
) )
) )
return return None
ret = super().register(plugin, name) ret = super().register(plugin, name) # type: Optional[str]
if ret: if ret:
self.hook.pytest_plugin_registered.call_historic( self.hook.pytest_plugin_registered.call_historic(
kwargs=dict(plugin=plugin, manager=self) kwargs=dict(plugin=plugin, manager=self)
@ -398,15 +431,16 @@ class PytestPluginManager(PluginManager):
self.consider_module(plugin) self.consider_module(plugin)
return ret return ret
def getplugin(self, name): def getplugin(self, name: str):
# support deprecated naming because plugins (xdist e.g.) use it # support deprecated naming because plugins (xdist e.g.) use it
return self.get_plugin(name) plugin = self.get_plugin(name) # type: Optional[_PluggyPlugin]
return plugin
def hasplugin(self, name): def hasplugin(self, name: str) -> bool:
"""Return True if the plugin with the given name is registered.""" """Return True if the plugin with the given name is registered."""
return bool(self.get_plugin(name)) return bool(self.get_plugin(name))
def pytest_configure(self, config): def pytest_configure(self, config: "Config") -> None:
# XXX now that the pluginmanager exposes hookimpl(tryfirst...) # 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( config.addinivalue_line(
@ -424,7 +458,7 @@ class PytestPluginManager(PluginManager):
# #
# internal API for local conftest plugin handling # internal API for local conftest plugin handling
# #
def _set_initial_conftests(self, namespace): def _set_initial_conftests(self, namespace: argparse.Namespace) -> None:
""" load initial conftest files given a preparsed "namespace". """ load initial conftest files given a preparsed "namespace".
As conftest files may add their own command line options As conftest files may add their own command line options
which have arguments ('--my-opt somepath') we might get some which have arguments ('--my-opt somepath') we might get some
@ -442,29 +476,33 @@ class PytestPluginManager(PluginManager):
self._using_pyargs = namespace.pyargs self._using_pyargs = namespace.pyargs
testpaths = namespace.file_or_dir testpaths = namespace.file_or_dir
foundanchor = False foundanchor = False
for path in testpaths: for testpath in testpaths:
path = str(path) path = str(testpath)
# remove node-id syntax # remove node-id syntax
i = path.find("::") i = path.find("::")
if i != -1: if i != -1:
path = path[:i] path = path[:i]
anchor = current.join(path, abs=1) anchor = current.join(path, abs=1)
if exists(anchor): # we found some file object if anchor.exists(): # we found some file object
self._try_load_conftest(anchor) self._try_load_conftest(anchor, namespace.importmode)
foundanchor = True foundanchor = True
if not foundanchor: if not foundanchor:
self._try_load_conftest(current) self._try_load_conftest(current, namespace.importmode)
def _try_load_conftest(self, anchor): def _try_load_conftest(
self._getconftestmodules(anchor) self, anchor: py.path.local, importmode: Union[str, ImportMode]
) -> None:
self._getconftestmodules(anchor, importmode)
# let's also consider test* subdirs # let's also consider test* subdirs
if anchor.check(dir=1): if anchor.check(dir=1):
for x in anchor.listdir("test*"): for x in anchor.listdir("test*"):
if x.check(dir=1): if x.check(dir=1):
self._getconftestmodules(x) self._getconftestmodules(x, importmode)
@lru_cache(maxsize=128) @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: if self._noconftest:
return [] return []
@ -477,18 +515,20 @@ class PytestPluginManager(PluginManager):
# and allow users to opt into looking into the rootdir parent # 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 = [] clist = []
for parent in directory.realpath().parts(): for parent in directory.parts():
if self._confcutdir and self._confcutdir.relto(parent): if self._confcutdir and self._confcutdir.relto(parent):
continue continue
conftestpath = parent.join("conftest.py") conftestpath = parent.join("conftest.py")
if conftestpath.isfile(): if conftestpath.isfile():
mod = self._importconftest(conftestpath) mod = self._importconftest(conftestpath, importmode)
clist.append(mod) clist.append(mod)
self._dirpath2confmods[directory] = clist self._dirpath2confmods[directory] = clist
return clist return clist
def _rget_with_confmod(self, name, path): def _rget_with_confmod(
modules = self._getconftestmodules(path) 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): for mod in reversed(modules):
try: try:
return mod, getattr(mod, name) return mod, getattr(mod, name)
@ -496,7 +536,9 @@ class PytestPluginManager(PluginManager):
continue continue
raise KeyError(name) raise KeyError(name)
def _importconftest(self, conftestpath): 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 # Use a resolved Path object as key to avoid loading the same conftest twice
# with build systems that create build directories containing # with build systems that create build directories containing
# symlinks to actual files. # symlinks to actual files.
@ -512,9 +554,11 @@ class PytestPluginManager(PluginManager):
_ensure_removed_sysmodule(conftestpath.purebasename) _ensure_removed_sysmodule(conftestpath.purebasename)
try: try:
mod = conftestpath.pyimport() mod = import_path(conftestpath, mode=importmode)
except Exception as e: except Exception as e:
raise ConftestImportFailure(conftestpath, sys.exc_info()) from 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._check_non_top_pytest_plugins(mod, conftestpath)
@ -530,7 +574,9 @@ class PytestPluginManager(PluginManager):
self.consider_conftest(mod) self.consider_conftest(mod)
return mod return mod
def _check_non_top_pytest_plugins(self, mod, conftestpath): def _check_non_top_pytest_plugins(
self, mod: types.ModuleType, conftestpath: py.path.local,
) -> None:
if ( if (
hasattr(mod, "pytest_plugins") hasattr(mod, "pytest_plugins")
and self._configured and self._configured
@ -552,7 +598,9 @@ class PytestPluginManager(PluginManager):
# #
# #
def consider_preparse(self, args, *, exclude_only=False): def consider_preparse(
self, args: Sequence[str], *, exclude_only: bool = False
) -> None:
i = 0 i = 0
n = len(args) n = len(args)
while i < n: while i < n:
@ -573,7 +621,7 @@ class PytestPluginManager(PluginManager):
continue continue
self.consider_pluginarg(parg) self.consider_pluginarg(parg)
def consider_pluginarg(self, arg): def consider_pluginarg(self, arg: str) -> None:
if arg.startswith("no:"): if arg.startswith("no:"):
name = arg[3:] name = arg[3:]
if name in essential_plugins: if name in essential_plugins:
@ -598,21 +646,23 @@ class PytestPluginManager(PluginManager):
del self._name2plugin["pytest_" + name] del self._name2plugin["pytest_" + name]
self.import_plugin(arg, consider_entry_points=True) 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__) self.register(conftestmodule, name=conftestmodule.__file__)
def consider_env(self): def consider_env(self) -> None:
self._import_plugin_specs(os.environ.get("PYTEST_PLUGINS")) 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", [])) 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) plugins = _get_plugin_specs_as_list(spec)
for import_spec in plugins: for import_spec in plugins:
self.import_plugin(import_spec) self.import_plugin(import_spec)
def import_plugin(self, modname, consider_entry_points=False): def import_plugin(self, modname: str, consider_entry_points: bool = False) -> None:
""" """
Imports a plugin with ``modname``. If ``consider_entry_points`` is True, entry point Imports a plugin with ``modname``. If ``consider_entry_points`` is True, entry point
names are also considered to find a plugin. names are also considered to find a plugin.
@ -624,7 +674,6 @@ class PytestPluginManager(PluginManager):
assert isinstance(modname, str), ( assert isinstance(modname, str), (
"module name as text required, got %r" % modname "module name as text required, got %r" % modname
) )
modname = str(modname)
if self.is_blocked(modname) or self.get_plugin(modname) is not None: if self.is_blocked(modname) or self.get_plugin(modname) is not None:
return return
@ -641,7 +690,7 @@ class PytestPluginManager(PluginManager):
except ImportError as e: except ImportError as e:
raise ImportError( raise ImportError(
'Error importing plugin "{}": {}'.format(modname, str(e.args[0])) 'Error importing plugin "{}": {}'.format(modname, str(e.args[0]))
).with_traceback(e.__traceback__) ).with_traceback(e.__traceback__) from e
except Skipped as e: except Skipped as e:
from _pytest.warnings import _issue_warning_captured from _pytest.warnings import _issue_warning_captured
@ -656,27 +705,29 @@ class PytestPluginManager(PluginManager):
self.register(mod, modname) self.register(mod, modname)
def _get_plugin_specs_as_list(specs): def _get_plugin_specs_as_list(
""" specs: Union[None, types.ModuleType, str, Sequence[str]]
Parses a list of "plugin specs" and returns a list of plugin names. ) -> List[str]:
"""Parse a plugins specification into a list of plugin names."""
Plugin specs can be given as a list of strings separated by "," or already as a list/tuple in # None means empty.
which case it is returned as a list. Specs can also be `None` in which case an if specs is None:
empty list is returned. return []
""" # Workaround for #3899 - a submodule which happens to be called "pytest_plugins".
if specs is not None and not isinstance(specs, types.ModuleType): if isinstance(specs, types.ModuleType):
if isinstance(specs, str): return []
specs = specs.split(",") if specs else [] # Comma-separated list.
if not isinstance(specs, (list, tuple)): if isinstance(specs, str):
raise UsageError( return specs.split(",") if specs else []
"Plugin specs must be a ','-separated string or a " # Direct specification.
"list/tuple of strings for plugin names. Given: %r" % specs if isinstance(specs, collections.abc.Sequence):
)
return list(specs) 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: try:
del sys.modules[modname] del sys.modules[modname]
except KeyError: except KeyError:
@ -691,7 +742,7 @@ class Notset:
notset = Notset() notset = Notset()
def _iter_rewritable_modules(package_files): 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 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 marked for assertion rewrite (for example the package "pytest_mock/__init__.py" should
@ -754,6 +805,10 @@ def _iter_rewritable_modules(package_files):
yield from _iter_rewritable_modules(new_package_files) yield from _iter_rewritable_modules(new_package_files)
def _args_converter(args: Iterable[str]) -> Tuple[str, ...]:
return tuple(args)
class Config: class Config:
""" """
Access to configuration values, pluginmanager and plugin hooks. Access to configuration values, pluginmanager and plugin hooks.
@ -781,9 +836,9 @@ class Config:
Plugins accessing ``InvocationParams`` must be aware of that. Plugins accessing ``InvocationParams`` must be aware of that.
""" """
args = attr.ib(converter=tuple) args = attr.ib(type=Tuple[str, ...], converter=_args_converter)
"""tuple of command-line arguments as passed to ``pytest.main()``.""" """tuple of command-line arguments as passed to ``pytest.main()``."""
plugins = attr.ib() plugins = attr.ib(type=Optional[Sequence[Union[str, _PluggyPlugin]]])
"""list of extra plugins, might be `None`.""" """list of extra plugins, might be `None`."""
dir = attr.ib(type=Path) dir = attr.ib(type=Path)
"""directory where ``pytest.main()`` was invoked from.""" """directory where ``pytest.main()`` was invoked from."""
@ -798,7 +853,7 @@ class Config:
if invocation_params is None: if invocation_params is None:
invocation_params = self.InvocationParams( invocation_params = self.InvocationParams(
args=(), plugins=None, dir=Path().resolve() args=(), plugins=None, dir=Path.cwd()
) )
self.option = argparse.Namespace() self.option = argparse.Namespace()
@ -839,23 +894,23 @@ class Config:
self.cache = None # type: Optional[Cache] self.cache = None # type: Optional[Cache]
@property @property
def invocation_dir(self): def invocation_dir(self) -> py.path.local:
"""Backward compatibility""" """Backward compatibility"""
return py.path.local(str(self.invocation_params.dir)) return py.path.local(str(self.invocation_params.dir))
def add_cleanup(self, func): def add_cleanup(self, func: Callable[[], None]) -> None:
""" Add a function to be called when the config object gets out of """ Add a function to be called when the config object gets out of
use (usually coninciding with pytest_unconfigure).""" use (usually coninciding with pytest_unconfigure)."""
self._cleanup.append(func) self._cleanup.append(func)
def _do_configure(self): def _do_configure(self) -> None:
assert not self._configured assert not self._configured
self._configured = True self._configured = True
with warnings.catch_warnings(): with warnings.catch_warnings():
warnings.simplefilter("default") warnings.simplefilter("default")
self.hook.pytest_configure.call_historic(kwargs=dict(config=self)) self.hook.pytest_configure.call_historic(kwargs=dict(config=self))
def _ensure_unconfigure(self): def _ensure_unconfigure(self) -> None:
if self._configured: if self._configured:
self._configured = False self._configured = False
self.hook.pytest_unconfigure(config=self) self.hook.pytest_unconfigure(config=self)
@ -864,10 +919,15 @@ class Config:
fin = self._cleanup.pop() fin = self._cleanup.pop()
fin() fin()
def get_terminal_writer(self): def get_terminal_writer(self) -> TerminalWriter:
return self.pluginmanager.get_plugin("terminalreporter")._tw terminalreporter = self.pluginmanager.get_plugin(
"terminalreporter"
) # type: TerminalReporter
return terminalreporter._tw
def pytest_cmdline_parse(self, pluginmanager, args): def pytest_cmdline_parse(
self, pluginmanager: PytestPluginManager, args: List[str]
) -> "Config":
try: try:
self.parse(args) self.parse(args)
except UsageError: except UsageError:
@ -891,9 +951,13 @@ class Config:
return self 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): if option and getattr(option, "fulltrace", False):
style = "long" style = "long" # type: _TracebackStyle
else: else:
style = "native" style = "native"
excrepr = excinfo.getrepr( excrepr = excinfo.getrepr(
@ -905,7 +969,7 @@ class Config:
sys.stderr.write("INTERNALERROR> %s\n" % line) sys.stderr.write("INTERNALERROR> %s\n" % line)
sys.stderr.flush() sys.stderr.flush()
def cwd_relative_nodeid(self, nodeid): def cwd_relative_nodeid(self, nodeid: str) -> str:
# nodeid's are relative to the rootpath, compute relative to cwd # nodeid's are relative to the rootpath, compute relative to cwd
if self.invocation_dir != self.rootdir: if self.invocation_dir != self.rootdir:
fullpath = self.rootdir.join(nodeid) fullpath = self.rootdir.join(nodeid)
@ -913,7 +977,7 @@ class Config:
return nodeid return nodeid
@classmethod @classmethod
def fromdictargs(cls, option_dict, args): def fromdictargs(cls, option_dict, args) -> "Config":
""" constructor usable for subprocesses. """ """ constructor usable for subprocesses. """
config = get_config(args) config = get_config(args)
config.option.__dict__.update(option_dict) config.option.__dict__.update(option_dict)
@ -931,24 +995,29 @@ class Config:
setattr(self.option, opt.dest, opt.default) setattr(self.option, opt.dest, opt.default)
@hookimpl(trylast=True) @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) self.pluginmanager._set_initial_conftests(early_config.known_args_namespace)
def _initini(self, args: Sequence[str]) -> None: def _initini(self, args: Sequence[str]) -> None:
ns, unknown_args = self._parser.parse_known_and_unknown_args( ns, unknown_args = self._parser.parse_known_and_unknown_args(
args, namespace=copy.copy(self.option) args, namespace=copy.copy(self.option)
) )
r = determine_setup( self.rootdir, self.inifile, self.inicfg = determine_setup(
ns.inifilename, ns.inifilename,
ns.file_or_dir + unknown_args, ns.file_or_dir + unknown_args,
rootdir_cmd_arg=ns.rootdir or None, rootdir_cmd_arg=ns.rootdir or None,
config=self, config=self,
) )
self.rootdir, self.inifile, self.inicfg = r
self._parser.extra_info["rootdir"] = self.rootdir self._parser.extra_info["rootdir"] = self.rootdir
self._parser.extra_info["inifile"] = self.inifile self._parser.extra_info["inifile"] = self.inifile
self._parser.addini("addopts", "extra command line options", "args") self._parser.addini("addopts", "extra command line options", "args")
self._parser.addini("minversion", "minimally required pytest version") 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 () self._override_ini = ns.override_ini or ()
def _consider_importhook(self, args: Sequence[str]) -> None: def _consider_importhook(self, args: Sequence[str]) -> None:
@ -971,7 +1040,7 @@ class Config:
self._mark_plugins_for_rewrite(hook) self._mark_plugins_for_rewrite(hook)
_warn_about_missing_assertion(mode) _warn_about_missing_assertion(mode)
def _mark_plugins_for_rewrite(self, hook): def _mark_plugins_for_rewrite(self, hook) -> None:
""" """
Given an importhook, mark for rewrite any top-level Given an importhook, mark for rewrite any top-level
modules or packages in the distribution package for modules or packages in the distribution package for
@ -1030,6 +1099,7 @@ class Config:
self.known_args_namespace = ns = self._parser.parse_known_args( self.known_args_namespace = ns = self._parser.parse_known_args(
args, namespace=copy.copy(self.option) args, namespace=copy.copy(self.option)
) )
self._validate_plugins()
if self.known_args_namespace.confcutdir is None and self.inifile: if self.known_args_namespace.confcutdir is None and self.inifile:
confcutdir = py.path.local(self.inifile).dirname confcutdir = py.path.local(self.inifile).dirname
self.known_args_namespace.confcutdir = confcutdir self.known_args_namespace.confcutdir = confcutdir
@ -1052,8 +1122,9 @@ class Config:
) )
else: else:
raise raise
self._validate_keys()
def _checkversion(self): def _checkversion(self) -> None:
import pytest import pytest
minver = self.inicfg.get("minversion", None) minver = self.inicfg.get("minversion", None)
@ -1061,17 +1132,62 @@ class Config:
# Imported lazily to improve start-up time. # Imported lazily to improve start-up time.
from packaging.version import Version from packaging.version import Version
if not isinstance(minver, str):
raise pytest.UsageError(
"%s: 'minversion' must be a single value" % self.inifile
)
if Version(minver) > Version(pytest.__version__): if Version(minver) > Version(pytest.__version__):
raise pytest.UsageError( raise pytest.UsageError(
"%s:%d: requires pytest-%s, actual pytest-%s'" "%s: 'minversion' requires pytest-%s, actual pytest-%s'"
% ( % (self.inifile, minver, pytest.__version__,)
self.inicfg.config.path,
self.inicfg.lineof("minversion"),
minver,
pytest.__version__,
)
) )
def _validate_keys(self) -> None:
for key in sorted(self._get_unknown_ini_keys()):
self._warn_or_fail_if_strict("Unknown config ini key: {}\n".format(key))
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:
spec = None
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:
fail(
"Missing required plugins: {}".format(", ".join(missing_plugins)),
pytrace=False,
)
def _warn_or_fail_if_strict(self, message: str) -> None:
if self.known_args_namespace.strict_config:
fail(message, pytrace=False)
sys.stderr.write("WARNING: {}".format(message))
def _get_unknown_ini_keys(self) -> List[str]:
parser_inicfg = self._parser._inidict
return [name for name in self.inicfg if name not in parser_inicfg]
def parse(self, args: List[str], addopts: bool = True) -> None: def parse(self, args: List[str], addopts: bool = True) -> None:
# parse given cmdline arguments into this config object. # parse given cmdline arguments into this config object.
assert not hasattr( assert not hasattr(
@ -1097,7 +1213,7 @@ class Config:
except PrintHelp: except PrintHelp:
pass pass
def addinivalue_line(self, name, line): def addinivalue_line(self, name: str, line: str) -> None:
""" add a line to an ini-file option. The option must have been """ 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 declared but might not yet be set in which case the line becomes the
the first line in its value. """ the first line in its value. """
@ -1106,7 +1222,7 @@ class Config:
x.append(line) # modifies the cached list inline x.append(line) # modifies the cached list inline
def getini(self, name: str): def getini(self, name: str):
""" return configuration value from an :ref:`ini file <inifiles>`. If the """ return configuration value from an :ref:`ini file <configfiles>`. If the
specified name hasn't been registered through a prior specified name hasn't been registered through a prior
:py:func:`parser.addini <_pytest.config.argparsing.Parser.addini>` :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. """
@ -1116,13 +1232,13 @@ class Config:
self._inicache[name] = val = self._getini(name) self._inicache[name] = val = self._getini(name)
return val return val
def _getini(self, name: str) -> Any: def _getini(self, name: str):
try: try:
description, type, default = self._parser._inidict[name] description, type, default = self._parser._inidict[name]
except KeyError: except KeyError as e:
raise ValueError("unknown configuration value: {!r}".format(name)) raise ValueError("unknown configuration value: {!r}".format(name)) from e
value = self._get_override_ini_value(name) override_value = self._get_override_ini_value(name)
if value is None: if override_value is None:
try: try:
value = self.inicfg[name] value = self.inicfg[name]
except KeyError: except KeyError:
@ -1131,29 +1247,52 @@ class Config:
if type is None: if type is None:
return "" return ""
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": if type == "pathlist":
dp = py.path.local(self.inicfg.config.path).dirpath() # TODO: This assert is probably not valid in all cases.
values = [] assert self.inifile is not None
for relpath in shlex.split(value): dp = py.path.local(self.inifile).dirpath()
values.append(dp.join(relpath, abs=True)) input_values = shlex.split(value) if isinstance(value, str) else value
return values return [dp.join(x, abs=True) for x in input_values]
elif type == "args": elif type == "args":
return shlex.split(value) return shlex.split(value) if isinstance(value, str) else value
elif type == "linelist": 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": elif type == "bool":
return bool(_strtobool(value.strip())) return _strtobool(str(value).strip())
else: else:
assert type is None assert type is None
return value return value
def _getconftest_pathlist(self, name, path): def _getconftest_pathlist(
self, name: str, path: py.path.local
) -> Optional[List[py.path.local]]:
try: try:
mod, relroots = self.pluginmanager._rget_with_confmod(name, path) mod, relroots = self.pluginmanager._rget_with_confmod(
name, path, self.getoption("importmode")
)
except KeyError: except KeyError:
return None return None
modpath = py.path.local(mod.__file__).dirpath() modpath = py.path.local(mod.__file__).dirpath()
values = [] values = [] # type: List[py.path.local]
for relroot in relroots: for relroot in relroots:
if not isinstance(relroot, py.path.local): if not isinstance(relroot, py.path.local):
relroot = relroot.replace("/", py.path.local.sep) relroot = relroot.replace("/", py.path.local.sep)
@ -1169,12 +1308,12 @@ class Config:
for ini_config in self._override_ini: for ini_config in self._override_ini:
try: try:
key, user_ini_value = ini_config.split("=", 1) key, user_ini_value = ini_config.split("=", 1)
except ValueError: except ValueError as e:
raise UsageError( raise UsageError(
"-o/--override-ini expects option=value style (got: {!r}).".format( "-o/--override-ini expects option=value style (got: {!r}).".format(
ini_config ini_config
) )
) ) from e
else: else:
if key == name: if key == name:
value = user_ini_value value = user_ini_value
@ -1195,25 +1334,25 @@ class Config:
if val is None and skip: if val is None and skip:
raise AttributeError(name) raise AttributeError(name)
return val return val
except AttributeError: except AttributeError as e:
if default is not notset: if default is not notset:
return default return default
if skip: if skip:
import pytest import pytest
pytest.skip("no {!r} option found".format(name)) pytest.skip("no {!r} option found".format(name))
raise ValueError("no option named {!r}".format(name)) raise ValueError("no option named {!r}".format(name)) from e
def getvalue(self, name, path=None): def getvalue(self, name: str, path=None):
""" (deprecated, use getoption()) """ """ (deprecated, use getoption()) """
return self.getoption(name) return self.getoption(name)
def getvalueorskip(self, name, path=None): def getvalueorskip(self, name: str, path=None):
""" (deprecated, use getoption(skip=True)) """ """ (deprecated, use getoption(skip=True)) """
return self.getoption(name, skip=True) return self.getoption(name, skip=True)
def _assertion_supported(): def _assertion_supported() -> bool:
try: try:
assert False assert False
except AssertionError: except AssertionError:
@ -1222,7 +1361,7 @@ def _assertion_supported():
return False return False
def _warn_about_missing_assertion(mode): def _warn_about_missing_assertion(mode) -> None:
if not _assertion_supported(): if not _assertion_supported():
if mode == "plain": if mode == "plain":
sys.stderr.write( sys.stderr.write(
@ -1240,21 +1379,28 @@ def _warn_about_missing_assertion(mode):
) )
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 """Create a TerminalWriter instance configured according to the options
in the config object. Every code which requires a TerminalWriter object in the config object. Every code which requires a TerminalWriter object
and has access to a config object should use this function. and has access to a config object should use this function.
""" """
tw = TerminalWriter(*args, **kwargs) tw = TerminalWriter(file=file)
if config.option.color == "yes": if config.option.color == "yes":
tw.hasmarkup = True tw.hasmarkup = True
if config.option.color == "no": elif config.option.color == "no":
tw.hasmarkup = False 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 return tw
def _strtobool(val): def _strtobool(val: str) -> bool:
"""Convert a string representation of truth to true (1) or false (0). """Convert a string representation of truth to True or False.
True values are 'y', 'yes', 't', 'true', 'on', and '1'; false values True values are 'y', 'yes', 't', 'true', 'on', and '1'; false values
are 'n', 'no', 'f', 'false', 'off', and '0'. Raises ValueError if are 'n', 'no', 'f', 'false', 'off', and '0'. Raises ValueError if
@ -1264,8 +1410,8 @@ def _strtobool(val):
""" """
val = val.lower() val = val.lower()
if val in ("y", "yes", "t", "true", "on", "1"): if val in ("y", "yes", "t", "true", "on", "1"):
return 1 return True
elif val in ("n", "no", "f", "false", "off", "0"): elif val in ("n", "no", "f", "false", "off", "0"):
return 0 return False
else: else:
raise ValueError("invalid truth value {!r}".format(val)) raise ValueError("invalid truth value {!r}".format(val))

View File

@ -265,9 +265,9 @@ class Argument:
else: else:
try: try:
self.dest = self._short_opts[0][1:] self.dest = self._short_opts[0][1:]
except IndexError: except IndexError as e:
self.dest = "???" # Needed for the error repr. 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]: def names(self) -> List[str]:
return self._short_opts + self._long_opts return self._short_opts + self._long_opts

View File

@ -1,10 +1,12 @@
import os import os
from typing import Any from typing import Dict
from typing import Iterable from typing import Iterable
from typing import List from typing import List
from typing import Optional from typing import Optional
from typing import Tuple from typing import Tuple
from typing import Union
import iniconfig
import py import py
from .exceptions import UsageError from .exceptions import UsageError
@ -15,52 +17,95 @@ if TYPE_CHECKING:
from . import Config from . import Config
def exists(path, ignore=OSError): def _parse_ini_config(path: py.path.local) -> iniconfig.IniConfig:
"""Parses the given generic '.ini' file using legacy IniConfig parser, returning
the parsed object.
Raises UsageError if the file cannot be parsed.
"""
try: try:
return path.check() return iniconfig.IniConfig(path)
except ignore: except iniconfig.ParseError as exc:
return False raise UsageError(str(exc)) from exc
def getcfg(args, config=None): def load_config_dict_from_file(
filepath: py.path.local,
) -> Optional[Dict[str, Union[str, List[str]]]]:
"""Loads pytest configuration from the given file path, if supported.
Return None if the file does not contain valid pytest configuration.
""" """
Search the list of arguments for a valid ini-file for pytest,
# configuration from ini files are obtained from the [pytest] section, if present.
if filepath.ext == ".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.basename == "pytest.ini":
return {}
# '.cfg' files are considered if they contain a "[tool:pytest]" section
elif filepath.ext == ".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.ext == ".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[Union[str, py.path.local]]
) -> Tuple[
Optional[py.path.local], Optional[py.path.local], 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). and return a tuple of (rootdir, inifile, cfg-dict).
note: config is optional and used only to issue warnings explicitly (#2891).
""" """
inibasenames = ["pytest.ini", "tox.ini", "setup.cfg"] config_names = [
"pytest.ini",
"pyproject.toml",
"tox.ini",
"setup.cfg",
]
args = [x for x in args if not str(x).startswith("-")] args = [x for x in args if not str(x).startswith("-")]
if not args: if not args:
args = [py.path.local()] args = [py.path.local()]
for arg in args: for arg in args:
arg = py.path.local(arg) arg = py.path.local(arg)
for base in arg.parts(reverse=True): for base in arg.parts(reverse=True):
for inibasename in inibasenames: for config_name in config_names:
p = base.join(inibasename) p = base.join(config_name)
if exists(p): if p.isfile():
try: ini_config = load_config_dict_from_file(p)
iniconfig = py.iniconfig.IniConfig(p) if ini_config is not None:
except py.iniconfig.ParseError as exc: return base, p, ini_config
raise UsageError(str(exc)) return None, None, {}
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: def get_common_ancestor(paths: Iterable[py.path.local]) -> py.path.local:
@ -116,29 +161,18 @@ def determine_setup(
args: List[str], args: List[str],
rootdir_cmd_arg: Optional[str] = None, rootdir_cmd_arg: Optional[str] = None,
config: Optional["Config"] = None, config: Optional["Config"] = None,
) -> Tuple[py.path.local, Optional[str], Any]: ) -> Tuple[py.path.local, Optional[py.path.local], Dict[str, Union[str, List[str]]]]:
rootdir = None
dirs = get_dirs_from_args(args) dirs = get_dirs_from_args(args)
if inifile: if inifile:
iniconfig = py.iniconfig.IniConfig(inifile) inipath_ = py.path.local(inifile)
is_cfg_file = str(inifile).endswith(".cfg") inipath = inipath_ # type: Optional[py.path.local]
sections = ["tool:pytest", "pytest"] if is_cfg_file else ["pytest"] inicfg = load_config_dict_from_file(inipath_) or {}
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
if rootdir_cmd_arg is None: if rootdir_cmd_arg is None:
rootdir = get_common_ancestor(dirs) rootdir = get_common_ancestor(dirs)
else: else:
ancestor = get_common_ancestor(dirs) 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: if rootdir is None and rootdir_cmd_arg is None:
for possible_rootdir in ancestor.parts(reverse=True): for possible_rootdir in ancestor.parts(reverse=True):
if possible_rootdir.join("setup.py").exists(): if possible_rootdir.join("setup.py").exists():
@ -146,7 +180,7 @@ def determine_setup(
break break
else: else:
if dirs != [ancestor]: if dirs != [ancestor]:
rootdir, inifile, inicfg = getcfg(dirs, config=config) rootdir, inipath, inicfg = locate_config(dirs)
if rootdir is None: if rootdir is None:
if config is not None: if config is not None:
cwd = config.invocation_dir cwd = config.invocation_dir
@ -164,4 +198,5 @@ def determine_setup(
rootdir rootdir
) )
) )
return rootdir, inifile, inicfg or {} assert rootdir is not None
return rootdir, inipath, inicfg or {}

View File

@ -2,25 +2,40 @@
import argparse import argparse
import functools import functools
import sys import sys
import types
from typing import Generator
from typing import Tuple
from typing import Union
from _pytest import outcomes from _pytest import outcomes
from _pytest._code import ExceptionInfo
from _pytest.compat import TYPE_CHECKING
from _pytest.config import Config
from _pytest.config import ConftestImportFailure from _pytest.config import ConftestImportFailure
from _pytest.config import hookimpl from _pytest.config import hookimpl
from _pytest.config import PytestPluginManager
from _pytest.config.argparsing import Parser
from _pytest.config.exceptions import UsageError 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.""" """Validate syntax of --pdbcls option."""
try: try:
modname, classname = value.split(":") modname, classname = value.split(":")
except ValueError: except ValueError as e:
raise argparse.ArgumentTypeError( raise argparse.ArgumentTypeError(
"{!r} is not in the format 'modname:classname'".format(value) "{!r} is not in the format 'modname:classname'".format(value)
) ) from e
return (modname, classname) return (modname, classname)
def pytest_addoption(parser): def pytest_addoption(parser: Parser) -> None:
group = parser.getgroup("general") group = parser.getgroup("general")
group._addoption( group._addoption(
"--pdb", "--pdb",
@ -44,7 +59,7 @@ def pytest_addoption(parser):
) )
def pytest_configure(config): def pytest_configure(config: Config) -> None:
import pdb import pdb
if config.getvalue("trace"): if config.getvalue("trace"):
@ -61,7 +76,7 @@ def pytest_configure(config):
# NOTE: not using pytest_unconfigure, since it might get called although # NOTE: not using pytest_unconfigure, since it might get called although
# pytest_configure was not (if another plugin raises UsageError). # pytest_configure was not (if another plugin raises UsageError).
def fin(): def fin() -> None:
( (
pdb.set_trace, pdb.set_trace,
pytestPDB._pluginmanager, pytestPDB._pluginmanager,
@ -74,20 +89,20 @@ def pytest_configure(config):
class pytestPDB: class pytestPDB:
""" Pseudo PDB that defers to the real pdb. """ """ Pseudo PDB that defers to the real pdb. """
_pluginmanager = None _pluginmanager = None # type: PytestPluginManager
_config = None _config = None # type: Config
_saved = [] # type: list _saved = [] # type: list
_recursive_debug = 0 _recursive_debug = 0
_wrapped_pdb_cls = None _wrapped_pdb_cls = None
@classmethod @classmethod
def _is_capturing(cls, capman): def _is_capturing(cls, capman: "CaptureManager") -> Union[str, bool]:
if capman: if capman:
return capman.is_capturing() return capman.is_capturing()
return False return False
@classmethod @classmethod
def _import_pdb_cls(cls, capman): def _import_pdb_cls(cls, capman: "CaptureManager"):
if not cls._config: if not cls._config:
import pdb import pdb
@ -115,7 +130,7 @@ class pytestPDB:
value = ":".join((modname, classname)) value = ":".join((modname, classname))
raise UsageError( raise UsageError(
"--pdbcls: could not import {!r}: {}".format(value, exc) "--pdbcls: could not import {!r}: {}".format(value, exc)
) ) from exc
else: else:
import pdb import pdb
@ -126,10 +141,12 @@ class pytestPDB:
return wrapped_cls return wrapped_cls
@classmethod @classmethod
def _get_pdb_wrapper_class(cls, pdb_cls, capman): def _get_pdb_wrapper_class(cls, pdb_cls, capman: "CaptureManager"):
import _pytest.config 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] # noqa: F821
_pytest_capman = capman _pytest_capman = capman
_continued = False _continued = False
@ -248,7 +265,7 @@ class pytestPDB:
return _pdb return _pdb
@classmethod @classmethod
def set_trace(cls, *args, **kwargs): def set_trace(cls, *args, **kwargs) -> None:
"""Invoke debugging via ``Pdb.set_trace``, dropping any IO capturing.""" """Invoke debugging via ``Pdb.set_trace``, dropping any IO capturing."""
frame = sys._getframe().f_back frame = sys._getframe().f_back
_pdb = cls._init_pdb("set_trace", *args, **kwargs) _pdb = cls._init_pdb("set_trace", *args, **kwargs)
@ -256,23 +273,26 @@ class pytestPDB:
class PdbInvoke: class PdbInvoke:
def pytest_exception_interact(self, node, call, report): def pytest_exception_interact(
self, node: Node, call: "CallInfo", report: BaseReport
) -> None:
capman = node.config.pluginmanager.getplugin("capturemanager") capman = node.config.pluginmanager.getplugin("capturemanager")
if capman: if capman:
capman.suspend_global_capture(in_=True) capman.suspend_global_capture(in_=True)
out, err = capman.read_global_capture() out, err = capman.read_global_capture()
sys.stdout.write(out) sys.stdout.write(out)
sys.stdout.write(err) sys.stdout.write(err)
assert call.excinfo is not None
_enter_pdb(node, call.excinfo, report) _enter_pdb(node, call.excinfo, report)
def pytest_internalerror(self, excrepr, excinfo): def pytest_internalerror(self, excinfo: ExceptionInfo[BaseException]) -> None:
tb = _postmortem_traceback(excinfo) tb = _postmortem_traceback(excinfo)
post_mortem(tb) post_mortem(tb)
class PdbTrace: class PdbTrace:
@hookimpl(hookwrapper=True) @hookimpl(hookwrapper=True)
def pytest_pyfunc_call(self, pyfuncitem): def pytest_pyfunc_call(self, pyfuncitem) -> Generator[None, None, None]:
wrap_pytest_function_for_tracing(pyfuncitem) wrap_pytest_function_for_tracing(pyfuncitem)
yield yield
@ -303,7 +323,9 @@ def maybe_wrap_pytest_function_for_tracing(pyfuncitem):
wrap_pytest_function_for_tracing(pyfuncitem) wrap_pytest_function_for_tracing(pyfuncitem)
def _enter_pdb(node, excinfo, rep): def _enter_pdb(
node: Node, excinfo: ExceptionInfo[BaseException], rep: BaseReport
) -> BaseReport:
# XXX we re-use the TerminalReporter's terminalwriter # XXX we re-use the TerminalReporter's terminalwriter
# because this seems to avoid some encoding related troubles # because this seems to avoid some encoding related troubles
# for not completely clear reasons. # for not completely clear reasons.
@ -327,12 +349,12 @@ def _enter_pdb(node, excinfo, rep):
rep.toterminal(tw) rep.toterminal(tw)
tw.sep(">", "entering PDB") tw.sep(">", "entering PDB")
tb = _postmortem_traceback(excinfo) tb = _postmortem_traceback(excinfo)
rep._pdbshown = True rep._pdbshown = True # type: ignore[attr-defined] # noqa: F821
post_mortem(tb) post_mortem(tb)
return rep return rep
def _postmortem_traceback(excinfo): def _postmortem_traceback(excinfo: ExceptionInfo[BaseException]) -> types.TracebackType:
from doctest import UnexpectedException from doctest import UnexpectedException
if isinstance(excinfo.value, UnexpectedException): if isinstance(excinfo.value, UnexpectedException):
@ -344,10 +366,11 @@ def _postmortem_traceback(excinfo):
# Use the underlying exception instead: # Use the underlying exception instead:
return excinfo.value.excinfo[2] return excinfo.value.excinfo[2]
else: else:
assert excinfo._excinfo is not None
return excinfo._excinfo[2] return excinfo._excinfo[2]
def post_mortem(t): def post_mortem(t: types.TracebackType) -> None:
p = pytestPDB._init_pdb("post_mortem") p = pytestPDB._init_pdb("post_mortem")
p.reset() p.reset()
p.interaction(None, t) p.interaction(None, t)

View File

@ -4,11 +4,17 @@ import inspect
import platform import platform
import sys import sys
import traceback import traceback
import types
import warnings import warnings
from contextlib import contextmanager from contextlib import contextmanager
from typing import Any
from typing import Callable
from typing import Dict from typing import Dict
from typing import Generator
from typing import Iterable
from typing import List from typing import List
from typing import Optional from typing import Optional
from typing import Pattern
from typing import Sequence from typing import Sequence
from typing import Tuple from typing import Tuple
from typing import Union from typing import Union
@ -23,8 +29,11 @@ from _pytest._code.code import TerminalRepr
from _pytest._io import TerminalWriter from _pytest._io import TerminalWriter
from _pytest.compat import safe_getattr from _pytest.compat import safe_getattr
from _pytest.compat import TYPE_CHECKING from _pytest.compat import TYPE_CHECKING
from _pytest.config import Config
from _pytest.config.argparsing import Parser
from _pytest.fixtures import FixtureRequest from _pytest.fixtures import FixtureRequest
from _pytest.outcomes import OutcomeException from _pytest.outcomes import OutcomeException
from _pytest.pathlib import import_path
from _pytest.python_api import approx from _pytest.python_api import approx
from _pytest.warning_types import PytestWarning from _pytest.warning_types import PytestWarning
@ -52,7 +61,7 @@ RUNNER_CLASS = None
CHECKER_CLASS = None # type: Optional[Type[doctest.OutputChecker]] CHECKER_CLASS = None # type: Optional[Type[doctest.OutputChecker]]
def pytest_addoption(parser): def pytest_addoption(parser: Parser) -> None:
parser.addini( parser.addini(
"doctest_optionflags", "doctest_optionflags",
"option flags for doctests", "option flags for doctests",
@ -102,19 +111,24 @@ def pytest_addoption(parser):
) )
def pytest_unconfigure(): def pytest_unconfigure() -> None:
global RUNNER_CLASS global RUNNER_CLASS
RUNNER_CLASS = None RUNNER_CLASS = None
def pytest_collect_file(path: py.path.local, parent): def pytest_collect_file(
path: py.path.local, parent
) -> Optional[Union["DoctestModule", "DoctestTextfile"]]:
config = parent.config config = parent.config
if path.ext == ".py": if path.ext == ".py":
if config.option.doctestmodules and not _is_setup_py(path): if config.option.doctestmodules and not _is_setup_py(path):
return DoctestModule.from_parent(parent, fspath=path) mod = DoctestModule.from_parent(parent, fspath=path) # type: DoctestModule
return mod
elif _is_doctest(config, path, parent): elif _is_doctest(config, path, parent):
return DoctestTextfile.from_parent(parent, fspath=path) txt = DoctestTextfile.from_parent(parent, fspath=path) # type: DoctestTextfile
return txt
return None
def _is_setup_py(path: py.path.local) -> bool: def _is_setup_py(path: py.path.local) -> bool:
@ -124,7 +138,7 @@ def _is_setup_py(path: py.path.local) -> bool:
return b"setuptools" in contents or b"distutils" in contents 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): if path.ext in (".txt", ".rst") and parent.session.isinitpath(path):
return True return True
globs = config.getoption("doctestglob") or ["test*.txt"] globs = config.getoption("doctestglob") or ["test*.txt"]
@ -137,7 +151,7 @@ def _is_doctest(config, path, parent):
class ReprFailDoctest(TerminalRepr): class ReprFailDoctest(TerminalRepr):
def __init__( def __init__(
self, reprlocation_lines: Sequence[Tuple[ReprFileLocation, Sequence[str]]] self, reprlocation_lines: Sequence[Tuple[ReprFileLocation, Sequence[str]]]
): ) -> None:
self.reprlocation_lines = reprlocation_lines self.reprlocation_lines = reprlocation_lines
def toterminal(self, tw: TerminalWriter) -> None: def toterminal(self, tw: TerminalWriter) -> None:
@ -148,7 +162,7 @@ class ReprFailDoctest(TerminalRepr):
class MultipleDoctestFailures(Exception): class MultipleDoctestFailures(Exception):
def __init__(self, failures): def __init__(self, failures: "Sequence[doctest.DocTestFailure]") -> None:
super().__init__() super().__init__()
self.failures = failures self.failures = failures
@ -163,21 +177,33 @@ def _init_runner_class() -> "Type[doctest.DocTestRunner]":
""" """
def __init__( 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__( doctest.DebugRunner.__init__(
self, checker=checker, verbose=verbose, optionflags=optionflags self, checker=checker, verbose=verbose, optionflags=optionflags
) )
self.continue_on_failure = continue_on_failure 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) failure = doctest.DocTestFailure(test, example, got)
if self.continue_on_failure: if self.continue_on_failure:
out.append(failure) out.append(failure)
else: else:
raise failure 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): if isinstance(exc_info[1], OutcomeException):
raise exc_info[1] raise exc_info[1]
if isinstance(exc_info[1], bdb.BdbQuit): if isinstance(exc_info[1], bdb.BdbQuit):
@ -212,16 +238,27 @@ def _get_runner(
class DoctestItem(pytest.Item): 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) super().__init__(name, parent)
self.runner = runner self.runner = runner
self.dtest = dtest self.dtest = dtest
self.obj = None self.obj = None
self.fixture_request = None self.fixture_request = None # type: Optional[FixtureRequest]
@classmethod @classmethod
def from_parent( # type: ignore 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 # incompatible signature due to to imposed limits on sublcass
""" """
@ -229,7 +266,7 @@ class DoctestItem(pytest.Item):
""" """
return super().from_parent(name=name, parent=parent, runner=runner, dtest=dtest) 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: if self.dtest is not None:
self.fixture_request = _setup_fixtures(self) self.fixture_request = _setup_fixtures(self)
globs = dict(getfixture=self.fixture_request.getfixturevalue) globs = dict(getfixture=self.fixture_request.getfixturevalue)
@ -240,14 +277,18 @@ class DoctestItem(pytest.Item):
self.dtest.globs.update(globs) self.dtest.globs.update(globs)
def runtest(self) -> None: def runtest(self) -> None:
assert self.dtest is not None
assert self.runner is not None
_check_all_skipped(self.dtest) _check_all_skipped(self.dtest)
self._disable_output_capturing_for_darwin() self._disable_output_capturing_for_darwin()
failures = [] # type: List[doctest.DocTestFailure] failures = [] # type: List[doctest.DocTestFailure]
self.runner.run(self.dtest, out=failures) # Type ignored because we change the type of `out` from what
# doctest expects.
self.runner.run(self.dtest, out=failures) # type: ignore[arg-type] # noqa: F821
if failures: if failures:
raise MultipleDoctestFailures(failures) raise MultipleDoctestFailures(failures)
def _disable_output_capturing_for_darwin(self): def _disable_output_capturing_for_darwin(self) -> None:
""" """
Disable output capturing. Otherwise, stdout is lost to doctest (#985) Disable output capturing. Otherwise, stdout is lost to doctest (#985)
""" """
@ -260,15 +301,20 @@ class DoctestItem(pytest.Item):
sys.stdout.write(out) sys.stdout.write(out)
sys.stderr.write(err) sys.stderr.write(err)
def repr_failure(self, excinfo): # TODO: Type ignored -- breaks Liskov Substitution.
def repr_failure( # type: ignore[override] # noqa: F821
self, excinfo: ExceptionInfo[BaseException],
) -> Union[str, TerminalRepr]:
import doctest import doctest
failures = ( failures = (
None None
) # type: Optional[List[Union[doctest.DocTestFailure, doctest.UnexpectedException]]] ) # type: Optional[Sequence[Union[doctest.DocTestFailure, doctest.UnexpectedException]]]
if excinfo.errisinstance((doctest.DocTestFailure, doctest.UnexpectedException)): if isinstance(
excinfo.value, (doctest.DocTestFailure, doctest.UnexpectedException)
):
failures = [excinfo.value] failures = [excinfo.value]
elif excinfo.errisinstance(MultipleDoctestFailures): elif isinstance(excinfo.value, MultipleDoctestFailures):
failures = excinfo.value.failures failures = excinfo.value.failures
if failures is not None: if failures is not None:
@ -282,7 +328,8 @@ class DoctestItem(pytest.Item):
else: else:
lineno = test.lineno + example.lineno + 1 lineno = test.lineno + example.lineno + 1
message = type(failure).__name__ 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] # noqa: F821
checker = _get_checker() checker = _get_checker()
report_choice = _get_report_choice( report_choice = _get_report_choice(
self.config.getoption("doctestreport") self.config.getoption("doctestreport")
@ -322,7 +369,8 @@ class DoctestItem(pytest.Item):
else: else:
return super().repr_failure(excinfo) 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 return self.fspath, self.dtest.lineno, "[doctest] %s" % self.name
@ -364,7 +412,7 @@ def _get_continue_on_failure(config):
class DoctestTextfile(pytest.Module): class DoctestTextfile(pytest.Module):
obj = None obj = None
def collect(self): def collect(self) -> Iterable[DoctestItem]:
import doctest import doctest
# inspired by doctest.testfile; ideally we would use it directly, # inspired by doctest.testfile; ideally we would use it directly,
@ -392,7 +440,7 @@ class DoctestTextfile(pytest.Module):
) )
def _check_all_skipped(test): def _check_all_skipped(test: "doctest.DocTest") -> None:
"""raises pytest.skip() if all examples in the given DocTest have the SKIP """raises pytest.skip() if all examples in the given DocTest have the SKIP
option set. option set.
""" """
@ -403,7 +451,7 @@ def _check_all_skipped(test):
pytest.skip("all tests skipped by +SKIP option") pytest.skip("all tests skipped by +SKIP option")
def _is_mocked(obj): def _is_mocked(obj: object) -> bool:
""" """
returns if a object is possibly a mock object by checking the existence of a highly improbable attribute returns if a object is possibly a mock object by checking the existence of a highly improbable attribute
""" """
@ -414,23 +462,26 @@ def _is_mocked(obj):
@contextmanager @contextmanager
def _patch_unwrap_mock_aware(): def _patch_unwrap_mock_aware() -> Generator[None, None, None]:
""" """
contextmanager which replaces ``inspect.unwrap`` with a version contextmanager which replaces ``inspect.unwrap`` with a version
that's aware of mock objects and doesn't recurse on them that's aware of mock objects and doesn't recurse on them
""" """
real_unwrap = inspect.unwrap 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: try:
if stop is None or stop is _is_mocked: if stop is None or stop is _is_mocked:
return real_unwrap(obj, stop=_is_mocked) return real_unwrap(func, stop=_is_mocked)
return real_unwrap(obj, stop=lambda obj: _is_mocked(obj) or stop(obj)) _stop = stop
return real_unwrap(func, stop=lambda obj: _is_mocked(obj) or _stop(func))
except Exception as e: except Exception as e:
warnings.warn( warnings.warn(
"Got %r when unwrapping %r. This is usually caused " "Got %r when unwrapping %r. This is usually caused "
"by a violation of Python's object protocol; see e.g. " "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, PytestWarning,
) )
raise raise
@ -443,7 +494,7 @@ def _patch_unwrap_mock_aware():
class DoctestModule(pytest.Module): class DoctestModule(pytest.Module):
def collect(self): def collect(self) -> Iterable[DoctestItem]:
import doctest import doctest
class MockAwareDocTestFinder(doctest.DocTestFinder): class MockAwareDocTestFinder(doctest.DocTestFinder):
@ -462,7 +513,10 @@ class DoctestModule(pytest.Module):
""" """
if isinstance(obj, property): if isinstance(obj, property):
obj = getattr(obj, "fget", obj) 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( def _find(
self, tests, obj, name, module, source_lines, globs, seen self, tests, obj, name, module, source_lines, globs, seen
@ -477,10 +531,12 @@ class DoctestModule(pytest.Module):
) )
if self.fspath.basename == "conftest.py": 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: else:
try: try:
module = self.fspath.pyimport() module = import_path(self.fspath)
except ImportError: except ImportError:
if self.config.getvalue("doctest_ignore_import_errors"): if self.config.getvalue("doctest_ignore_import_errors"):
pytest.skip("unable to import module %r" % self.fspath) pytest.skip("unable to import module %r" % self.fspath)
@ -503,17 +559,17 @@ class DoctestModule(pytest.Module):
) )
def _setup_fixtures(doctest_item): def _setup_fixtures(doctest_item: DoctestItem) -> FixtureRequest:
""" """
Used by DoctestTextfile and DoctestItem to setup fixture information. Used by DoctestTextfile and DoctestItem to setup fixture information.
""" """
def func(): def func() -> None:
pass pass
doctest_item.funcargs = {} doctest_item.funcargs = {} # type: ignore[attr-defined] # noqa: F821
fm = doctest_item.session._fixturemanager fm = doctest_item.session._fixturemanager
doctest_item._fixtureinfo = fm.getfixtureinfo( doctest_item._fixtureinfo = fm.getfixtureinfo( # type: ignore[attr-defined] # noqa: F821
node=doctest_item, func=func, cls=None, funcargs=False node=doctest_item, func=func, cls=None, funcargs=False
) )
fixture_request = FixtureRequest(doctest_item) fixture_request = FixtureRequest(doctest_item)
@ -557,7 +613,7 @@ def _init_checker_class() -> "Type[doctest.OutputChecker]":
re.VERBOSE, 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): if doctest.OutputChecker.check_output(self, want, got, optionflags):
return True return True
@ -568,7 +624,7 @@ def _init_checker_class() -> "Type[doctest.OutputChecker]":
if not allow_unicode and not allow_bytes and not allow_number: if not allow_unicode and not allow_bytes and not allow_number:
return False return False
def remove_prefixes(regex, txt): def remove_prefixes(regex: Pattern[str], txt: str) -> str:
return re.sub(regex, r"\1\2", txt) return re.sub(regex, r"\1\2", txt)
if allow_unicode: if allow_unicode:
@ -584,7 +640,7 @@ def _init_checker_class() -> "Type[doctest.OutputChecker]":
return doctest.OutputChecker.check_output(self, want, got, optionflags) 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)) wants = list(self._number_re.finditer(want))
gots = list(self._number_re.finditer(got)) gots = list(self._number_re.finditer(got))
if len(wants) != len(gots): if len(wants) != len(gots):
@ -679,7 +735,7 @@ def _get_report_choice(key: str) -> int:
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
def doctest_namespace(): def doctest_namespace() -> Dict[str, Any]:
""" """
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.
""" """

View File

@ -1,16 +1,20 @@
import io import io
import os import os
import sys import sys
from typing import Generator
from typing import TextIO from typing import TextIO
import pytest import pytest
from _pytest.config import Config
from _pytest.config.argparsing import Parser
from _pytest.nodes import Item
from _pytest.store import StoreKey from _pytest.store import StoreKey
fault_handler_stderr_key = StoreKey[TextIO]() fault_handler_stderr_key = StoreKey[TextIO]()
def pytest_addoption(parser): def pytest_addoption(parser: Parser) -> None:
help = ( help = (
"Dump the traceback of all threads if a test takes " "Dump the traceback of all threads if a test takes "
"more than TIMEOUT seconds to finish." "more than TIMEOUT seconds to finish."
@ -18,7 +22,7 @@ def pytest_addoption(parser):
parser.addini("faulthandler_timeout", help, default=0.0) parser.addini("faulthandler_timeout", help, default=0.0)
def pytest_configure(config): def pytest_configure(config: Config) -> None:
import faulthandler import faulthandler
if not faulthandler.is_enabled(): if not faulthandler.is_enabled():
@ -46,14 +50,14 @@ class FaultHandlerHooks:
"""Implements hooks that will actually install fault handler before tests execute, """Implements hooks that will actually install fault handler before tests execute,
as well as correctly handle pdb and internal errors.""" as well as correctly handle pdb and internal errors."""
def pytest_configure(self, config): def pytest_configure(self, config: Config) -> None:
import faulthandler import faulthandler
stderr_fd_copy = os.dup(self._get_stderr_fileno()) stderr_fd_copy = os.dup(self._get_stderr_fileno())
config._store[fault_handler_stderr_key] = open(stderr_fd_copy, "w") config._store[fault_handler_stderr_key] = open(stderr_fd_copy, "w")
faulthandler.enable(file=config._store[fault_handler_stderr_key]) faulthandler.enable(file=config._store[fault_handler_stderr_key])
def pytest_unconfigure(self, config): def pytest_unconfigure(self, config: Config) -> None:
import faulthandler import faulthandler
faulthandler.disable() faulthandler.disable()
@ -80,7 +84,7 @@ class FaultHandlerHooks:
return float(config.getini("faulthandler_timeout") or 0.0) return float(config.getini("faulthandler_timeout") or 0.0)
@pytest.hookimpl(hookwrapper=True, trylast=True) @pytest.hookimpl(hookwrapper=True, trylast=True)
def pytest_runtest_protocol(self, item): def pytest_runtest_protocol(self, item: Item) -> Generator[None, None, None]:
timeout = self.get_timeout_config_value(item.config) timeout = self.get_timeout_config_value(item.config)
stderr = item.config._store[fault_handler_stderr_key] stderr = item.config._store[fault_handler_stderr_key]
if timeout > 0 and stderr is not None: if timeout > 0 and stderr is not None:
@ -95,7 +99,7 @@ class FaultHandlerHooks:
yield yield
@pytest.hookimpl(tryfirst=True) @pytest.hookimpl(tryfirst=True)
def pytest_enter_pdb(self): def pytest_enter_pdb(self) -> None:
"""Cancel any traceback dumping due to timeout before entering pdb. """Cancel any traceback dumping due to timeout before entering pdb.
""" """
import faulthandler import faulthandler
@ -103,7 +107,7 @@ class FaultHandlerHooks:
faulthandler.cancel_dump_traceback_later() faulthandler.cancel_dump_traceback_later()
@pytest.hookimpl(tryfirst=True) @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 """Cancel any traceback dumping due to an interactive exception being
raised. raised.
""" """

File diff suppressed because it is too large Load Diff

View File

@ -2,9 +2,13 @@
Provides a function to report all internal modules for using freezing tools Provides a function to report all internal modules for using freezing tools
pytest pytest
""" """
import types
from typing import Iterator
from typing import List
from typing import Union
def freeze_includes(): def freeze_includes() -> List[str]:
""" """
Returns a list of module names used by pytest that should be Returns a list of module names used by pytest that should be
included by cx_freeze. included by cx_freeze.
@ -17,7 +21,9 @@ def freeze_includes():
return result return result
def _iter_all_modules(package, prefix=""): def _iter_all_modules(
package: Union[str, types.ModuleType], prefix: str = "",
) -> Iterator[str]:
""" """
Iterates over the names of all modules that can be found in the given Iterates over the names of all modules that can be found in the given
package, recursively. package, recursively.
@ -29,10 +35,13 @@ def _iter_all_modules(package, prefix=""):
import os import os
import pkgutil import pkgutil
if type(package) is not str: if isinstance(package, str):
path, prefix = package.__path__[0], package.__name__ + "."
else:
path = package 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]): for _, name, is_package in pkgutil.iter_modules([path]):
if is_package: if is_package:
for m in _iter_all_modules(os.path.join(path, name), prefix=name + "."): for m in _iter_all_modules(os.path.join(path, name), prefix=name + "."):

View File

@ -2,11 +2,17 @@
import os import os
import sys import sys
from argparse import Action from argparse import Action
from typing import List
from typing import Optional
from typing import Union
import py import py
import pytest import pytest
from _pytest.config import Config
from _pytest.config import ExitCode
from _pytest.config import PrintHelp from _pytest.config import PrintHelp
from _pytest.config.argparsing import Parser
class HelpAction(Action): class HelpAction(Action):
@ -36,7 +42,7 @@ class HelpAction(Action):
raise PrintHelp raise PrintHelp
def pytest_addoption(parser): def pytest_addoption(parser: Parser) -> None:
group = parser.getgroup("debugconfig") group = parser.getgroup("debugconfig")
group.addoption( group.addoption(
"--version", "--version",
@ -90,7 +96,7 @@ def pytest_addoption(parser):
@pytest.hookimpl(hookwrapper=True) @pytest.hookimpl(hookwrapper=True)
def pytest_cmdline_parse(): def pytest_cmdline_parse():
outcome = yield outcome = yield
config = outcome.get_result() config = outcome.get_result() # type: Config
if config.option.debug: if config.option.debug:
path = os.path.abspath("pytestdebug.log") path = os.path.abspath("pytestdebug.log")
debugfile = open(path, "w") debugfile = open(path, "w")
@ -109,7 +115,7 @@ def pytest_cmdline_parse():
undo_tracing = config.pluginmanager.enable_tracing() undo_tracing = config.pluginmanager.enable_tracing()
sys.stderr.write("writing pytestdebug information to %s\n" % path) sys.stderr.write("writing pytestdebug information to %s\n" % path)
def unset_tracing(): def unset_tracing() -> None:
debugfile.close() debugfile.close()
sys.stderr.write("wrote pytestdebug information to %s\n" % debugfile.name) sys.stderr.write("wrote pytestdebug information to %s\n" % debugfile.name)
config.trace.root.setwriter(None) config.trace.root.setwriter(None)
@ -118,7 +124,7 @@ def pytest_cmdline_parse():
config.add_cleanup(unset_tracing) config.add_cleanup(unset_tracing)
def showversion(config): def showversion(config: Config) -> None:
if config.option.version > 1: if config.option.version > 1:
sys.stderr.write( sys.stderr.write(
"This is pytest version {}, imported from {}\n".format( "This is pytest version {}, imported from {}\n".format(
@ -133,7 +139,7 @@ def showversion(config):
sys.stderr.write("pytest {}\n".format(pytest.__version__)) sys.stderr.write("pytest {}\n".format(pytest.__version__))
def pytest_cmdline_main(config): def pytest_cmdline_main(config: Config) -> Optional[Union[int, ExitCode]]:
if config.option.version > 0: if config.option.version > 0:
showversion(config) showversion(config)
return 0 return 0
@ -142,9 +148,10 @@ def pytest_cmdline_main(config):
showhelp(config) showhelp(config)
config._ensure_unconfigure() config._ensure_unconfigure()
return 0 return 0
return None
def showhelp(config): def showhelp(config: Config) -> None:
import textwrap import textwrap
reporter = config.pluginmanager.get_plugin("terminalreporter") reporter = config.pluginmanager.get_plugin("terminalreporter")
@ -217,7 +224,7 @@ def showhelp(config):
conftest_options = [("pytest_plugins", "list of plugin names to load")] conftest_options = [("pytest_plugins", "list of plugin names to load")]
def getpluginversioninfo(config): def getpluginversioninfo(config: Config) -> List[str]:
lines = [] lines = []
plugininfo = config.pluginmanager.list_plugin_distinfo() plugininfo = config.pluginmanager.list_plugin_distinfo()
if plugininfo: if plugininfo:
@ -229,7 +236,7 @@ def getpluginversioninfo(config):
return lines return lines
def pytest_report_header(config): def pytest_report_header(config: Config) -> List[str]:
lines = [] lines = []
if config.option.debug or config.option.traceconfig: if config.option.debug or config.option.traceconfig:
lines.append( lines.append(

View File

@ -1,10 +1,14 @@
""" hook specifications for pytest plugins, invoked from main.py and builtin plugins. """ """ hook specifications for pytest plugins, invoked from main.py and builtin plugins. """
from typing import Any from typing import Any
from typing import Dict
from typing import List
from typing import Mapping from typing import Mapping
from typing import Optional from typing import Optional
from typing import Sequence
from typing import Tuple from typing import Tuple
from typing import Union from typing import Union
import py.path
from pluggy import HookspecMarker from pluggy import HookspecMarker
from .deprecated import COLLECT_DIRECTORY_HOOK from .deprecated import COLLECT_DIRECTORY_HOOK
@ -12,10 +16,32 @@ from .deprecated import WARNING_CAPTURED_HOOK
from _pytest.compat import TYPE_CHECKING from _pytest.compat import TYPE_CHECKING
if TYPE_CHECKING: if TYPE_CHECKING:
import pdb
import warnings 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 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.main import Session
from _pytest.reports import BaseReport from _pytest.nodes import Collector
from _pytest.nodes import Item
from _pytest.nodes import Node
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") hookspec = HookspecMarker("pytest")
@ -26,7 +52,7 @@ hookspec = HookspecMarker("pytest")
@hookspec(historic=True) @hookspec(historic=True)
def pytest_addhooks(pluginmanager): def pytest_addhooks(pluginmanager: "PytestPluginManager") -> None:
"""called at plugin registration time to allow adding new hooks via a call to """called at plugin registration time to allow adding new hooks via a call to
``pluginmanager.add_hookspecs(module_or_class, prefix)``. ``pluginmanager.add_hookspecs(module_or_class, prefix)``.
@ -39,7 +65,9 @@ def pytest_addhooks(pluginmanager):
@hookspec(historic=True) @hookspec(historic=True)
def pytest_plugin_registered(plugin, manager): def pytest_plugin_registered(
plugin: "_PluggyPlugin", manager: "PytestPluginManager"
) -> None:
""" a new pytest plugin got registered. """ a new pytest plugin got registered.
:param plugin: the plugin module or instance :param plugin: the plugin module or instance
@ -51,7 +79,7 @@ def pytest_plugin_registered(plugin, manager):
@hookspec(historic=True) @hookspec(historic=True)
def pytest_addoption(parser, pluginmanager): def pytest_addoption(parser: "Parser", pluginmanager: "PytestPluginManager") -> None:
"""register argparse-style options and ini-style config values, """register argparse-style options and ini-style config values,
called once at the beginning of a test run. called once at the beginning of a test run.
@ -89,7 +117,7 @@ def pytest_addoption(parser, pluginmanager):
@hookspec(historic=True) @hookspec(historic=True)
def pytest_configure(config): def pytest_configure(config: "Config") -> None:
""" """
Allows plugins and conftest files to perform initial configuration. Allows plugins and conftest files to perform initial configuration.
@ -113,7 +141,9 @@ def pytest_configure(config):
@hookspec(firstresult=True) @hookspec(firstresult=True)
def pytest_cmdline_parse(pluginmanager, args): def pytest_cmdline_parse(
pluginmanager: "PytestPluginManager", args: List[str]
) -> Optional["Config"]:
"""return initialized config object, parsing the specified args. """return 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`
@ -127,7 +157,7 @@ def pytest_cmdline_parse(pluginmanager, args):
""" """
def pytest_cmdline_preparse(config, args): def pytest_cmdline_preparse(config: "Config", args: List[str]) -> None:
"""(**Deprecated**) modify command line arguments before option parsing. """(**Deprecated**) modify command line arguments before option parsing.
This hook is considered deprecated and will be removed in a future pytest version. Consider This hook is considered deprecated and will be removed in a future pytest version. Consider
@ -142,7 +172,7 @@ def pytest_cmdline_preparse(config, args):
@hookspec(firstresult=True) @hookspec(firstresult=True)
def pytest_cmdline_main(config): def pytest_cmdline_main(config: "Config") -> Optional[Union["ExitCode", int]]:
""" called for performing the main command line action. The default """ called for performing the main command line action. The default
implementation will invoke the configure hooks and runtest_mainloop. implementation will invoke the configure hooks and runtest_mainloop.
@ -155,7 +185,9 @@ def pytest_cmdline_main(config):
""" """
def pytest_load_initial_conftests(early_config, parser, args): def pytest_load_initial_conftests(
early_config: "Config", parser: "Parser", args: List[str]
) -> None:
""" implements the loading of initial conftest files ahead """ implements the loading of initial conftest files ahead
of command line option parsing. of command line option parsing.
@ -174,7 +206,7 @@ def pytest_load_initial_conftests(early_config, parser, args):
@hookspec(firstresult=True) @hookspec(firstresult=True)
def pytest_collection(session: "Session") -> Optional[Any]: def pytest_collection(session: "Session") -> Optional[object]:
"""Perform the collection protocol for the given session. """Perform the collection protocol for the given session.
Stops at first non-None result, see :ref:`firstresult`. Stops at first non-None result, see :ref:`firstresult`.
@ -198,7 +230,9 @@ def pytest_collection(session: "Session") -> Optional[Any]:
""" """
def pytest_collection_modifyitems(session, config, items): def pytest_collection_modifyitems(
session: "Session", config: "Config", items: List["Item"]
) -> None:
""" called after collection has been performed, may filter or re-order """ called after collection has been performed, may filter or re-order
the items in-place. the items in-place.
@ -208,20 +242,21 @@ def pytest_collection_modifyitems(session, config, items):
""" """
def pytest_collection_finish(session): def pytest_collection_finish(session: "Session") -> None:
""" called after collection has been performed and modified. """Called after collection has been performed and modified.
:param _pytest.main.Session session: the pytest session object :param _pytest.main.Session session: the pytest session object
""" """
@hookspec(firstresult=True) @hookspec(firstresult=True)
def pytest_ignore_collect(path, config): def pytest_ignore_collect(path: py.path.local, config: "Config") -> Optional[bool]:
""" return True to prevent considering this path for collection. """Return True to prevent considering this path for collection.
This hook is consulted for all files and directories prior to calling This hook is consulted for all files and directories prior to calling
more specific hooks. 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 path: a :py:class:`py.path.local` - the path to analyze
:param _pytest.config.Config config: pytest config object :param _pytest.config.Config config: pytest config object
@ -229,18 +264,19 @@ def pytest_ignore_collect(path, config):
@hookspec(firstresult=True, warn_on_impl=COLLECT_DIRECTORY_HOOK) @hookspec(firstresult=True, warn_on_impl=COLLECT_DIRECTORY_HOOK)
def pytest_collect_directory(path, parent): def pytest_collect_directory(path: py.path.local, parent) -> Optional[object]:
""" called before traversing a directory for collection files. """Called before traversing a directory for collection files.
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 path: a :py:class:`py.path.local` - the path to analyze
""" """
def pytest_collect_file(path, parent): def pytest_collect_file(path: py.path.local, parent) -> "Optional[Collector]":
""" return collection Node or None for the given path. Any new node """Return collection Node or None for the given path.
needs to have the specified ``parent`` as a parent.
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 path: a :py:class:`py.path.local` - the path to collect
""" """
@ -249,24 +285,24 @@ def pytest_collect_file(path, parent):
# logging hooks for collection # logging hooks for collection
def pytest_collectstart(collector): def pytest_collectstart(collector: "Collector") -> None:
""" collector starts collecting. """ """ collector starts collecting. """
def pytest_itemcollected(item): def pytest_itemcollected(item: "Item") -> None:
""" we just collected a test item. """ """We just collected a test item."""
def pytest_collectreport(report): def pytest_collectreport(report: "CollectReport") -> None:
""" collector finished collecting. """ """ collector finished collecting. """
def pytest_deselected(items): def pytest_deselected(items: Sequence["Item"]) -> None:
""" called for test items deselected, e.g. by keyword. """ """Called for deselected test items, e.g. by keyword."""
@hookspec(firstresult=True) @hookspec(firstresult=True)
def pytest_make_collect_report(collector): def pytest_make_collect_report(collector: "Collector") -> "Optional[CollectReport]":
""" perform ``collector.collect()`` and return a 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` """
@ -278,38 +314,44 @@ def pytest_make_collect_report(collector):
@hookspec(firstresult=True) @hookspec(firstresult=True)
def pytest_pycollect_makemodule(path, parent): def pytest_pycollect_makemodule(path: py.path.local, parent) -> Optional["Module"]:
""" return a Module collector or None for the given path. """Return a Module collector or None for the given path.
This hook will be called for each matching test module 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 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. 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 path: a :py:class:`py.path.local` - the path of module to collect
""" """
@hookspec(firstresult=True) @hookspec(firstresult=True)
def pytest_pycollect_makeitem(collector, name, obj): def pytest_pycollect_makeitem(
""" return custom item/collector for a python object in a module, or None. 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) @hookspec(firstresult=True)
def pytest_pyfunc_call(pyfuncitem): def pytest_pyfunc_call(pyfuncitem: "Function") -> Optional[object]:
""" call underlying test function. """ 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): def pytest_generate_tests(metafunc: "Metafunc") -> None:
""" generate (multiple) parametrized calls to a test function.""" """ generate (multiple) parametrized calls to a test function."""
@hookspec(firstresult=True) @hookspec(firstresult=True)
def pytest_make_parametrize_id(config, val, argname): 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 """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``. by @pytest.mark.parametrize calls. Return None if the hook doesn't know about ``val``.
The parameter name is available as ``argname``, if required. The parameter name is available as ``argname``, if required.
@ -323,73 +365,120 @@ def pytest_make_parametrize_id(config, val, argname):
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
# generic runtest related hooks # runtest related hooks
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
@hookspec(firstresult=True) @hookspec(firstresult=True)
def pytest_runtestloop(session): def pytest_runtestloop(session: "Session") -> Optional[object]:
""" called for performing the main runtest loop """Performs the main runtest loop (after collection finished).
(after collection finished).
Stops at first non-None result, see :ref:`firstresult` 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.
:param _pytest.main.Session session: the pytest session object If at any point :py:func:`pytest.exit` is called, the loop is
terminated immediately.
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.
Stops at first non-None result, see :ref:`firstresult`.
The return value is not used, but only stops further processing.
""" """
@hookspec(firstresult=True) @hookspec(firstresult=True)
def pytest_runtest_protocol(item, nextitem): def pytest_runtest_protocol(
""" implements the runtest_setup/call/teardown protocol for item: "Item", nextitem: "Optional[Item]"
the given test item, including capturing exceptions and calling ) -> Optional[object]:
reporting hooks. """Performs the runtest protocol for a single test item.
:arg item: test item for which the runtest protocol is performed. The default runtest protocol is this (see individual hooks for full details):
:arg nextitem: the scheduled-to-be-next test item (or None if this - ``pytest_runtest_logstart(nodeid, location)``
is the end my friend). This argument is passed on to
:py:func:`pytest_runtest_teardown`.
:return boolean: True if no further hook implementations should be invoked. - 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
- 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
Stops at first non-None result, see :ref:`firstresult` """ - 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
- ``pytest_runtest_logfinish(nodeid, location)``
def pytest_runtest_logstart(nodeid, location): :arg item: Test item for which the runtest protocol is performed.
""" signal the start of running a single test item.
This hook will be called **before** :func:`pytest_runtest_setup`, :func:`pytest_runtest_call` and :arg nextitem: The scheduled-to-be-next test item (or None if this is the end my friend).
:func:`pytest_runtest_teardown` hooks.
:param str nodeid: full id of the item Stops at first non-None result, see :ref:`firstresult`.
:param location: a triple of ``(filename, linenum, testname)`` The return value is not used, but only stops further processing.
""" """
def pytest_runtest_logfinish(nodeid, location): def pytest_runtest_logstart(
""" signal the complete finish of running a single test item. 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 See :func:`pytest_runtest_protocol` for a description of the runtest protocol.
:func:`pytest_runtest_teardown` hooks.
:param str nodeid: full id of the item :param str nodeid: Full node ID of the item.
:param location: a triple of ``(filename, linenum, testname)`` :param location: A triple of ``(filename, lineno, testname)``.
""" """
def pytest_runtest_setup(item): def pytest_runtest_logfinish(
""" called before ``pytest_runtest_call(item)``. """ 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.
:param str nodeid: Full node ID of the item.
:param location: A triple of ``(filename, lineno, testname)``.
"""
def pytest_runtest_call(item): def pytest_runtest_setup(item: "Item") -> None:
""" called to execute the test ``item``. """ """Called to perform the setup phase for a test item.
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).
"""
def pytest_runtest_teardown(item, nextitem): def pytest_runtest_call(item: "Item") -> None:
""" called after ``pytest_runtest_call``. """Called to run the test for test item (the call phase).
:arg nextitem: the scheduled-to-be-next test item (None if no further The default implementation calls ``item.runtest()``.
"""
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).
:arg nextitem: The scheduled-to-be-next test item (None if no further
test item is scheduled). This argument can be used to test item is scheduled). This argument can be used to
perform exact teardowns, i.e. calling just enough finalizers perform exact teardowns, i.e. calling just enough finalizers
so that nextitem only needs to call setup-functions. so that nextitem only needs to call setup-functions.
@ -397,21 +486,32 @@ def pytest_runtest_teardown(item, nextitem):
@hookspec(firstresult=True) @hookspec(firstresult=True)
def pytest_runtest_makereport(item, call): def pytest_runtest_makereport(
""" return a :py:class:`_pytest.runner.TestReport` object item: "Item", call: "CallInfo[None]"
for the given :py:class:`pytest.Item <_pytest.main.Item>` and ) -> Optional["TestReport"]:
:py:class:`_pytest.runner.CallInfo`. """Called to create a :py:class:`_pytest.reports.TestReport` for each of
the setup, call and teardown runtest phases of a test item.
Stops at first non-None result, see :ref:`firstresult` """ 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`.
"""
def pytest_runtest_logreport(report): def pytest_runtest_logreport(report: "TestReport") -> None:
""" process a test setup/call/teardown report relating to """Process the :py:class:`_pytest.reports.TestReport` produced for each
the respective phase of executing a test. """ 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) @hookspec(firstresult=True)
def pytest_report_to_serializable(config, report): def pytest_report_to_serializable(
config: "Config", report: Union["CollectReport", "TestReport"],
) -> Optional[Dict[str, Any]]:
""" """
Serializes the given report object into a data structure suitable for sending Serializes the given report object into a data structure suitable for sending
over the wire, e.g. converted to JSON. over the wire, e.g. converted to JSON.
@ -419,7 +519,9 @@ def pytest_report_to_serializable(config, report):
@hookspec(firstresult=True) @hookspec(firstresult=True)
def pytest_report_from_serializable(config, data): def pytest_report_from_serializable(
config: "Config", data: Dict[str, Any],
) -> Optional[Union["CollectReport", "TestReport"]]:
""" """
Restores a report object previously serialized with pytest_report_to_serializable(). Restores a report object previously serialized with pytest_report_to_serializable().
""" """
@ -431,12 +533,14 @@ def pytest_report_from_serializable(config, data):
@hookspec(firstresult=True) @hookspec(firstresult=True)
def pytest_fixture_setup(fixturedef, request): def pytest_fixture_setup(
""" performs fixture setup execution. fixturedef: "FixtureDef", request: "SubRequest"
) -> Optional[object]:
"""Performs fixture setup execution.
:return: The return value of the call to the fixture function :return: 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:: .. note::
If the fixture function returns None, other implementations of If the fixture function returns None, other implementations of
@ -445,7 +549,9 @@ def pytest_fixture_setup(fixturedef, request):
""" """
def pytest_fixture_post_finalizer(fixturedef, request): def pytest_fixture_post_finalizer(
fixturedef: "FixtureDef", request: "SubRequest"
) -> None:
"""Called after fixture teardown, but before the cache is cleared, so """Called after fixture teardown, but before the cache is cleared, so
the fixture result ``fixturedef.cached_result`` is still available (not the fixture result ``fixturedef.cached_result`` is still available (not
``None``).""" ``None``)."""
@ -456,24 +562,26 @@ def pytest_fixture_post_finalizer(fixturedef, request):
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
def pytest_sessionstart(session): def pytest_sessionstart(session: "Session") -> None:
""" called after the ``Session`` object has been created and before performing collection """Called after the ``Session`` object has been created and before performing collection
and entering the run test loop. and entering the run test loop.
:param _pytest.main.Session session: the pytest session object :param _pytest.main.Session session: the pytest session object
""" """
def pytest_sessionfinish(session, exitstatus): def pytest_sessionfinish(
""" called after whole test run finished, right before returning the exit status to the system. 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 _pytest.main.Session session: the pytest session object
:param int exitstatus: the status which pytest will return to the system :param int exitstatus: the status which pytest will return to the system
""" """
def pytest_unconfigure(config): def pytest_unconfigure(config: "Config") -> None:
""" called before test process is exited. """Called before test process is exited.
:param _pytest.config.Config config: pytest config object :param _pytest.config.Config config: pytest config object
""" """
@ -484,8 +592,10 @@ def pytest_unconfigure(config):
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
def pytest_assertrepr_compare(config, op, left, right): def pytest_assertrepr_compare(
"""return explanation for comparisons in failing assert expressions. 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 Return None for no custom explanation, otherwise return a list
of strings. The strings will be joined by newlines but any newlines of strings. The strings will be joined by newlines but any newlines
@ -496,7 +606,7 @@ def pytest_assertrepr_compare(config, op, left, right):
""" """
def pytest_assertion_pass(item, lineno, orig, expl): def pytest_assertion_pass(item: "Item", lineno: int, orig: str, expl: str) -> None:
""" """
**(Experimental)** **(Experimental)**
@ -539,7 +649,9 @@ def pytest_assertion_pass(item, lineno, orig, expl):
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
def pytest_report_header(config, startdir): 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. """ 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 _pytest.config.Config config: pytest config object
@ -560,11 +672,13 @@ 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]]:
""" """
.. versionadded:: 3.2 .. versionadded:: 3.2
return a string or list of strings to be displayed after collection has finished successfully. 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. These strings will be displayed after the standard "collected X items" message.
@ -583,7 +697,7 @@ def pytest_report_collectionfinish(config, startdir, items):
@hookspec(firstresult=True) @hookspec(firstresult=True)
def pytest_report_teststatus( def pytest_report_teststatus(
report: "BaseReport", config: "Config" report: Union["CollectReport", "TestReport"], config: "Config"
) -> Tuple[ ) -> Tuple[
str, str, Union[str, Mapping[str, bool]], str, str, Union[str, Mapping[str, bool]],
]: ]:
@ -610,7 +724,9 @@ def pytest_report_teststatus(
""" """
def pytest_terminal_summary(terminalreporter, exitstatus, config): def pytest_terminal_summary(
terminalreporter: "TerminalReporter", exitstatus: "ExitCode", config: "Config",
) -> None:
"""Add a section to terminal summary reporting. """Add a section to terminal summary reporting.
:param _pytest.terminal.TerminalReporter terminalreporter: the internal terminal reporter object :param _pytest.terminal.TerminalReporter terminalreporter: the internal terminal reporter object
@ -623,9 +739,16 @@ def pytest_terminal_summary(terminalreporter, exitstatus, config):
@hookspec(historic=True, warn_on_impl=WARNING_CAPTURED_HOOK) @hookspec(historic=True, warn_on_impl=WARNING_CAPTURED_HOOK)
def pytest_warning_captured(warning_message, when, item, location): 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**) 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. This hook is considered deprecated and will be removed in a future pytest version.
Use :func:`pytest_warning_recorded` instead. Use :func:`pytest_warning_recorded` instead.
@ -644,18 +767,19 @@ def pytest_warning_captured(warning_message, when, item, location):
The item being executed if ``when`` is ``"runtest"``, otherwise ``None``. The item being executed if ``when`` is ``"runtest"``, otherwise ``None``.
:param tuple location: :param tuple location:
Holds information about the execution context of the captured warning (filename, linenumber, function). When available, holds information about the execution context of the captured
``function`` evaluates to <module> when the execution context is at the module level. warning (filename, linenumber, function). ``function`` evaluates to <module>
when the execution context is at the module level.
""" """
@hookspec(historic=True) @hookspec(historic=True)
def pytest_warning_recorded( def pytest_warning_recorded(
warning_message: "warnings.WarningMessage", warning_message: "warnings.WarningMessage",
when: str, when: "Literal['config', 'collect', 'runtest']",
nodeid: str, nodeid: str,
location: Tuple[str, int, str], location: Optional[Tuple[str, int, str]],
): ) -> None:
""" """
Process a warning captured by the internal pytest warnings plugin. Process a warning captured by the internal pytest warnings plugin.
@ -672,47 +796,56 @@ def pytest_warning_recorded(
:param str nodeid: full id of the item :param str nodeid: full id of the item
:param tuple location: :param tuple|None location:
Holds information about the execution context of the captured warning (filename, linenumber, function). When available, holds information about the execution context of the captured
``function`` evaluates to <module> when the execution context is at the module level. warning (filename, linenumber, function). ``function`` evaluates to <module>
when the execution context is at the module level.
.. versionadded:: 6.0
""" """
# -------------------------------------------------------------------------
# doctest hooks
# -------------------------------------------------------------------------
@hookspec(firstresult=True)
def pytest_doctest_prepare_content(content):
""" return processed content for a given doctest
Stops at first non-None result, see :ref:`firstresult` """
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
# error handling and internal debugging hooks # error handling and internal debugging hooks
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
def pytest_internalerror(excrepr, excinfo): def pytest_internalerror(
""" called for internal errors. """ excrepr: "ExceptionRepr", excinfo: "ExceptionInfo[BaseException]",
) -> Optional[bool]:
"""Called for internal errors.
Return True to suppress the fallback handling of printing an
def pytest_keyboard_interrupt(excinfo): INTERNALERROR message directly to sys.stderr.
""" called for keyboard interrupt. """
def pytest_exception_interact(node, call, report):
"""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``.
""" """
def pytest_enter_pdb(config, pdb): def pytest_keyboard_interrupt(
excinfo: "ExceptionInfo[Union[KeyboardInterrupt, Exit]]",
) -> None:
""" called for keyboard interrupt. """
def pytest_exception_interact(
node: "Node",
call: "CallInfo[object]",
report: Union["CollectReport", "TestReport"],
) -> None:
"""Called when an exception was raised which can potentially be
interactively handled.
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: "Config", pdb: "pdb.Pdb") -> None:
""" called upon pdb.set_trace(), can be used by plugins to take special """ called upon pdb.set_trace(), can be used by plugins to take special
action just before the python debugger enters in interactive mode. action just before the python debugger enters in interactive mode.
@ -721,7 +854,7 @@ def pytest_enter_pdb(config, pdb):
""" """
def pytest_leave_pdb(config, pdb): def pytest_leave_pdb(config: "Config", pdb: "pdb.Pdb") -> None:
""" called when leaving pdb (e.g. with continue after pdb.set_trace()). """ 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 Can be used by plugins to take special action just after the python

View File

@ -13,18 +13,33 @@ import os
import platform import platform
import re import re
import sys import sys
import time
from datetime import datetime from datetime import datetime
from typing import Dict
from typing import List
from typing import Optional
from typing import Tuple
from typing import Union
import py import py
import pytest import pytest
from _pytest import deprecated from _pytest import deprecated
from _pytest import nodes from _pytest import nodes
from _pytest import timing
from _pytest._code.code import ExceptionRepr
from _pytest.compat import TYPE_CHECKING
from _pytest.config import Config
from _pytest.config import filename_arg 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.store import StoreKey
from _pytest.terminal import TerminalReporter
from _pytest.warnings import _issue_warning_captured from _pytest.warnings import _issue_warning_captured
if TYPE_CHECKING:
from typing import Type
xml_key = StoreKey["LogXML"]() xml_key = StoreKey["LogXML"]()
@ -54,8 +69,8 @@ del _legal_xml_re
_py_ext_re = re.compile(r"\.py$") _py_ext_re = re.compile(r"\.py$")
def bin_xml_escape(arg): def bin_xml_escape(arg: str) -> py.xml.raw:
def repl(matchobj): def repl(matchobj: "re.Match[str]") -> str:
i = ord(matchobj.group()) i = ord(matchobj.group())
if i <= 0xFF: if i <= 0xFF:
return "#x%02X" % i return "#x%02X" % i
@ -65,7 +80,7 @@ def bin_xml_escape(arg):
return py.xml.raw(illegal_xml_re.sub(repl, py.xml.escape(arg))) return py.xml.raw(illegal_xml_re.sub(repl, py.xml.escape(arg)))
def merge_family(left, right): def merge_family(left, right) -> None:
result = {} result = {}
for kl, vl in left.items(): for kl, vl in left.items():
for kr, vr in right.items(): for kr, vr in right.items():
@ -88,28 +103,27 @@ families["xunit2"] = families["_base"]
class _NodeReporter: class _NodeReporter:
def __init__(self, nodeid, xml): def __init__(self, nodeid: Union[str, TestReport], xml: "LogXML") -> None:
self.id = nodeid self.id = nodeid
self.xml = xml self.xml = xml
self.add_stats = self.xml.add_stats self.add_stats = self.xml.add_stats
self.family = self.xml.family self.family = self.xml.family
self.duration = 0 self.duration = 0
self.properties = [] self.properties = [] # type: List[Tuple[str, py.xml.raw]]
self.nodes = [] self.nodes = [] # type: List[py.xml.Tag]
self.testcase = None self.attrs = {} # type: Dict[str, Union[str, py.xml.raw]]
self.attrs = {}
def append(self, node): def append(self, node: py.xml.Tag) -> None:
self.xml.add_stats(type(node).__name__) self.xml.add_stats(type(node).__name__)
self.nodes.append(node) self.nodes.append(node)
def add_property(self, name, value): def add_property(self, name: str, value: str) -> None:
self.properties.append((str(name), bin_xml_escape(value))) self.properties.append((str(name), bin_xml_escape(value)))
def add_attribute(self, name, value): def add_attribute(self, name: str, value: str) -> None:
self.attrs[str(name)] = bin_xml_escape(value) self.attrs[str(name)] = bin_xml_escape(value)
def make_properties_node(self): def make_properties_node(self) -> Union[py.xml.Tag, str]:
"""Return a Junit node containing custom properties, if any. """Return a Junit node containing custom properties, if any.
""" """
if self.properties: if self.properties:
@ -121,8 +135,7 @@ class _NodeReporter:
) )
return "" return ""
def record_testreport(self, testreport): def record_testreport(self, testreport: TestReport) -> None:
assert not self.testcase
names = mangle_test_address(testreport.nodeid) names = mangle_test_address(testreport.nodeid)
existing_attrs = self.attrs existing_attrs = self.attrs
classnames = names[:-1] classnames = names[:-1]
@ -132,9 +145,9 @@ class _NodeReporter:
"classname": ".".join(classnames), "classname": ".".join(classnames),
"name": bin_xml_escape(names[-1]), "name": bin_xml_escape(names[-1]),
"file": testreport.location[0], "file": testreport.location[0],
} } # type: Dict[str, Union[str, py.xml.raw]]
if testreport.location[1] is not None: if testreport.location[1] is not None:
attrs["line"] = testreport.location[1] attrs["line"] = str(testreport.location[1])
if hasattr(testreport, "url"): if hasattr(testreport, "url"):
attrs["url"] = testreport.url attrs["url"] = testreport.url
self.attrs = attrs self.attrs = attrs
@ -152,19 +165,19 @@ class _NodeReporter:
temp_attrs[key] = self.attrs[key] temp_attrs[key] = self.attrs[key]
self.attrs = temp_attrs self.attrs = temp_attrs
def to_xml(self): def to_xml(self) -> py.xml.Tag:
testcase = Junit.testcase(time="%.3f" % self.duration, **self.attrs) testcase = Junit.testcase(time="%.3f" % self.duration, **self.attrs)
testcase.append(self.make_properties_node()) testcase.append(self.make_properties_node())
for node in self.nodes: for node in self.nodes:
testcase.append(node) testcase.append(node)
return testcase return testcase
def _add_simple(self, kind, message, data=None): def _add_simple(self, kind: "Type[py.xml.Tag]", message: str, data=None) -> None:
data = bin_xml_escape(data) data = bin_xml_escape(data)
node = kind(data, message=message) node = kind(data, message=message)
self.append(node) 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: if not self.xml.log_passing_tests and report.passed:
return return
@ -187,21 +200,22 @@ class _NodeReporter:
if content_all: if content_all:
self._write_content(report, content_all, "system-out") 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, ""]) return "\n".join([header.center(80, "-"), content, ""])
def _write_content(self, report, content, jheader): def _write_content(self, report: TestReport, content: str, jheader: str) -> None:
tag = getattr(Junit, jheader) tag = getattr(Junit, jheader)
self.append(tag(bin_xml_escape(content))) self.append(tag(bin_xml_escape(content)))
def append_pass(self, report): def append_pass(self, report: TestReport) -> None:
self.add_stats("passed") self.add_stats("passed")
def append_failure(self, report): def append_failure(self, report: TestReport) -> None:
# msg = str(report.longrepr.reprtraceback.extraline) # msg = str(report.longrepr.reprtraceback.extraline)
if hasattr(report, "wasxfail"): if hasattr(report, "wasxfail"):
self._add_simple(Junit.skipped, "xfail-marked test passes unexpectedly") self._add_simple(Junit.skipped, "xfail-marked test passes unexpectedly")
else: else:
assert report.longrepr is not None
if getattr(report.longrepr, "reprcrash", None) is not None: if getattr(report.longrepr, "reprcrash", None) is not None:
message = report.longrepr.reprcrash.message message = report.longrepr.reprcrash.message
else: else:
@ -211,23 +225,30 @@ class _NodeReporter:
fail.append(bin_xml_escape(report.longrepr)) fail.append(bin_xml_escape(report.longrepr))
self.append(fail) self.append(fail)
def append_collect_error(self, report): def append_collect_error(self, report: TestReport) -> None:
# msg = str(report.longrepr.reprtraceback.extraline) # msg = str(report.longrepr.reprtraceback.extraline)
assert report.longrepr is not None
self.append( self.append(
Junit.error(bin_xml_escape(report.longrepr), message="collection failure") Junit.error(bin_xml_escape(report.longrepr), message="collection failure")
) )
def append_collect_skipped(self, report): def append_collect_skipped(self, report: TestReport) -> None:
self._add_simple(Junit.skipped, "collection skipped", report.longrepr) self._add_simple(Junit.skipped, "collection skipped", report.longrepr)
def append_error(self, report): def append_error(self, report: TestReport) -> None:
if report.when == "teardown": assert report.longrepr is not None
msg = "test teardown failure" if getattr(report.longrepr, "reprcrash", None) is not None:
reason = report.longrepr.reprcrash.message
else: else:
msg = "test setup failure" reason = str(report.longrepr)
if report.when == "teardown":
msg = 'failed on teardown with "{}"'.format(reason)
else:
msg = 'failed on setup with "{}"'.format(reason)
self._add_simple(Junit.error, msg, report.longrepr) self._add_simple(Junit.error, msg, report.longrepr)
def append_skipped(self, report): def append_skipped(self, report: TestReport) -> None:
if hasattr(report, "wasxfail"): if hasattr(report, "wasxfail"):
xfailreason = report.wasxfail xfailreason = report.wasxfail
if xfailreason.startswith("reason: "): if xfailreason.startswith("reason: "):
@ -238,6 +259,7 @@ class _NodeReporter:
) )
) )
else: else:
assert report.longrepr is not None
filename, lineno, skipreason = report.longrepr filename, lineno, skipreason = report.longrepr
if skipreason.startswith("Skipped: "): if skipreason.startswith("Skipped: "):
skipreason = skipreason[9:] skipreason = skipreason[9:]
@ -252,13 +274,17 @@ class _NodeReporter:
) )
self.write_captured_output(report) self.write_captured_output(report)
def finalize(self): def finalize(self) -> None:
data = self.to_xml().unicode(indent=0) data = self.to_xml().unicode(indent=0)
self.__dict__.clear() 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: py.xml.raw(data) # type: ignore # noqa: F821
def _warn_incompatibility_with_xunit2(request, fixture_name): def _warn_incompatibility_with_xunit2(
request: FixtureRequest, fixture_name: str
) -> None:
"""Emits a PytestWarning about the given fixture being incompatible with newer xunit revisions""" """Emits a PytestWarning about the given fixture being incompatible with newer xunit revisions"""
from _pytest.warning_types import PytestWarning from _pytest.warning_types import PytestWarning
@ -274,7 +300,7 @@ def _warn_incompatibility_with_xunit2(request, fixture_name):
@pytest.fixture @pytest.fixture
def record_property(request): def record_property(request: FixtureRequest):
"""Add an extra properties the calling test. """Add an extra properties the calling test.
User properties become part of the test report and are available to the User properties become part of the test report and are available to the
configured reporters, like JUnit XML. configured reporters, like JUnit XML.
@ -288,14 +314,14 @@ def record_property(request):
""" """
_warn_incompatibility_with_xunit2(request, "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)) request.node.user_properties.append((name, value))
return append_property return append_property
@pytest.fixture @pytest.fixture
def record_xml_attribute(request): def record_xml_attribute(request: FixtureRequest):
"""Add extra xml attributes to the tag for the calling test. """Add extra xml attributes to the tag for the calling test.
The fixture is callable with ``(name, value)``, with value being The fixture is callable with ``(name, value)``, with value being
automatically xml-encoded automatically xml-encoded
@ -309,7 +335,7 @@ def record_xml_attribute(request):
_warn_incompatibility_with_xunit2(request, "record_xml_attribute") _warn_incompatibility_with_xunit2(request, "record_xml_attribute")
# Declare noop # Declare noop
def add_attr_noop(name, value): def add_attr_noop(name: str, value: str) -> None:
pass pass
attr_func = add_attr_noop attr_func = add_attr_noop
@ -322,7 +348,7 @@ def record_xml_attribute(request):
return attr_func 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 """Used by record_testsuite_property to check that the given parameter name is of the proper
type""" type"""
__tracebackhide__ = True __tracebackhide__ = True
@ -332,7 +358,7 @@ def _check_record_param_type(param, v):
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
def record_testsuite_property(request): def record_testsuite_property(request: FixtureRequest):
""" """
Records a new ``<property>`` tag as child of the root ``<testsuite>``. This is suitable to Records a new ``<property>`` tag as child of the root ``<testsuite>``. This is suitable to
writing global information regarding the entire test suite, and is compatible with ``xunit2`` JUnit family. writing global information regarding the entire test suite, and is compatible with ``xunit2`` JUnit family.
@ -350,7 +376,7 @@ def record_testsuite_property(request):
__tracebackhide__ = True __tracebackhide__ = True
def record_func(name, value): def record_func(name: str, value: str):
"""noop function in case --junitxml was not passed in the command-line""" """noop function in case --junitxml was not passed in the command-line"""
__tracebackhide__ = True __tracebackhide__ = True
_check_record_param_type("name", name) _check_record_param_type("name", name)
@ -361,7 +387,7 @@ def record_testsuite_property(request):
return record_func return record_func
def pytest_addoption(parser): def pytest_addoption(parser: Parser) -> None:
group = parser.getgroup("terminal reporting") group = parser.getgroup("terminal reporting")
group.addoption( group.addoption(
"--junitxml", "--junitxml",
@ -406,10 +432,10 @@ def pytest_addoption(parser):
) )
def pytest_configure(config): def pytest_configure(config: Config) -> None:
xmlpath = config.option.xmlpath xmlpath = config.option.xmlpath
# prevent opening xmllog on slave nodes (xdist) # prevent opening xmllog on worker nodes (xdist)
if xmlpath and not hasattr(config, "slaveinput"): if xmlpath and not hasattr(config, "workerinput"):
junit_family = config.getini("junit_family") junit_family = config.getini("junit_family")
if not junit_family: if not junit_family:
_issue_warning_captured(deprecated.JUNIT_XML_DEFAULT_FAMILY, config.hook, 2) _issue_warning_captured(deprecated.JUNIT_XML_DEFAULT_FAMILY, config.hook, 2)
@ -426,14 +452,14 @@ def pytest_configure(config):
config.pluginmanager.register(config._store[xml_key]) config.pluginmanager.register(config._store[xml_key])
def pytest_unconfigure(config): def pytest_unconfigure(config: Config) -> None:
xml = config._store.get(xml_key, None) xml = config._store.get(xml_key, None)
if xml: if xml:
del config._store[xml_key] del config._store[xml_key]
config.pluginmanager.unregister(xml) config.pluginmanager.unregister(xml)
def mangle_test_address(address): def mangle_test_address(address: str) -> List[str]:
path, possible_open_bracket, params = address.partition("[") path, possible_open_bracket, params = address.partition("[")
names = path.split("::") names = path.split("::")
try: try:
@ -452,13 +478,13 @@ class LogXML:
def __init__( def __init__(
self, self,
logfile, logfile,
prefix, prefix: Optional[str],
suite_name="pytest", suite_name: str = "pytest",
logging="no", logging: str = "no",
report_duration="total", report_duration: str = "total",
family="xunit1", family="xunit1",
log_passing_tests=True, log_passing_tests: bool = True,
): ) -> None:
logfile = os.path.expanduser(os.path.expandvars(logfile)) logfile = os.path.expanduser(os.path.expandvars(logfile))
self.logfile = os.path.normpath(os.path.abspath(logfile)) self.logfile = os.path.normpath(os.path.abspath(logfile))
self.prefix = prefix self.prefix = prefix
@ -467,33 +493,37 @@ class LogXML:
self.log_passing_tests = log_passing_tests self.log_passing_tests = log_passing_tests
self.report_duration = report_duration self.report_duration = report_duration
self.family = family self.family = family
self.stats = dict.fromkeys(["error", "passed", "failure", "skipped"], 0) self.stats = dict.fromkeys(
self.node_reporters = {} # nodeid -> _NodeReporter ["error", "passed", "failure", "skipped"], 0
self.node_reporters_ordered = [] ) # type: Dict[str, int]
self.global_properties = [] self.node_reporters = (
{}
) # type: Dict[Tuple[Union[str, TestReport], object], _NodeReporter]
self.node_reporters_ordered = [] # type: List[_NodeReporter]
self.global_properties = [] # type: List[Tuple[str, py.xml.raw]]
# List of reports that failed on call but teardown is pending. # List of reports that failed on call but teardown is pending.
self.open_reports = [] self.open_reports = [] # type: List[TestReport]
self.cnt_double_fail_tests = 0 self.cnt_double_fail_tests = 0
# Replaces convenience family with real family # Replaces convenience family with real family
if self.family == "legacy": if self.family == "legacy":
self.family = "xunit1" self.family = "xunit1"
def finalize(self, report): def finalize(self, report: TestReport) -> None:
nodeid = getattr(report, "nodeid", report) nodeid = getattr(report, "nodeid", report)
# local hack to handle xdist report order # local hack to handle xdist report order
slavenode = getattr(report, "node", None) workernode = getattr(report, "node", None)
reporter = self.node_reporters.pop((nodeid, slavenode)) reporter = self.node_reporters.pop((nodeid, workernode))
if reporter is not None: if reporter is not None:
reporter.finalize() reporter.finalize()
def node_reporter(self, report): def node_reporter(self, report: Union[TestReport, str]) -> _NodeReporter:
nodeid = getattr(report, "nodeid", report) nodeid = getattr(report, "nodeid", report) # type: Union[str, TestReport]
# local hack to handle xdist report order # local hack to handle xdist report order
slavenode = getattr(report, "node", None) workernode = getattr(report, "node", None)
key = nodeid, slavenode key = nodeid, workernode
if key in self.node_reporters: if key in self.node_reporters:
# TODO: breaks for --dist=each # TODO: breaks for --dist=each
@ -506,16 +536,16 @@ class LogXML:
return reporter return reporter
def add_stats(self, key): def add_stats(self, key: str) -> None:
if key in self.stats: if key in self.stats:
self.stats[key] += 1 self.stats[key] += 1
def _opentestcase(self, report): def _opentestcase(self, report: TestReport) -> _NodeReporter:
reporter = self.node_reporter(report) reporter = self.node_reporter(report)
reporter.record_testreport(report) reporter.record_testreport(report)
return reporter return reporter
def pytest_runtest_logreport(self, report): def pytest_runtest_logreport(self, report: TestReport) -> None:
"""handle a setup/call/teardown report, generating the appropriate """handle a setup/call/teardown report, generating the appropriate
xml tags as necessary. xml tags as necessary.
@ -583,7 +613,7 @@ class LogXML:
reporter.write_captured_output(report) reporter.write_captured_output(report)
for propname, propvalue in report.user_properties: for propname, propvalue in report.user_properties:
reporter.add_property(propname, propvalue) reporter.add_property(propname, str(propvalue))
self.finalize(report) self.finalize(report)
report_wid = getattr(report, "worker_id", None) report_wid = getattr(report, "worker_id", None)
@ -603,7 +633,7 @@ class LogXML:
if close_report: if close_report:
self.open_reports.remove(close_report) self.open_reports.remove(close_report)
def update_testcase_duration(self, report): def update_testcase_duration(self, report: TestReport) -> None:
"""accumulates total duration for nodeid from given report and updates """accumulates total duration for nodeid from given report and updates
the Junit.testcase with the new total if already created. the Junit.testcase with the new total if already created.
""" """
@ -611,7 +641,7 @@ class LogXML:
reporter = self.node_reporter(report) reporter = self.node_reporter(report)
reporter.duration += getattr(report, "duration", 0.0) reporter.duration += getattr(report, "duration", 0.0)
def pytest_collectreport(self, report): def pytest_collectreport(self, report: TestReport) -> None:
if not report.passed: if not report.passed:
reporter = self._opentestcase(report) reporter = self._opentestcase(report)
if report.failed: if report.failed:
@ -619,20 +649,20 @@ class LogXML:
else: else:
reporter.append_collect_skipped(report) reporter.append_collect_skipped(report)
def pytest_internalerror(self, excrepr): def pytest_internalerror(self, excrepr: ExceptionRepr) -> None:
reporter = self.node_reporter("internal") reporter = self.node_reporter("internal")
reporter.attrs.update(classname="pytest", name="internal") reporter.attrs.update(classname="pytest", name="internal")
reporter._add_simple(Junit.error, "internal error", excrepr) reporter._add_simple(Junit.error, "internal error", excrepr)
def pytest_sessionstart(self): def pytest_sessionstart(self) -> None:
self.suite_start_time = time.time() self.suite_start_time = timing.time()
def pytest_sessionfinish(self): def pytest_sessionfinish(self) -> None:
dirname = os.path.dirname(os.path.abspath(self.logfile)) dirname = os.path.dirname(os.path.abspath(self.logfile))
if not os.path.isdir(dirname): if not os.path.isdir(dirname):
os.makedirs(dirname) os.makedirs(dirname)
logfile = open(self.logfile, "w", encoding="utf-8") 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 suite_time_delta = suite_stop_time - self.suite_start_time
numtests = ( numtests = (
@ -648,10 +678,10 @@ class LogXML:
self._get_global_properties_node(), self._get_global_properties_node(),
[x.to_xml() for x in self.node_reporters_ordered], [x.to_xml() for x in self.node_reporters_ordered],
name=self.suite_name, name=self.suite_name,
errors=self.stats["error"], errors=str(self.stats["error"]),
failures=self.stats["failure"], failures=str(self.stats["failure"]),
skipped=self.stats["skipped"], skipped=str(self.stats["skipped"]),
tests=numtests, tests=str(numtests),
time="%.3f" % suite_time_delta, time="%.3f" % suite_time_delta,
timestamp=datetime.fromtimestamp(self.suite_start_time).isoformat(), timestamp=datetime.fromtimestamp(self.suite_start_time).isoformat(),
hostname=platform.node(), hostname=platform.node(),
@ -659,15 +689,15 @@ class LogXML:
logfile.write(Junit.testsuites([suite_node]).unicode(indent=0)) logfile.write(Junit.testsuites([suite_node]).unicode(indent=0))
logfile.close() logfile.close()
def pytest_terminal_summary(self, terminalreporter): def pytest_terminal_summary(self, terminalreporter: TerminalReporter) -> None:
terminalreporter.write_sep("-", "generated xml file: %s" % (self.logfile)) terminalreporter.write_sep("-", "generated xml file: {}".format(self.logfile))
def add_global_property(self, name, value): def add_global_property(self, name: str, value: str) -> None:
__tracebackhide__ = True __tracebackhide__ = True
_check_record_param_type("name", name) _check_record_param_type("name", name)
self.global_properties.append((name, bin_xml_escape(value))) self.global_properties.append((name, bin_xml_escape(value)))
def _get_global_properties_node(self): def _get_global_properties_node(self) -> Union[py.xml.Tag, str]:
"""Return a Junit node containing custom properties, if any. """Return a Junit node containing custom properties, if any.
""" """
if self.global_properties: if self.global_properties:

View File

@ -11,16 +11,24 @@ from typing import Generator
from typing import List from typing import List
from typing import Mapping from typing import Mapping
from typing import Optional from typing import Optional
from typing import Tuple
from typing import TypeVar
from typing import Union from typing import Union
import pytest import pytest
from _pytest import nodes from _pytest import nodes
from _pytest._io import TerminalWriter
from _pytest.capture import CaptureManager
from _pytest.compat import nullcontext from _pytest.compat import nullcontext
from _pytest.config import _strtobool from _pytest.config import _strtobool
from _pytest.config import Config from _pytest.config import Config
from _pytest.config import create_terminal_writer from _pytest.config import create_terminal_writer
from _pytest.config.argparsing import Parser
from _pytest.fixtures import FixtureRequest
from _pytest.main import Session
from _pytest.pathlib import Path from _pytest.pathlib import Path
from _pytest.store import StoreKey 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_FORMAT = "%(levelname)-8s %(name)s:%(filename)s:%(lineno)d %(message)s"
@ -30,7 +38,7 @@ catch_log_handler_key = StoreKey["LogCaptureHandler"]()
catch_log_records_key = StoreKey[Dict[str, List[logging.LogRecord]]]() catch_log_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) return _ANSI_ESCAPE_SEQ.sub("", text)
@ -50,7 +58,7 @@ class ColoredLevelFormatter(logging.Formatter):
} # type: Mapping[int, AbstractSet[str]] } # type: Mapping[int, AbstractSet[str]]
LEVELNAME_FMT_REGEX = re.compile(r"%\(levelname\)([+-.]?\d*s)") 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) super().__init__(*args, **kwargs)
self._original_fmt = self._style._fmt self._original_fmt = self._style._fmt
self._level_to_fmt_mapping = {} # type: Dict[int, str] self._level_to_fmt_mapping = {} # type: Dict[int, str]
@ -75,7 +83,7 @@ class ColoredLevelFormatter(logging.Formatter):
colorized_formatted_levelname, self._fmt 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) fmt = self._level_to_fmt_mapping.get(record.levelno, self._original_fmt)
self._style._fmt = fmt self._style._fmt = fmt
return super().format(record) return super().format(record)
@ -88,18 +96,20 @@ class PercentStyleMultiline(logging.PercentStyle):
formats the message as if each line were logged separately. 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) super().__init__(fmt)
self._auto_indent = self._get_auto_indent(auto_indent) self._auto_indent = self._get_auto_indent(auto_indent)
@staticmethod @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 = record_dict.copy()
tmp["message"] = message tmp["message"] = message
return tmp return tmp
@staticmethod @staticmethod
def _get_auto_indent(auto_indent_option) -> int: def _get_auto_indent(auto_indent_option: Union[int, str, bool, None]) -> int:
"""Determines the current auto indentation setting """Determines the current auto indentation setting
Specify auto indent behavior (on/off/fixed) by passing in Specify auto indent behavior (on/off/fixed) by passing in
@ -129,9 +139,16 @@ class PercentStyleMultiline(logging.PercentStyle):
>0 (explicitly set indentation position). >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) return int(auto_indent_option)
elif type(auto_indent_option) is str: elif isinstance(auto_indent_option, str):
try: try:
return int(auto_indent_option) return int(auto_indent_option)
except ValueError: except ValueError:
@ -141,17 +158,14 @@ class PercentStyleMultiline(logging.PercentStyle):
return -1 return -1
except ValueError: except ValueError:
return 0 return 0
elif type(auto_indent_option) is bool:
if auto_indent_option:
return -1
return 0 return 0
def format(self, record): def format(self, record: logging.LogRecord) -> str:
if "\n" in record.message: if "\n" in record.message:
if hasattr(record, "auto_indent"): if hasattr(record, "auto_indent"):
# passed in from the "extra={}" kwarg on the call to logging.log() # passed in from the "extra={}" kwarg on the call to logging.log()
auto_indent = self._get_auto_indent(record.auto_indent) auto_indent = self._get_auto_indent(record.auto_indent) # type: ignore[attr-defined] # noqa: F821
else: else:
auto_indent = self._auto_indent auto_indent = self._auto_indent
@ -171,7 +185,7 @@ class PercentStyleMultiline(logging.PercentStyle):
return self._fmt % record.__dict__ return self._fmt % record.__dict__
def get_option_ini(config, *names): def get_option_ini(config: Config, *names: str):
for name in names: for name in names:
ret = config.getoption(name) # 'default' arg won't work as expected ret = config.getoption(name) # 'default' arg won't work as expected
if ret is None: if ret is None:
@ -180,7 +194,7 @@ def get_option_ini(config, *names):
return ret return ret
def pytest_addoption(parser): def pytest_addoption(parser: Parser) -> None:
"""Add options to control log capturing.""" """Add options to control log capturing."""
group = parser.getgroup("logging") group = parser.getgroup("logging")
@ -266,13 +280,16 @@ def pytest_addoption(parser):
) )
_HandlerType = TypeVar("_HandlerType", bound=logging.Handler)
# Not using @contextmanager for performance reasons. # Not using @contextmanager for performance reasons.
class catching_logs: class catching_logs:
"""Context manager that prepares the whole logging machinery properly.""" """Context manager that prepares the whole logging machinery properly."""
__slots__ = ("handler", "level", "orig_level") __slots__ = ("handler", "level", "orig_level")
def __init__(self, handler, level=None): def __init__(self, handler: _HandlerType, level: Optional[int] = None) -> None:
self.handler = handler self.handler = handler
self.level = level self.level = level
@ -328,7 +345,7 @@ class LogCaptureFixture:
"""Creates a new funcarg.""" """Creates a new funcarg."""
self._item = item self._item = item
# dict of log name -> log level # dict of log name -> log level
self._initial_log_levels = {} # type: Dict[str, int] self._initial_logger_levels = {} # type: Dict[Optional[str], int]
def _finalize(self) -> None: def _finalize(self) -> None:
"""Finalizes the fixture. """Finalizes the fixture.
@ -336,7 +353,7 @@ class LogCaptureFixture:
This restores the log levels changed by :meth:`set_level`. This restores the log levels changed by :meth:`set_level`.
""" """
# restore log levels # restore log levels
for logger_name, level in self._initial_log_levels.items(): for logger_name, level in self._initial_logger_levels.items():
logger = logging.getLogger(logger_name) logger = logging.getLogger(logger_name)
logger.setLevel(level) logger.setLevel(level)
@ -362,17 +379,17 @@ class LogCaptureFixture:
return self._item._store[catch_log_records_key].get(when, []) return self._item._store[catch_log_records_key].get(when, [])
@property @property
def text(self): def text(self) -> str:
"""Returns the formatted log text.""" """Returns the formatted log text."""
return _remove_ansi_escape_sequences(self.handler.stream.getvalue()) return _remove_ansi_escape_sequences(self.handler.stream.getvalue())
@property @property
def records(self): def records(self) -> List[logging.LogRecord]:
"""Returns the list of log records.""" """Returns the list of log records."""
return self.handler.records return self.handler.records
@property @property
def record_tuples(self): def record_tuples(self) -> List[Tuple[str, int, str]]:
"""Returns a list of a stripped down version of log records intended """Returns a list of a stripped down version of log records intended
for use in assertion comparison. for use in assertion comparison.
@ -383,7 +400,7 @@ class LogCaptureFixture:
return [(r.name, r.levelno, r.getMessage()) for r in self.records] return [(r.name, r.levelno, r.getMessage()) for r in self.records]
@property @property
def messages(self): def messages(self) -> List[str]:
"""Returns a list of format-interpolated log messages. """Returns a list of format-interpolated log messages.
Unlike 'records', which contains the format string and parameters for interpolation, log messages in this list Unlike 'records', which contains the format string and parameters for interpolation, log messages in this list
@ -398,11 +415,11 @@ class LogCaptureFixture:
""" """
return [r.getMessage() for r in self.records] 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.""" """Reset the list of log records and the captured log text."""
self.handler.reset() self.handler.reset()
def set_level(self, level, logger=None): def set_level(self, level: Union[int, str], logger: Optional[str] = None) -> None:
"""Sets the level for capturing of logs. The level will be restored to its previous value at the end of """Sets the level for capturing of logs. The level will be restored to its previous value at the end of
the test. the test.
@ -413,31 +430,36 @@ class LogCaptureFixture:
The levels of the loggers changed by this function will be restored to their initial values at the The levels of the loggers changed by this function will be restored to their initial values at the
end of the test. end of the test.
""" """
logger_name = logger logger_obj = logging.getLogger(logger)
logger = logging.getLogger(logger_name)
# save the original log-level to restore it during teardown # save the original log-level to restore it during teardown
self._initial_log_levels.setdefault(logger_name, logger.level) self._initial_logger_levels.setdefault(logger, logger_obj.level)
logger.setLevel(level) logger_obj.setLevel(level)
self.handler.setLevel(level)
@contextmanager @contextmanager
def at_level(self, level, logger=None): 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 """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. level is restored to its original value.
:param int level: the logger to level. :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 str logger: the logger to update the level. If not given, the root logger level is updated.
""" """
logger = logging.getLogger(logger) logger_obj = logging.getLogger(logger)
orig_level = logger.level orig_level = logger_obj.level
logger.setLevel(level) logger_obj.setLevel(level)
handler_orig_level = self.handler.level
self.handler.setLevel(level)
try: try:
yield yield
finally: finally:
logger.setLevel(orig_level) logger_obj.setLevel(orig_level)
self.handler.setLevel(handler_orig_level)
@pytest.fixture @pytest.fixture
def caplog(request): def caplog(request: FixtureRequest) -> Generator[LogCaptureFixture, None, None]:
"""Access and control log capturing. """Access and control log capturing.
Captured logs are available through the following properties/methods:: Captured logs are available through the following properties/methods::
@ -467,18 +489,18 @@ def get_log_level_for_setting(config: Config, *setting_names: str) -> Optional[i
log_level = log_level.upper() log_level = log_level.upper()
try: try:
return int(getattr(logging, log_level, log_level)) return int(getattr(logging, log_level, log_level))
except ValueError: except ValueError as e:
# Python logging does not recognise this as a logging level # Python logging does not recognise this as a logging level
raise pytest.UsageError( raise pytest.UsageError(
"'{}' is not recognized as a logging level name for " "'{}' is not recognized as a logging level name for "
"'{}'. Please consider passing the " "'{}'. Please consider passing the "
"logging level num instead.".format(log_level, setting_name) "logging level num instead.".format(log_level, setting_name)
) ) from e
# run after terminalreporter/capturemanager are configured # run after terminalreporter/capturemanager are configured
@pytest.hookimpl(trylast=True) @pytest.hookimpl(trylast=True)
def pytest_configure(config): def pytest_configure(config: Config) -> None:
config.pluginmanager.register(LoggingPlugin(config), "logging-plugin") config.pluginmanager.register(LoggingPlugin(config), "logging-plugin")
@ -555,7 +577,7 @@ class LoggingPlugin:
return formatter return formatter
def set_log_path(self, fname): def set_log_path(self, fname: str) -> None:
"""Public method, which can set filename parameter for """Public method, which can set filename parameter for
Logging.FileHandler(). Also creates parent directory if Logging.FileHandler(). Also creates parent directory if
it does not exist. it does not exist.
@ -563,15 +585,15 @@ class LoggingPlugin:
.. warning:: .. warning::
Please considered as an experimental API. Please considered as an experimental API.
""" """
fname = Path(fname) fpath = Path(fname)
if not fname.is_absolute(): if not fpath.is_absolute():
fname = Path(self._config.rootdir, fname) fpath = Path(str(self._config.rootdir), fpath)
if not fname.parent.exists(): if not fpath.parent.exists():
fname.parent.mkdir(exist_ok=True, parents=True) fpath.parent.mkdir(exist_ok=True, parents=True)
stream = fname.open(mode="w", encoding="UTF-8") stream = fpath.open(mode="w", encoding="UTF-8")
if sys.version_info >= (3, 7): if sys.version_info >= (3, 7):
old_stream = self.log_file_handler.setStream(stream) old_stream = self.log_file_handler.setStream(stream)
else: else:
@ -601,7 +623,7 @@ class LoggingPlugin:
return True return True
@pytest.hookimpl(hookwrapper=True, tryfirst=True) @pytest.hookimpl(hookwrapper=True, tryfirst=True)
def pytest_sessionstart(self): def pytest_sessionstart(self) -> Generator[None, None, None]:
self.log_cli_handler.set_when("sessionstart") self.log_cli_handler.set_when("sessionstart")
with catching_logs(self.log_cli_handler, level=self.log_cli_level): with catching_logs(self.log_cli_handler, level=self.log_cli_level):
@ -617,7 +639,7 @@ class LoggingPlugin:
yield yield
@pytest.hookimpl(hookwrapper=True) @pytest.hookimpl(hookwrapper=True)
def pytest_runtestloop(self, session): def pytest_runtestloop(self, session: Session) -> Generator[None, None, None]:
"""Runs all collected test items.""" """Runs all collected test items."""
if session.config.option.collectonly: if session.config.option.collectonly:
@ -633,12 +655,12 @@ class LoggingPlugin:
yield # run all the tests yield # run all the tests
@pytest.hookimpl @pytest.hookimpl
def pytest_runtest_logstart(self): def pytest_runtest_logstart(self) -> None:
self.log_cli_handler.reset() self.log_cli_handler.reset()
self.log_cli_handler.set_when("start") self.log_cli_handler.set_when("start")
@pytest.hookimpl @pytest.hookimpl
def pytest_runtest_logreport(self): def pytest_runtest_logreport(self) -> None:
self.log_cli_handler.set_when("logreport") self.log_cli_handler.set_when("logreport")
def _runtest_for(self, item: nodes.Item, when: str) -> Generator[None, None, None]: def _runtest_for(self, item: nodes.Item, when: str) -> Generator[None, None, None]:
@ -654,20 +676,21 @@ class LoggingPlugin:
item.add_report_section(when, "log", log) item.add_report_section(when, "log", log)
@pytest.hookimpl(hookwrapper=True) @pytest.hookimpl(hookwrapper=True)
def pytest_runtest_setup(self, item): def pytest_runtest_setup(self, item: nodes.Item) -> Generator[None, None, None]:
self.log_cli_handler.set_when("setup") self.log_cli_handler.set_when("setup")
item._store[catch_log_records_key] = {} empty = {} # type: Dict[str, List[logging.LogRecord]]
item._store[catch_log_records_key] = empty
yield from self._runtest_for(item, "setup") yield from self._runtest_for(item, "setup")
@pytest.hookimpl(hookwrapper=True) @pytest.hookimpl(hookwrapper=True)
def pytest_runtest_call(self, item): def pytest_runtest_call(self, item: nodes.Item) -> Generator[None, None, None]:
self.log_cli_handler.set_when("call") self.log_cli_handler.set_when("call")
yield from self._runtest_for(item, "call") yield from self._runtest_for(item, "call")
@pytest.hookimpl(hookwrapper=True) @pytest.hookimpl(hookwrapper=True)
def pytest_runtest_teardown(self, item): def pytest_runtest_teardown(self, item: nodes.Item) -> Generator[None, None, None]:
self.log_cli_handler.set_when("teardown") self.log_cli_handler.set_when("teardown")
yield from self._runtest_for(item, "teardown") yield from self._runtest_for(item, "teardown")
@ -675,11 +698,11 @@ class LoggingPlugin:
del item._store[catch_log_handler_key] del item._store[catch_log_handler_key]
@pytest.hookimpl @pytest.hookimpl
def pytest_runtest_logfinish(self): def pytest_runtest_logfinish(self) -> None:
self.log_cli_handler.set_when("finish") self.log_cli_handler.set_when("finish")
@pytest.hookimpl(hookwrapper=True, tryfirst=True) @pytest.hookimpl(hookwrapper=True, tryfirst=True)
def pytest_sessionfinish(self): def pytest_sessionfinish(self) -> Generator[None, None, None]:
self.log_cli_handler.set_when("sessionfinish") self.log_cli_handler.set_when("sessionfinish")
with catching_logs(self.log_cli_handler, level=self.log_cli_level): with catching_logs(self.log_cli_handler, level=self.log_cli_level):
@ -687,7 +710,7 @@ class LoggingPlugin:
yield yield
@pytest.hookimpl @pytest.hookimpl
def pytest_unconfigure(self): def pytest_unconfigure(self) -> None:
# Close the FileHandler explicitly. # Close the FileHandler explicitly.
# (logging.shutdown might have lost the weakref?!) # (logging.shutdown might have lost the weakref?!)
self.log_file_handler.close() self.log_file_handler.close()
@ -712,29 +735,37 @@ class _LiveLoggingStreamHandler(logging.StreamHandler):
and won't appear in the terminal. and won't appear in the terminal.
""" """
def __init__(self, terminal_reporter, capture_manager): # Officially stream needs to be a IO[str], but TerminalReporter
# isn't. So force it.
stream = None # type: TerminalReporter # type: ignore
def __init__(
self,
terminal_reporter: TerminalReporter,
capture_manager: Optional[CaptureManager],
) -> None:
""" """
:param _pytest.terminal.TerminalReporter terminal_reporter: :param _pytest.terminal.TerminalReporter terminal_reporter:
:param _pytest.capture.CaptureManager capture_manager: :param _pytest.capture.CaptureManager capture_manager:
""" """
logging.StreamHandler.__init__(self, stream=terminal_reporter) logging.StreamHandler.__init__(self, stream=terminal_reporter) # type: ignore[arg-type] # noqa: F821
self.capture_manager = capture_manager self.capture_manager = capture_manager
self.reset() self.reset()
self.set_when(None) self.set_when(None)
self._test_outcome_written = False self._test_outcome_written = False
def reset(self): def reset(self) -> None:
"""Reset the handler; should be called before the start of each test""" """Reset the handler; should be called before the start of each test"""
self._first_record_emitted = False self._first_record_emitted = False
def set_when(self, when): def set_when(self, when: Optional[str]) -> None:
"""Prepares for the given test phase (setup/call/teardown)""" """Prepares for the given test phase (setup/call/teardown)"""
self._when = when self._when = when
self._section_name_shown = False self._section_name_shown = False
if when == "start": if when == "start":
self._test_outcome_written = False self._test_outcome_written = False
def emit(self, record): def emit(self, record: logging.LogRecord) -> None:
ctx_manager = ( ctx_manager = (
self.capture_manager.global_and_fixture_disabled() self.capture_manager.global_and_fixture_disabled()
if self.capture_manager if self.capture_manager
@ -761,10 +792,10 @@ class _LiveLoggingStreamHandler(logging.StreamHandler):
class _LiveLoggingNullHandler(logging.NullHandler): class _LiveLoggingNullHandler(logging.NullHandler):
"""A handler used when live logging is disabled.""" """A handler used when live logging is disabled."""
def reset(self): def reset(self) -> None:
pass pass
def set_when(self, when): def set_when(self, when: str) -> None:
pass pass
def handleError(self, record: logging.LogRecord) -> None: def handleError(self, record: logging.LogRecord) -> None:

View File

@ -1,4 +1,5 @@
""" core implementation of testing process: init, session, runtest loop. """ """ core implementation of testing process: init, session, runtest loop. """
import argparse
import fnmatch import fnmatch
import functools import functools
import importlib import importlib
@ -7,9 +8,11 @@ import sys
from typing import Callable from typing import Callable
from typing import Dict from typing import Dict
from typing import FrozenSet from typing import FrozenSet
from typing import Iterator
from typing import List from typing import List
from typing import Optional from typing import Optional
from typing import Sequence from typing import Sequence
from typing import Set
from typing import Tuple from typing import Tuple
from typing import Union from typing import Union
@ -18,15 +21,19 @@ import py
import _pytest._code import _pytest._code
from _pytest import nodes from _pytest import nodes
from _pytest.compat import overload
from _pytest.compat import TYPE_CHECKING from _pytest.compat import TYPE_CHECKING
from _pytest.config import Config from _pytest.config import Config
from _pytest.config import directory_arg from _pytest.config import directory_arg
from _pytest.config import ExitCode from _pytest.config import ExitCode
from _pytest.config import hookimpl from _pytest.config import hookimpl
from _pytest.config import UsageError from _pytest.config import UsageError
from _pytest.config.argparsing import Parser
from _pytest.fixtures import FixtureManager from _pytest.fixtures import FixtureManager
from _pytest.outcomes import exit from _pytest.outcomes import exit
from _pytest.pathlib import Path
from _pytest.reports import CollectReport from _pytest.reports import CollectReport
from _pytest.reports import TestReport
from _pytest.runner import collect_one_node from _pytest.runner import collect_one_node
from _pytest.runner import SetupState from _pytest.runner import SetupState
@ -38,7 +45,7 @@ if TYPE_CHECKING:
from _pytest.python import Package from _pytest.python import Package
def pytest_addoption(parser): def pytest_addoption(parser: Parser) -> None:
parser.addini( parser.addini(
"norecursedirs", "norecursedirs",
"directory patterns to avoid for recursion", "directory patterns to avoid for recursion",
@ -70,6 +77,11 @@ def pytest_addoption(parser):
default=0, default=0,
help="exit after first num failures or errors.", 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( group._addoption(
"--strict-markers", "--strict-markers",
"--strict", "--strict",
@ -161,12 +173,21 @@ def pytest_addoption(parser):
default=False, default=False,
help="Don't ignore tests in a local virtualenv directory", 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 = parser.getgroup("debugconfig", "test session debugging and configuration")
group.addoption( group.addoption(
"--basetemp", "--basetemp",
dest="basetemp", dest="basetemp",
default=None, default=None,
type=validate_basetemp,
metavar="dir", metavar="dir",
help=( help=(
"base temporary directory for this test run." "base temporary directory for this test run."
@ -175,6 +196,34 @@ 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 True if query is an ancestor of base, else False."""
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( def wrap_session(
config: Config, doit: Callable[[Config, "Session"], Optional[Union[int, ExitCode]]] config: Config, doit: Callable[[Config, "Session"], Optional[Union[int, ExitCode]]]
) -> Union[int, ExitCode]: ) -> Union[int, ExitCode]:
@ -236,7 +285,7 @@ def wrap_session(
return session.exitstatus return session.exitstatus
def pytest_cmdline_main(config): def pytest_cmdline_main(config: Config) -> Union[int, ExitCode]:
return wrap_session(config, _main) return wrap_session(config, _main)
@ -253,11 +302,11 @@ def _main(config: Config, session: "Session") -> Optional[Union[int, ExitCode]]:
return None return None
def pytest_collection(session): def pytest_collection(session: "Session") -> None:
return session.perform_collect() 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: if session.testsfailed and not session.config.option.continue_on_collection_errors:
raise session.Interrupted( raise session.Interrupted(
"%d error%s during collection" "%d error%s during collection"
@ -277,7 +326,7 @@ def pytest_runtestloop(session):
return True return True
def _in_venv(path): def _in_venv(path: py.path.local) -> bool:
"""Attempts to detect if ``path`` is the root of a Virtual Environment by """Attempts to detect if ``path`` is the root of a Virtual Environment by
checking for the existence of the appropriate activate script""" checking for the existence of the appropriate activate script"""
bindir = path.join("Scripts" if sys.platform.startswith("win") else "bin") bindir = path.join("Scripts" if sys.platform.startswith("win") else "bin")
@ -294,9 +343,7 @@ def _in_venv(path):
return any([fname.basename in activates for fname in bindir.listdir()]) return any([fname.basename in activates for fname in bindir.listdir()])
def pytest_ignore_collect( def pytest_ignore_collect(path: py.path.local, config: Config) -> Optional[bool]:
path: py.path.local, config: Config
) -> "Optional[Literal[True]]":
ignore_paths = config._getconftest_pathlist("collect_ignore", path=path.dirpath()) ignore_paths = config._getconftest_pathlist("collect_ignore", path=path.dirpath())
ignore_paths = ignore_paths or [] ignore_paths = ignore_paths or []
excludeopt = config.getoption("ignore") excludeopt = config.getoption("ignore")
@ -323,7 +370,7 @@ def pytest_ignore_collect(
return None 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 []) deselect_prefixes = tuple(config.getoption("deselect") or [])
if not deselect_prefixes: if not deselect_prefixes:
return return
@ -380,8 +427,8 @@ class Session(nodes.FSCollector):
) )
self.testsfailed = 0 self.testsfailed = 0
self.testscollected = 0 self.testscollected = 0
self.shouldstop = False self.shouldstop = False # type: Union[bool, str]
self.shouldfail = False self.shouldfail = False # type: Union[bool, str]
self.trace = config.trace.root.get("collection") self.trace = config.trace.root.get("collection")
self.startdir = config.invocation_dir self.startdir = config.invocation_dir
self._initialpaths = frozenset() # type: FrozenSet[py.path.local] self._initialpaths = frozenset() # type: FrozenSet[py.path.local]
@ -398,7 +445,7 @@ class Session(nodes.FSCollector):
) # type: Dict[Tuple[Type[nodes.Collector], str], CollectReport] ) # type: Dict[Tuple[Type[nodes.Collector], str], CollectReport]
# Dirnames of pkgs with dunder-init files. # Dirnames of pkgs with dunder-init files.
self._collection_pkg_roots = {} # type: Dict[py.path.local, Package] self._collection_pkg_roots = {} # type: Dict[str, Package]
self._bestrelpathcache = _bestrelpath_cache( self._bestrelpathcache = _bestrelpath_cache(
config.rootdir config.rootdir
@ -407,10 +454,11 @@ class Session(nodes.FSCollector):
self.config.pluginmanager.register(self, name="session") self.config.pluginmanager.register(self, name="session")
@classmethod @classmethod
def from_config(cls, config): def from_config(cls, config: Config) -> "Session":
return cls._create(config) session = cls._create(config) # type: Session
return session
def __repr__(self): def __repr__(self) -> str:
return "<%s %s exitstatus=%r testsfailed=%d testscollected=%d>" % ( return "<%s %s exitstatus=%r testsfailed=%d testscollected=%d>" % (
self.__class__.__name__, self.__class__.__name__,
self.name, self.name,
@ -424,14 +472,16 @@ class Session(nodes.FSCollector):
return self._bestrelpathcache[node_path] return self._bestrelpathcache[node_path]
@hookimpl(tryfirst=True) @hookimpl(tryfirst=True)
def pytest_collectstart(self): def pytest_collectstart(self) -> None:
if self.shouldfail: if self.shouldfail:
raise self.Failed(self.shouldfail) raise self.Failed(self.shouldfail)
if self.shouldstop: if self.shouldstop:
raise self.Interrupted(self.shouldstop) raise self.Interrupted(self.shouldstop)
@hookimpl(tryfirst=True) @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"): if report.failed and not hasattr(report, "wasxfail"):
self.testsfailed += 1 self.testsfailed += 1
maxfail = self.config.getvalue("maxfail") maxfail = self.config.getvalue("maxfail")
@ -440,13 +490,27 @@ class Session(nodes.FSCollector):
pytest_collectreport = pytest_runtest_logreport pytest_collectreport = pytest_runtest_logreport
def isinitpath(self, path): def isinitpath(self, path: py.path.local) -> bool:
return path in self._initialpaths return path in self._initialpaths
def gethookproxy(self, fspath: py.path.local): def gethookproxy(self, fspath: py.path.local):
return super()._gethookproxy(fspath) return super()._gethookproxy(fspath)
def perform_collect(self, args=None, genitems=True): @overload
def perform_collect(
self, args: Optional[Sequence[str]] = ..., genitems: "Literal[True]" = ...
) -> Sequence[nodes.Item]:
raise NotImplementedError()
@overload # noqa: F811
def perform_collect( # noqa: F811
self, args: Optional[Sequence[str]] = ..., genitems: bool = ...
) -> Sequence[Union[nodes.Item, nodes.Collector]]:
raise NotImplementedError()
def perform_collect( # noqa: F811
self, args: Optional[Sequence[str]] = None, genitems: bool = True
) -> Sequence[Union[nodes.Item, nodes.Collector]]:
hook = self.config.hook hook = self.config.hook
try: try:
items = self._perform_collect(args, genitems) items = self._perform_collect(args, genitems)
@ -459,15 +523,29 @@ class Session(nodes.FSCollector):
self.testscollected = len(items) self.testscollected = len(items)
return items return items
def _perform_collect(self, args, genitems): @overload
def _perform_collect(
self, args: Optional[Sequence[str]], genitems: "Literal[True]"
) -> List[nodes.Item]:
raise NotImplementedError()
@overload # noqa: F811
def _perform_collect( # noqa: F811
self, args: Optional[Sequence[str]], genitems: bool
) -> Union[List[Union[nodes.Item]], List[Union[nodes.Item, nodes.Collector]]]:
raise NotImplementedError()
def _perform_collect( # noqa: F811
self, args: Optional[Sequence[str]], genitems: bool
) -> Union[List[Union[nodes.Item]], List[Union[nodes.Item, nodes.Collector]]]:
if args is None: if args is None:
args = self.config.args args = self.config.args
self.trace("perform_collect", self, args) self.trace("perform_collect", self, args)
self.trace.root.indent += 1 self.trace.root.indent += 1
self._notfound = [] self._notfound = [] # type: List[Tuple[str, NoMatch]]
initialpaths = [] # type: List[py.path.local] initialpaths = [] # type: List[py.path.local]
self._initial_parts = [] # type: List[Tuple[py.path.local, List[str]]] self._initial_parts = [] # type: List[Tuple[py.path.local, List[str]]]
self.items = items = [] self.items = items = [] # type: List[nodes.Item]
for arg in args: for arg in args:
fspath, parts = self._parsearg(arg) fspath, parts = self._parsearg(arg)
self._initial_parts.append((fspath, parts)) self._initial_parts.append((fspath, parts))
@ -490,7 +568,7 @@ class Session(nodes.FSCollector):
self.items.extend(self.genitems(node)) self.items.extend(self.genitems(node))
return items return items
def collect(self): def collect(self) -> Iterator[Union[nodes.Item, nodes.Collector]]:
for fspath, parts in self._initial_parts: for fspath, parts in self._initial_parts:
self.trace("processing argument", (fspath, parts)) self.trace("processing argument", (fspath, parts))
self.trace.root.indent += 1 self.trace.root.indent += 1
@ -508,7 +586,9 @@ class Session(nodes.FSCollector):
self._collection_node_cache3.clear() self._collection_node_cache3.clear()
self._collection_pkg_roots.clear() self._collection_pkg_roots.clear()
def _collect(self, argpath, names): def _collect(
self, argpath: py.path.local, names: List[str]
) -> Iterator[Union[nodes.Item, nodes.Collector]]:
from _pytest.python import Package from _pytest.python import Package
# Start with a Session root, and delve to argpath item (dir or file) # Start with a Session root, and delve to argpath item (dir or file)
@ -527,7 +607,7 @@ class Session(nodes.FSCollector):
col = self._collectfile(pkginit, handle_dupes=False) col = self._collectfile(pkginit, handle_dupes=False)
if col: if col:
if isinstance(col[0], Package): if isinstance(col[0], Package):
self._collection_pkg_roots[parent] = col[0] self._collection_pkg_roots[str(parent)] = col[0]
# always store a list in the cache, matchnodes expects it # always store a list in the cache, matchnodes expects it
self._collection_node_cache1[col[0].fspath] = [col[0]] self._collection_node_cache1[col[0].fspath] = [col[0]]
@ -536,7 +616,7 @@ class Session(nodes.FSCollector):
if argpath.check(dir=1): if argpath.check(dir=1):
assert not names, "invalid arg {!r}".format((argpath, names)) assert not names, "invalid arg {!r}".format((argpath, names))
seen_dirs = set() seen_dirs = set() # type: Set[py.path.local]
for path in argpath.visit( for path in argpath.visit(
fil=self._visit_filter, rec=self._recurse, bf=True, sort=True fil=self._visit_filter, rec=self._recurse, bf=True, sort=True
): ):
@ -549,8 +629,8 @@ class Session(nodes.FSCollector):
for x in self._collectfile(pkginit): for x in self._collectfile(pkginit):
yield x yield x
if isinstance(x, Package): if isinstance(x, Package):
self._collection_pkg_roots[dirpath] = x self._collection_pkg_roots[str(dirpath)] = x
if dirpath in self._collection_pkg_roots: if str(dirpath) in self._collection_pkg_roots:
# Do not collect packages here. # Do not collect packages here.
continue continue
@ -577,8 +657,9 @@ class Session(nodes.FSCollector):
# Module itself, so just use that. If this special case isn't taken, then all # Module itself, so just use that. If this special case isn't taken, then all
# the files in the package will be yielded. # the files in the package will be yielded.
if argpath.basename == "__init__.py": if argpath.basename == "__init__.py":
assert isinstance(m[0], nodes.Collector)
try: try:
yield next(m[0].collect()) yield next(iter(m[0].collect()))
except StopIteration: except StopIteration:
# The package collects nothing with only an __init__.py # The package collects nothing with only an __init__.py
# file in it, which gets ignored by the default # file in it, which gets ignored by the default
@ -588,10 +669,11 @@ class Session(nodes.FSCollector):
yield from m yield from m
@staticmethod @staticmethod
def _visit_filter(f): def _visit_filter(f: py.path.local) -> bool:
return f.check(file=1) # TODO: Remove type: ignore once `py` is typed.
return f.check(file=1) # type: ignore
def _tryconvertpyarg(self, x): def _tryconvertpyarg(self, x: str) -> str:
"""Convert a dotted module name to path.""" """Convert a dotted module name to path."""
try: try:
spec = importlib.util.find_spec(x) spec = importlib.util.find_spec(x)
@ -600,14 +682,14 @@ class Session(nodes.FSCollector):
# ValueError: not a module name # ValueError: not a module name
except (AttributeError, ImportError, ValueError): except (AttributeError, ImportError, ValueError):
return x return x
if spec is None or spec.origin in {None, "namespace"}: if spec is None or spec.origin is None or spec.origin == "namespace":
return x return x
elif spec.submodule_search_locations: elif spec.submodule_search_locations:
return os.path.dirname(spec.origin) return os.path.dirname(spec.origin)
else: else:
return spec.origin return spec.origin
def _parsearg(self, arg): def _parsearg(self, arg: str) -> Tuple[py.path.local, List[str]]:
""" return (fspath, names) tuple after checking the file exists. """ """ return (fspath, names) tuple after checking the file exists. """
strpath, *parts = str(arg).split("::") strpath, *parts = str(arg).split("::")
if self.config.option.pyargs: if self.config.option.pyargs:
@ -620,10 +702,11 @@ class Session(nodes.FSCollector):
"file or package not found: " + arg + " (missing __init__.py?)" "file or package not found: " + arg + " (missing __init__.py?)"
) )
raise UsageError("file not found: " + arg) raise UsageError("file not found: " + arg)
fspath = fspath.realpath()
return (fspath, parts) return (fspath, parts)
def matchnodes(self, matching, names): def matchnodes(
self, matching: Sequence[Union[nodes.Item, nodes.Collector]], names: List[str],
) -> Sequence[Union[nodes.Item, nodes.Collector]]:
self.trace("matchnodes", matching, names) self.trace("matchnodes", matching, names)
self.trace.root.indent += 1 self.trace.root.indent += 1
nodes = self._matchnodes(matching, names) nodes = self._matchnodes(matching, names)
@ -634,13 +717,15 @@ class Session(nodes.FSCollector):
raise NoMatch(matching, names[:1]) raise NoMatch(matching, names[:1])
return nodes return nodes
def _matchnodes(self, matching, names): def _matchnodes(
self, matching: Sequence[Union[nodes.Item, nodes.Collector]], names: List[str],
) -> Sequence[Union[nodes.Item, nodes.Collector]]:
if not matching or not names: if not matching or not names:
return matching return matching
name = names[0] name = names[0]
assert name assert name
nextnames = names[1:] nextnames = names[1:]
resultnodes = [] resultnodes = [] # type: List[Union[nodes.Item, nodes.Collector]]
for node in matching: for node in matching:
if isinstance(node, nodes.Item): if isinstance(node, nodes.Item):
if not names: if not names:
@ -671,7 +756,9 @@ class Session(nodes.FSCollector):
node.ihook.pytest_collectreport(report=rep) node.ihook.pytest_collectreport(report=rep)
return resultnodes return resultnodes
def genitems(self, node): def genitems(
self, node: Union[nodes.Item, nodes.Collector]
) -> Iterator[nodes.Item]:
self.trace("genitems", node) self.trace("genitems", node)
if isinstance(node, nodes.Item): if isinstance(node, nodes.Item):
node.ihook.pytest_itemcollected(item=node) node.ihook.pytest_itemcollected(item=node)

View File

@ -1,7 +1,10 @@
""" generic mechanism for marking and selecting python functions. """ """ generic mechanism for marking and selecting python functions. """
import typing
import warnings import warnings
from typing import AbstractSet from typing import AbstractSet
from typing import List
from typing import Optional from typing import Optional
from typing import Union
import attr import attr
@ -16,8 +19,10 @@ from .structures import MarkGenerator
from .structures import ParameterSet from .structures import ParameterSet
from _pytest.compat import TYPE_CHECKING from _pytest.compat import TYPE_CHECKING
from _pytest.config import Config from _pytest.config import Config
from _pytest.config import ExitCode
from _pytest.config import hookimpl from _pytest.config import hookimpl
from _pytest.config import UsageError from _pytest.config import UsageError
from _pytest.config.argparsing import Parser
from _pytest.deprecated import MINUS_K_COLON from _pytest.deprecated import MINUS_K_COLON
from _pytest.deprecated import MINUS_K_DASH from _pytest.deprecated import MINUS_K_DASH
from _pytest.store import StoreKey from _pytest.store import StoreKey
@ -25,13 +30,18 @@ from _pytest.store import StoreKey
if TYPE_CHECKING: if TYPE_CHECKING:
from _pytest.nodes import Item from _pytest.nodes import Item
__all__ = ["Mark", "MarkDecorator", "MarkGenerator", "get_empty_parameterset_mark"] __all__ = ["Mark", "MarkDecorator", "MarkGenerator", "get_empty_parameterset_mark"]
old_mark_config_key = StoreKey[Optional[Config]]() old_mark_config_key = StoreKey[Optional[Config]]()
def param(*values, **kw): def param(
*values: object,
marks: "Union[MarkDecorator, typing.Collection[Union[MarkDecorator, Mark]]]" = (),
id: Optional[str] = None
) -> ParameterSet:
"""Specify a parameter in `pytest.mark.parametrize`_ calls or """Specify a parameter in `pytest.mark.parametrize`_ calls or
:ref:`parametrized fixtures <fixture-parametrize-marks>`. :ref:`parametrized fixtures <fixture-parametrize-marks>`.
@ -48,10 +58,10 @@ def param(*values, **kw):
:keyword marks: a single mark or a list of marks to be applied to this parameter set. :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. :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 = parser.getgroup("general")
group._addoption( group._addoption(
"-k", "-k",
@ -94,7 +104,7 @@ def pytest_addoption(parser):
@hookimpl(tryfirst=True) @hookimpl(tryfirst=True)
def pytest_cmdline_main(config): def pytest_cmdline_main(config: Config) -> Optional[Union[int, ExitCode]]:
import _pytest.config import _pytest.config
if config.option.markers: if config.option.markers:
@ -110,6 +120,8 @@ def pytest_cmdline_main(config):
config._ensure_unconfigure() config._ensure_unconfigure()
return 0 return 0
return None
@attr.s(slots=True) @attr.s(slots=True)
class KeywordMatcher: class KeywordMatcher:
@ -135,9 +147,9 @@ class KeywordMatcher:
# Add the names of the current item and any parent items # Add the names of the current item and any parent items
import pytest import pytest
for item in item.listchain(): for node in item.listchain():
if not isinstance(item, (pytest.Instance, pytest.Session)): if not isinstance(node, (pytest.Instance, pytest.Session)):
mapped_names.add(item.name) mapped_names.add(node.name)
# Add the names added as extra keywords to current or parent items # Add the names added as extra keywords to current or parent items
mapped_names.update(item.listextrakeywords()) mapped_names.update(item.listextrakeywords())
@ -162,7 +174,7 @@ class KeywordMatcher:
return False return False
def deselect_by_keyword(items, config): def deselect_by_keyword(items: "List[Item]", config: Config) -> None:
keywordexpr = config.option.keyword.lstrip() keywordexpr = config.option.keyword.lstrip()
if not keywordexpr: if not keywordexpr:
return return
@ -218,7 +230,7 @@ class MarkMatcher:
return name in self.own_mark_names return name in self.own_mark_names
def deselect_by_mark(items, config): def deselect_by_mark(items: "List[Item]", config: Config) -> None:
matchexpr = config.option.markexpr matchexpr = config.option.markexpr
if not matchexpr: if not matchexpr:
return return
@ -243,12 +255,12 @@ def deselect_by_mark(items, config):
items[:] = remaining 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_keyword(items, config)
deselect_by_mark(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 config._store[old_mark_config_key] = MARK_GEN._config
MARK_GEN._config = config MARK_GEN._config = config
@ -261,5 +273,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) MARK_GEN._config = config._store.get(old_mark_config_key, None)

View File

@ -1,130 +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())
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

View File

@ -127,6 +127,12 @@ class Scanner:
) )
# 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: def expression(s: Scanner) -> ast.Expression:
if s.accept(TokenType.EOF): if s.accept(TokenType.EOF):
ret = ast.NameConstant(False) # type: ast.expr ret = ast.NameConstant(False) # type: ast.expr
@ -161,7 +167,7 @@ def not_expr(s: Scanner) -> ast.expr:
return ret return ret
ident = s.accept(TokenType.IDENT) ident = s.accept(TokenType.IDENT)
if ident: if ident:
return ast.Name(ident.value, ast.Load()) return ast.Name(IDENT_PREFIX + ident.value, ast.Load())
s.reject((TokenType.NOT, TokenType.LPAREN, TokenType.IDENT)) s.reject((TokenType.NOT, TokenType.LPAREN, TokenType.IDENT))
@ -172,7 +178,7 @@ class MatcherAdapter(Mapping[str, bool]):
self.matcher = matcher self.matcher = matcher
def __getitem__(self, key: str) -> bool: def __getitem__(self, key: str) -> bool:
return self.matcher(key) return self.matcher(key[len(IDENT_PREFIX) :])
def __iter__(self) -> Iterator[str]: def __iter__(self) -> Iterator[str]:
raise NotImplementedError() raise NotImplementedError()

View File

@ -1,15 +1,18 @@
import collections.abc
import inspect import inspect
import typing
import warnings import warnings
from collections import namedtuple
from collections.abc import MutableMapping
from typing import Any from typing import Any
from typing import Callable
from typing import Iterable from typing import Iterable
from typing import List from typing import List
from typing import Mapping from typing import Mapping
from typing import NamedTuple
from typing import Optional from typing import Optional
from typing import Sequence from typing import Sequence
from typing import Set from typing import Set
from typing import Tuple from typing import Tuple
from typing import TypeVar
from typing import Union from typing import Union
import attr import attr
@ -17,27 +20,45 @@ import attr
from .._code import getfslineno from .._code import getfslineno
from ..compat import ascii_escaped from ..compat import ascii_escaped
from ..compat import NOTSET from ..compat import NOTSET
from ..compat import NotSetType
from ..compat import overload
from ..compat import TYPE_CHECKING
from _pytest.config import Config
from _pytest.outcomes import fail from _pytest.outcomes import fail
from _pytest.warning_types import PytestUnknownMarkWarning from _pytest.warning_types import PytestUnknownMarkWarning
if TYPE_CHECKING:
from _pytest.python import FunctionDefinition
EMPTY_PARAMETERSET_OPTION = "empty_parameter_set_mark" EMPTY_PARAMETERSET_OPTION = "empty_parameter_set_mark"
def istestfunc(func): def istestfunc(func) -> bool:
return ( return (
hasattr(func, "__call__") hasattr(func, "__call__")
and getattr(func, "__name__", "<lambda>") != "<lambda>" and getattr(func, "__name__", "<lambda>") != "<lambda>"
) )
def get_empty_parameterset_mark(config, argnames, func): def get_empty_parameterset_mark(
config: Config, argnames: Sequence[str], func
) -> "MarkDecorator":
from ..nodes import Collector 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) requested_mark = config.getini(EMPTY_PARAMETERSET_OPTION)
if requested_mark in ("", None, "skip"): if requested_mark in ("", None, "skip"):
mark = MARK_GEN.skip mark = MARK_GEN.skip(reason=reason)
elif requested_mark == "xfail": elif requested_mark == "xfail":
mark = MARK_GEN.xfail(run=False) mark = MARK_GEN.xfail(reason=reason, run=False)
elif requested_mark == "fail_at_collect": elif requested_mark == "fail_at_collect":
f_name = func.__name__ f_name = func.__name__
_, lineno = getfslineno(func) _, lineno = getfslineno(func)
@ -46,23 +67,31 @@ def get_empty_parameterset_mark(config, argnames, func):
) )
else: else:
raise LookupError(requested_mark) raise LookupError(requested_mark)
fs, lineno = getfslineno(func) return mark
reason = "got empty parameter set %r, function %s at %s:%d" % (
argnames,
func.__name__, class ParameterSet(
fs, NamedTuple(
lineno, "ParameterSet",
[
("values", Sequence[Union[object, NotSetType]]),
("marks", "typing.Collection[Union[MarkDecorator, Mark]]"),
("id", Optional[str]),
],
) )
return mark(reason=reason) ):
class ParameterSet(namedtuple("ParameterSet", "values, marks, id")):
@classmethod @classmethod
def param(cls, *values, marks=(), id=None): def param(
cls,
*values: object,
marks: "Union[MarkDecorator, typing.Collection[Union[MarkDecorator, Mark]]]" = (),
id: Optional[str] = None
) -> "ParameterSet":
if isinstance(marks, MarkDecorator): if isinstance(marks, MarkDecorator):
marks = (marks,) marks = (marks,)
else: else:
assert isinstance(marks, (tuple, list, set)) # TODO(py36): Change to collections.abc.Collection.
assert isinstance(marks, (collections.abc.Sequence, set))
if id is not None: if id is not None:
if not isinstance(id, str): if not isinstance(id, str):
@ -73,7 +102,11 @@ class ParameterSet(namedtuple("ParameterSet", "values, marks, id")):
return cls(values, marks, id) return cls(values, marks, id)
@classmethod @classmethod
def extract_from(cls, parameterset, force_tuple=False): def extract_from(
cls,
parameterset: Union["ParameterSet", Sequence[object], object],
force_tuple: bool = False,
) -> "ParameterSet":
""" """
:param parameterset: :param parameterset:
a legacy style parameterset that may or may not be a tuple, a legacy style parameterset that may or may not be a tuple,
@ -89,10 +122,20 @@ class ParameterSet(namedtuple("ParameterSet", "values, marks, id")):
if force_tuple: if force_tuple:
return cls.param(parameterset) return cls.param(parameterset)
else: else:
return cls(parameterset, marks=[], id=None) # TODO: Refactor to fix this type-ignore. Currently the following
# type-checks 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] # noqa: F821
@staticmethod @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)): if not isinstance(argnames, (tuple, list)):
argnames = [x.strip() for x in argnames.split(",") if x.strip()] argnames = [x.strip() for x in argnames.split(",") if x.strip()]
force_tuple = len(argnames) == 1 force_tuple = len(argnames) == 1
@ -101,13 +144,23 @@ class ParameterSet(namedtuple("ParameterSet", "values, marks, id")):
return argnames, force_tuple return argnames, force_tuple
@staticmethod @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 [ return [
ParameterSet.extract_from(x, force_tuple=force_tuple) for x in argvalues ParameterSet.extract_from(x, force_tuple=force_tuple) for x in argvalues
] ]
@classmethod @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,
function_definition: "FunctionDefinition",
) -> Tuple[Union[List[str], Tuple[str, ...]], List["ParameterSet"]]:
argnames, force_tuple = cls._parse_parametrize_args(argnames, argvalues) argnames, force_tuple = cls._parse_parametrize_args(argnames, argvalues)
parameters = cls._parse_parametrize_parameters(argvalues, force_tuple) parameters = cls._parse_parametrize_parameters(argvalues, force_tuple)
del argvalues del argvalues
@ -189,6 +242,12 @@ class 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 @attr.s
class MarkDecorator: class MarkDecorator:
"""A decorator for applying a mark on test functions and classes. """A decorator for applying a mark on test functions and classes.
@ -260,7 +319,20 @@ class MarkDecorator:
mark = Mark(self.name, args, kwargs) mark = Mark(self.name, args, kwargs)
return self.__class__(self.mark.combined_with(mark)) return self.__class__(self.mark.combined_with(mark))
def __call__(self, *args: object, **kwargs: object): # 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] # noqa: F821
raise NotImplementedError()
@overload # noqa: F811
def __call__( # noqa: F811
self, *args: object, **kwargs: object
) -> "MarkDecorator":
raise NotImplementedError()
def __call__(self, *args: object, **kwargs: object): # noqa: F811
"""Call the MarkDecorator.""" """Call the MarkDecorator."""
if args and not kwargs: if args and not kwargs:
func = args[0] func = args[0]
@ -271,7 +343,7 @@ class MarkDecorator:
return self.with_args(*args, **kwargs) return self.with_args(*args, **kwargs)
def get_unpacked_marks(obj): def get_unpacked_marks(obj) -> List[Mark]:
""" """
obtain the unpacked marks that are stored on an object obtain the unpacked marks that are stored on an object
""" """
@ -308,6 +380,76 @@ def store_mark(obj, mark: Mark) -> None:
obj.pytestmark = get_unpacked_marks(obj) + [mark] 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:
raise NotImplementedError()
@overload # noqa: F811
def __call__(self, reason: str = ...) -> "MarkDecorator": # noqa: F811
raise NotImplementedError()
class _SkipifMarkDecorator(MarkDecorator):
def __call__( # type: ignore[override]
self,
condition: Union[str, bool] = ...,
*conditions: Union[str, bool],
reason: str = ...
) -> MarkDecorator:
raise NotImplementedError()
class _XfailMarkDecorator(MarkDecorator):
@overload # type: ignore[override,misc]
def __call__(self, arg: _Markable) -> _Markable:
raise NotImplementedError()
@overload # noqa: F811
def __call__( # noqa: F811
self,
condition: Union[str, bool] = ...,
*conditions: Union[str, bool],
reason: str = ...,
run: bool = ...,
raises: Union[BaseException, Tuple[BaseException, ...]] = ...,
strict: bool = ...
) -> MarkDecorator:
raise NotImplementedError()
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[[object], Optional[object]],
]
] = ...,
scope: Optional[_Scope] = ...
) -> MarkDecorator:
raise NotImplementedError()
class _UsefixturesMarkDecorator(MarkDecorator):
def __call__( # type: ignore[override]
self, *fixtures: str
) -> MarkDecorator:
raise NotImplementedError()
class _FilterwarningsMarkDecorator(MarkDecorator):
def __call__( # type: ignore[override]
self, *filters: str
) -> MarkDecorator:
raise NotImplementedError()
class MarkGenerator: class MarkGenerator:
"""Factory for :class:`MarkDecorator` objects - exposed as """Factory for :class:`MarkDecorator` objects - exposed as
a ``pytest.mark`` singleton instance. a ``pytest.mark`` singleton instance.
@ -323,9 +465,18 @@ class MarkGenerator:
applies a 'slowtest' :class:`Mark` on ``test_function``. applies a 'slowtest' :class:`Mark` on ``test_function``.
""" """
_config = None _config = None # type: Optional[Config]
_markers = set() # type: Set[str] _markers = set() # type: Set[str]
# See TYPE_CHECKING above.
if TYPE_CHECKING:
skip = None # type: _SkipMarkDecorator
skipif = None # type: _SkipifMarkDecorator
xfail = None # type: _XfailMarkDecorator
parametrize = None # type: _ParametrizeMarkDecorator
usefixtures = None # type: _UsefixturesMarkDecorator
filterwarnings = None # type: _FilterwarningsMarkDecorator
def __getattr__(self, name: str) -> MarkDecorator: def __getattr__(self, name: str) -> MarkDecorator:
if name[0] == "_": if name[0] == "_":
raise AttributeError("Marker name must NOT start with underscore") raise AttributeError("Marker name must NOT start with underscore")
@ -370,7 +521,7 @@ class MarkGenerator:
MARK_GEN = MarkGenerator() MARK_GEN = MarkGenerator()
class NodeKeywords(MutableMapping): class NodeKeywords(collections.abc.MutableMapping):
def __init__(self, node): def __init__(self, node):
self.node = node self.node = node
self.parent = node.parent self.parent = node.parent
@ -400,8 +551,8 @@ class NodeKeywords(MutableMapping):
seen.update(self.parent.keywords) seen.update(self.parent.keywords)
return seen return seen
def __len__(self): def __len__(self) -> int:
return len(self._seen()) return len(self._seen())
def __repr__(self): def __repr__(self) -> str:
return "<NodeKeywords for node {}>".format(self.node) return "<NodeKeywords for node {}>".format(self.node)

View File

@ -4,17 +4,29 @@ import re
import sys import sys
import warnings import warnings
from contextlib import contextmanager from contextlib import contextmanager
from typing import Any
from typing import Generator from typing import Generator
from typing import List
from typing import MutableMapping
from typing import Optional
from typing import Tuple
from typing import TypeVar
from typing import Union
import pytest import pytest
from _pytest.compat import overload
from _pytest.fixtures import fixture from _pytest.fixtures import fixture
from _pytest.pathlib import Path from _pytest.pathlib import Path
RE_IMPORT_ERROR_NAME = re.compile(r"^No module named (.*)$") RE_IMPORT_ERROR_NAME = re.compile(r"^No module named (.*)$")
K = TypeVar("K")
V = TypeVar("V")
@fixture @fixture
def monkeypatch(): def monkeypatch() -> Generator["MonkeyPatch", None, None]:
"""The returned ``monkeypatch`` fixture provides these """The returned ``monkeypatch`` fixture provides these
helper methods to modify objects, dictionaries or os.environ:: helper methods to modify objects, dictionaries or os.environ::
@ -37,7 +49,7 @@ def monkeypatch():
mpatch.undo() mpatch.undo()
def resolve(name): def resolve(name: str) -> object:
# simplified from zope.dottedname # simplified from zope.dottedname
parts = name.split(".") parts = name.split(".")
@ -61,24 +73,24 @@ def resolve(name):
if expected == used: if expected == used:
raise raise
else: else:
raise ImportError("import error in {}: {}".format(used, ex)) raise ImportError("import error in {}: {}".format(used, ex)) from ex
found = annotated_getattr(found, part, used) found = annotated_getattr(found, part, used)
return found return found
def annotated_getattr(obj, name, ann): def annotated_getattr(obj: object, name: str, ann: str) -> object:
try: try:
obj = getattr(obj, name) obj = getattr(obj, name)
except AttributeError: except AttributeError as e:
raise AttributeError( raise AttributeError(
"{!r} object at {} has no attribute {!r}".format( "{!r} object at {} has no attribute {!r}".format(
type(obj).__name__, ann, name type(obj).__name__, ann, name
) )
) ) from e
return obj return obj
def derive_importpath(import_path, raising): def derive_importpath(import_path: str, raising: bool) -> Tuple[str, object]:
if not isinstance(import_path, str) or "." not in import_path: if not isinstance(import_path, str) or "." not in import_path:
raise TypeError( raise TypeError(
"must be absolute import path string, not {!r}".format(import_path) "must be absolute import path string, not {!r}".format(import_path)
@ -91,7 +103,7 @@ def derive_importpath(import_path, raising):
class Notset: class Notset:
def __repr__(self): def __repr__(self) -> str:
return "<notset>" return "<notset>"
@ -102,11 +114,13 @@ class MonkeyPatch:
""" Object returned by the ``monkeypatch`` fixture keeping a record of setattr/item/env/syspath changes. """ Object returned by the ``monkeypatch`` fixture keeping a record of setattr/item/env/syspath changes.
""" """
def __init__(self): def __init__(self) -> None:
self._setattr = [] self._setattr = [] # type: List[Tuple[object, str, object]]
self._setitem = [] self._setitem = (
self._cwd = None []
self._savesyspath = None ) # type: List[Tuple[MutableMapping[Any, Any], object, object]]
self._cwd = None # type: Optional[str]
self._savesyspath = None # type: Optional[List[str]]
@contextmanager @contextmanager
def context(self) -> Generator["MonkeyPatch", None, None]: def context(self) -> Generator["MonkeyPatch", None, None]:
@ -133,7 +147,25 @@ class MonkeyPatch:
finally: finally:
m.undo() m.undo()
def setattr(self, target, name, value=notset, raising=True): @overload
def setattr(
self, target: str, name: object, value: Notset = ..., raising: bool = ...,
) -> None:
raise NotImplementedError()
@overload # noqa: F811
def setattr( # noqa: F811
self, target: object, name: str, value: object, raising: bool = ...,
) -> None:
raise NotImplementedError()
def setattr( # noqa: F811
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. """ Set attribute value on target, memorizing the old value.
By default raise AttributeError if the attribute did not exist. By default raise AttributeError if the attribute did not exist.
@ -150,7 +182,7 @@ class MonkeyPatch:
__tracebackhide__ = True __tracebackhide__ = True
import inspect import inspect
if value is notset: if isinstance(value, Notset):
if not isinstance(target, str): if not isinstance(target, str):
raise TypeError( raise TypeError(
"use setattr(target, name, value) or " "use setattr(target, name, value) or "
@ -159,6 +191,13 @@ class MonkeyPatch:
) )
value = name value = name
name, target = derive_importpath(target, raising) 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) oldval = getattr(target, name, notset)
if raising and oldval is notset: if raising and oldval is notset:
@ -170,7 +209,12 @@ class MonkeyPatch:
self._setattr.append((target, name, oldval)) self._setattr.append((target, name, oldval))
setattr(target, name, value) setattr(target, name, value)
def delattr(self, target, name=notset, raising=True): def delattr(
self,
target: Union[object, str],
name: Union[str, Notset] = notset,
raising: bool = True,
) -> None:
""" Delete attribute ``name`` from ``target``, by default raise """ Delete attribute ``name`` from ``target``, by default raise
AttributeError it the attribute did not previously exist. AttributeError it the attribute did not previously exist.
@ -184,7 +228,7 @@ class MonkeyPatch:
__tracebackhide__ = True __tracebackhide__ = True
import inspect import inspect
if name is notset: if isinstance(name, Notset):
if not isinstance(target, str): if not isinstance(target, str):
raise TypeError( raise TypeError(
"use delattr(target, name) or " "use delattr(target, name) or "
@ -204,12 +248,12 @@ class MonkeyPatch:
self._setattr.append((target, name, oldval)) self._setattr.append((target, name, oldval))
delattr(target, name) delattr(target, name)
def setitem(self, dic, name, value): def setitem(self, dic: MutableMapping[K, V], name: K, value: V) -> None:
""" Set dictionary entry ``name`` to value. """ """ Set dictionary entry ``name`` to value. """
self._setitem.append((dic, name, dic.get(name, notset))) self._setitem.append((dic, name, dic.get(name, notset)))
dic[name] = value dic[name] = value
def delitem(self, dic, name, raising=True): def delitem(self, dic: MutableMapping[K, V], name: K, raising: bool = True) -> None:
""" Delete ``name`` from dict. Raise KeyError if it doesn't exist. """ Delete ``name`` from dict. Raise KeyError if it doesn't exist.
If ``raising`` is set to False, no exception will be raised if the If ``raising`` is set to False, no exception will be raised if the
@ -222,7 +266,7 @@ class MonkeyPatch:
self._setitem.append((dic, name, dic.get(name, notset))) self._setitem.append((dic, name, dic.get(name, notset)))
del dic[name] del dic[name]
def setenv(self, name, value, prepend=None): def setenv(self, name: str, value: str, prepend: Optional[str] = None) -> None:
""" Set environment variable ``name`` to ``value``. If ``prepend`` """ Set environment variable ``name`` to ``value``. If ``prepend``
is a character, read the current environment variable value is a character, read the current environment variable value
and prepend the ``value`` adjoined with the ``prepend`` character.""" and prepend the ``value`` adjoined with the ``prepend`` character."""
@ -241,16 +285,17 @@ class MonkeyPatch:
value = value + prepend + os.environ[name] value = value + prepend + os.environ[name]
self.setitem(os.environ, name, value) self.setitem(os.environ, name, value)
def delenv(self, name, raising=True): def delenv(self, name: str, raising: bool = True) -> None:
""" Delete ``name`` from the environment. Raise KeyError if it does """ Delete ``name`` from the environment. Raise KeyError if it does
not exist. not exist.
If ``raising`` is set to False, no exception will be raised if the If ``raising`` is set to False, no exception will be raised if the
environment variable is missing. environment variable is missing.
""" """
self.delitem(os.environ, name, raising=raising) environ = os.environ # type: MutableMapping[str, str]
self.delitem(environ, name, raising=raising)
def syspath_prepend(self, path): def syspath_prepend(self, path) -> None:
""" Prepend ``path`` to ``sys.path`` list of import locations. """ """ Prepend ``path`` to ``sys.path`` list of import locations. """
from pkg_resources import fixup_namespace_packages from pkg_resources import fixup_namespace_packages
@ -272,7 +317,7 @@ class MonkeyPatch:
invalidate_caches() invalidate_caches()
def chdir(self, path): def chdir(self, path) -> None:
""" Change the current working directory to the specified path. """ Change the current working directory to the specified path.
Path can be a string or a py.path.local object. Path can be a string or a py.path.local object.
""" """
@ -286,7 +331,7 @@ class MonkeyPatch:
else: else:
os.chdir(path) os.chdir(path)
def undo(self): def undo(self) -> None:
""" Undo previous changes. This call consumes the """ Undo previous changes. This call consumes the
undo stack. Calling it a second time has no effect unless undo stack. Calling it a second time has no effect unless
you do more monkeypatching after the undo call. you do more monkeypatching after the undo call.
@ -306,14 +351,14 @@ class MonkeyPatch:
else: else:
delattr(obj, name) delattr(obj, name)
self._setattr[:] = [] self._setattr[:] = []
for dictionary, name, value in reversed(self._setitem): for dictionary, key, value in reversed(self._setitem):
if value is notset: if value is notset:
try: try:
del dictionary[name] del dictionary[key]
except KeyError: except KeyError:
pass # was already deleted, so we have the desired state pass # was already deleted, so we have the desired state
else: else:
dictionary[name] = value dictionary[key] = value
self._setitem[:] = [] self._setitem[:] = []
if self._savesyspath is not None: if self._savesyspath is not None:
sys.path[:] = self._savesyspath sys.path[:] = self._savesyspath

Some files were not shown because too many files have changed in this diff Show More