diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index 45e386e57..262ed5946 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -94,7 +94,7 @@ jobs:
os: ubuntu-latest
tox_env: "py38-xdist"
- name: "ubuntu-py39"
- python: "3.8"
+ python: "3.9-dev"
os: ubuntu-latest
tox_env: "py39-xdist"
- name: "ubuntu-pypy3"
@@ -128,18 +128,18 @@ jobs:
steps:
- uses: actions/checkout@v2
- # For setuptools-scm.
- - run: git fetch --prune --unshallow
+ with:
+ fetch-depth: 0
- name: Set up Python ${{ matrix.python }}
uses: actions/setup-python@v2
+ if: matrix.python != '3.9-dev'
+ with:
+ python-version: ${{ matrix.python }}
+ - name: Set up Python ${{ matrix.python }} (deadsnakes)
+ uses: deadsnakes/action@v1.0.0
+ if: matrix.python == '3.9-dev'
with:
python-version: ${{ matrix.python }}
- - name: install python3.9
- if: matrix.tox_env == 'py39-xdist'
- run: |
- sudo add-apt-repository ppa:deadsnakes/nightly
- sudo apt-get update
- sudo apt-get install -y --no-install-recommends python3.9-dev python3.9-distutils
- name: Install dependencies
run: |
python -m pip install --upgrade pip
@@ -177,8 +177,8 @@ jobs:
steps:
- uses: actions/checkout@v2
- # For setuptools-scm.
- - run: git fetch --prune --unshallow
+ with:
+ fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v2
with:
diff --git a/.github/workflows/release-on-comment.yml b/.github/workflows/release-on-comment.yml
index 9d803cd38..94863d896 100644
--- a/.github/workflows/release-on-comment.yml
+++ b/.github/workflows/release-on-comment.yml
@@ -15,8 +15,8 @@ jobs:
steps:
- uses: actions/checkout@v2
- # For setuptools-scm.
- - run: git fetch --prune --unshallow
+ with:
+ fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v2
diff --git a/AUTHORS b/AUTHORS
index 5822c74f2..e1b195b9a 100644
--- a/AUTHORS
+++ b/AUTHORS
@@ -63,6 +63,7 @@ Christian Tismer
Christoph Buelter
Christopher Dignam
Christopher Gilling
+Claire Cecil
Claudio Madotto
CrazyMerlyn
Cyrus Maden
@@ -102,11 +103,13 @@ Fabio Zadrozny
Felix Nieuwenhuizen
Feng Ma
Florian Bruhin
+Florian Dahlitz
Floris Bruynooghe
Gabriel Reis
Gene Wood
George Kussumoto
Georgy Dyuldin
+Gleb Nikonorov
Graham Horler
Greg Price
Gregory Lee
@@ -275,6 +278,7 @@ Tom Dalton
Tom Viner
TomΓ‘Ε‘ GavenΔiak
Tomer Keren
+Tor Colvin
Trevor Bekolay
Tyler Goodlet
Tzu-ping Chung
diff --git a/changelog/1316.breaking.rst b/changelog/1316.breaking.rst
new file mode 100644
index 000000000..4c01de728
--- /dev/null
+++ b/changelog/1316.breaking.rst
@@ -0,0 +1 @@
+``TestReport.longrepr`` is now always an instance of ``ReprExceptionInfo``. Previously it was a ``str`` when a test failed with ``pytest.fail(..., pytrace=False)``.
diff --git a/changelog/6428.bugfix.rst b/changelog/6428.bugfix.rst
new file mode 100644
index 000000000..581b2b7ce
--- /dev/null
+++ b/changelog/6428.bugfix.rst
@@ -0,0 +1,2 @@
+Paths appearing in error messages are now correct in case the current working directory has
+changed since the start of the session.
diff --git a/changelog/6433.feature.rst b/changelog/6433.feature.rst
new file mode 100644
index 000000000..c331b0f58
--- /dev/null
+++ b/changelog/6433.feature.rst
@@ -0,0 +1,10 @@
+If an error is encountered while formatting the message in a logging call, for
+example ``logging.warning("oh no!: %s: %s", "first")`` (a second argument is
+missing), pytest now propagates the error, likely causing the test to fail.
+
+Previously, such a mistake would cause an error to be printed to stderr, which
+is not displayed by default for passing tests. This change makes the mistake
+visible during testing.
+
+You may supress this behavior temporarily or permanently by setting
+``logging.raiseExceptions = False``.
diff --git a/changelog/6755.bugfix.rst b/changelog/6755.bugfix.rst
new file mode 100644
index 000000000..8077baa4f
--- /dev/null
+++ b/changelog/6755.bugfix.rst
@@ -0,0 +1 @@
+Support deleting paths longer than 260 characters on windows created inside tmpdir.
diff --git a/changelog/6856.feature.rst b/changelog/6856.feature.rst
new file mode 100644
index 000000000..36892fa21
--- /dev/null
+++ b/changelog/6856.feature.rst
@@ -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.
diff --git a/changelog/6956.bugfix.rst b/changelog/6956.bugfix.rst
new file mode 100644
index 000000000..a88ef94b6
--- /dev/null
+++ b/changelog/6956.bugfix.rst
@@ -0,0 +1 @@
+Prevent pytest from printing ConftestImportFailure traceback to stdout.
diff --git a/changelog/7091.improvement.rst b/changelog/7091.improvement.rst
new file mode 100644
index 000000000..72f17c5e4
--- /dev/null
+++ b/changelog/7091.improvement.rst
@@ -0,0 +1,4 @@
+When ``fd`` capturing is used, through ``--capture=fd`` or the ``capfd`` and
+``capfdbinary`` fixtures, and the file descriptor (0, 1, 2) cannot be
+duplicated, FD capturing is still performed. Previously, direct writes to the
+file descriptors would fail or be lost in this case.
diff --git a/changelog/7128.improvement.rst b/changelog/7128.improvement.rst
new file mode 100644
index 000000000..9d24d567a
--- /dev/null
+++ b/changelog/7128.improvement.rst
@@ -0,0 +1 @@
+`pytest --version` now displays just the pytest version, while `pytest --version --version` displays more verbose information including plugins.
diff --git a/changelog/7150.bugfix.rst b/changelog/7150.bugfix.rst
new file mode 100644
index 000000000..42cf5c7d2
--- /dev/null
+++ b/changelog/7150.bugfix.rst
@@ -0,0 +1 @@
+Prevent hiding the underlying exception when ``ConfTestImportFailure`` is raised.
diff --git a/changelog/7202.doc.rst b/changelog/7202.doc.rst
new file mode 100644
index 000000000..143f28d40
--- /dev/null
+++ b/changelog/7202.doc.rst
@@ -0,0 +1 @@
+The development guide now links to the contributing section of the docs and 'RELEASING.rst' on GitHub.
diff --git a/changelog/7215.bugfix.rst b/changelog/7215.bugfix.rst
new file mode 100644
index 000000000..815149132
--- /dev/null
+++ b/changelog/7215.bugfix.rst
@@ -0,0 +1,2 @@
+Fix regression where running with ``--pdb`` would call the ``tearDown`` methods of ``unittest.TestCase``
+subclasses for skipped tests.
diff --git a/changelog/7233.doc.rst b/changelog/7233.doc.rst
new file mode 100644
index 000000000..c57f4d61f
--- /dev/null
+++ b/changelog/7233.doc.rst
@@ -0,0 +1 @@
+Add a note about ``--strict`` and ``--strict-markers`` and the preference for the latter one.
diff --git a/changelog/7255.feature.rst b/changelog/7255.feature.rst
new file mode 100644
index 000000000..4073589b0
--- /dev/null
+++ b/changelog/7255.feature.rst
@@ -0,0 +1,3 @@
+Introduced a new hook named `pytest_warning_recorded` to convey information about warnings captured by the internal `pytest` warnings plugin.
+
+This hook is meant to replace `pytest_warning_captured`, which will be removed in a future release.
diff --git a/changelog/7264.improvement.rst b/changelog/7264.improvement.rst
new file mode 100644
index 000000000..035745c4d
--- /dev/null
+++ b/changelog/7264.improvement.rst
@@ -0,0 +1 @@
+The dependency on the ``wcwidth`` package has been removed.
diff --git a/codecov.yml b/codecov.yml
index db2472009..f1cc86973 100644
--- a/codecov.yml
+++ b/codecov.yml
@@ -1 +1,6 @@
-comment: off
+# reference: https://docs.codecov.io/docs/codecovyml-reference
+coverage:
+ status:
+ patch: true
+ project: false
+comment: false
diff --git a/doc/en/_themes/flask/relations.html b/doc/en/_templates/relations.html
similarity index 100%
rename from doc/en/_themes/flask/relations.html
rename to doc/en/_templates/relations.html
diff --git a/doc/en/_themes/flask/slim_searchbox.html b/doc/en/_templates/slim_searchbox.html
similarity index 100%
rename from doc/en/_themes/flask/slim_searchbox.html
rename to doc/en/_templates/slim_searchbox.html
diff --git a/doc/en/_themes/.gitignore b/doc/en/_themes/.gitignore
deleted file mode 100644
index 66b6e4c2f..000000000
--- a/doc/en/_themes/.gitignore
+++ /dev/null
@@ -1,3 +0,0 @@
-*.pyc
-*.pyo
-.DS_Store
diff --git a/doc/en/_themes/LICENSE b/doc/en/_themes/LICENSE
deleted file mode 100644
index 8daab7ee6..000000000
--- a/doc/en/_themes/LICENSE
+++ /dev/null
@@ -1,37 +0,0 @@
-Copyright (c) 2010 by Armin Ronacher.
-
-Some rights reserved.
-
-Redistribution and use in source and binary forms of the theme, with or
-without modification, are permitted provided that the following conditions
-are met:
-
-* Redistributions of source code must retain the above copyright
- notice, this list of conditions and the following disclaimer.
-
-* Redistributions in binary form must reproduce the above
- copyright notice, this list of conditions and the following
- disclaimer in the documentation and/or other materials provided
- with the distribution.
-
-* The names of the contributors may not be used to endorse or
- promote products derived from this software without specific
- prior written permission.
-
-We kindly ask you to only use these themes in an unmodified manner just
-for Flask and Flask-related products, not for unrelated projects. If you
-like the visual style and want to use it for your own projects, please
-consider making some larger changes to the themes (such as changing
-font faces, sizes, colors or margins).
-
-THIS THEME IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
-AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
-LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-ARISING IN ANY WAY OUT OF THE USE OF THIS THEME, EVEN IF ADVISED OF THE
-POSSIBILITY OF SUCH DAMAGE.
diff --git a/doc/en/_themes/README b/doc/en/_themes/README
deleted file mode 100644
index b3292bdff..000000000
--- a/doc/en/_themes/README
+++ /dev/null
@@ -1,31 +0,0 @@
-Flask Sphinx Styles
-===================
-
-This repository contains sphinx styles for Flask and Flask related
-projects. To use this style in your Sphinx documentation, follow
-this guide:
-
-1. put this folder as _themes into your docs folder. Alternatively
- you can also use git submodules to check out the contents there.
-2. add this to your conf.py:
-
- sys.path.append(os.path.abspath('_themes'))
- html_theme_path = ['_themes']
- html_theme = 'flask'
-
-The following themes exist:
-
-- 'flask' - the standard flask documentation theme for large
- projects
-- 'flask_small' - small one-page theme. Intended to be used by
- very small addon libraries for flask.
-
-The following options exist for the flask_small theme:
-
- [options]
- index_logo = '' filename of a picture in _static
- to be used as replacement for the
- h1 in the index.rst file.
- index_logo_height = 120px height of the index logo
- github_fork = '' repository name on github for the
- "fork me" badge
diff --git a/doc/en/_themes/flask/layout.html b/doc/en/_themes/flask/layout.html
deleted file mode 100644
index f2fa8e6aa..000000000
--- a/doc/en/_themes/flask/layout.html
+++ /dev/null
@@ -1,24 +0,0 @@
-{%- extends "basic/layout.html" %}
-{%- block extrahead %}
- {{ super() }}
- {% if theme_touch_icon %}
-
- {% endif %}
-
-{% endblock %}
-{%- block relbar2 %}{% endblock %}
-{% block header %}
- {{ super() }}
- {% if pagename == 'index' %}
-
- {% endif %}
-{% endblock %}
-{%- block footer %}
-
- {% if pagename == 'index' %}
-
- {% endif %}
-{%- endblock %}
diff --git a/doc/en/_themes/flask/static/flasky.css_t b/doc/en/_themes/flask/static/flasky.css_t
deleted file mode 100644
index 108c85401..000000000
--- a/doc/en/_themes/flask/static/flasky.css_t
+++ /dev/null
@@ -1,623 +0,0 @@
-/*
- * flasky.css_t
- * ~~~~~~~~~~~~
- *
- * :copyright: Copyright 2010 by Armin Ronacher.
- * :license: Flask Design License, see LICENSE for details.
- */
-
-{% set page_width = '1020px' %}
-{% set sidebar_width = '220px' %}
-/* muted version of green logo color #C9D22A */
-{% set link_color = '#606413' %}
-/* blue logo color */
-{% set link_hover_color = '#009de0' %}
-{% set base_font = 'sans-serif' %}
-{% set header_font = 'sans-serif' %}
-
-@import url("basic.css");
-
-/* -- page layout ----------------------------------------------------------- */
-
-body {
- font-family: {{ base_font }};
- font-size: 16px;
- background-color: white;
- color: #000;
- margin: 0;
- padding: 0;
-}
-
-div.document {
- width: {{ page_width }};
- margin: 30px auto 0 auto;
-}
-
-div.documentwrapper {
- float: left;
- width: 100%;
-}
-
-div.bodywrapper {
- margin: 0 0 0 {{ sidebar_width }};
-}
-
-div.sphinxsidebar {
- width: {{ sidebar_width }};
-}
-
-hr {
- border: 0;
- border-top: 1px solid #B1B4B6;
-}
-
-div.body {
- background-color: #ffffff;
- color: #3E4349;
- padding: 0 30px 0 30px;
-}
-
-img.floatingflask {
- padding: 0 0 10px 10px;
- float: right;
-}
-
-div.footer {
- width: {{ page_width }};
- margin: 20px auto 30px auto;
- font-size: 14px;
- color: #888;
- text-align: right;
-}
-
-div.footer a {
- color: #888;
-}
-
-div.related {
- display: none;
-}
-
-div.sphinxsidebar a {
- text-decoration: none;
- border-bottom: none;
-}
-
-div.sphinxsidebar a:hover {
- color: {{ link_hover_color }};
- border-bottom: 1px solid {{ link_hover_color }};
-}
-
-div.sphinxsidebar {
- font-size: 14px;
- line-height: 1.5;
-}
-
-div.sphinxsidebarwrapper {
- padding: 18px 10px;
-}
-
-div.sphinxsidebarwrapper p.logo {
- padding: 0 0 20px 0;
- margin: 0;
- text-align: center;
-}
-
-div.sphinxsidebar h3,
-div.sphinxsidebar h4 {
- font-family: {{ header_font }};
- color: #444;
- font-size: 21px;
- font-weight: normal;
- margin: 16px 0 0 0;
- padding: 0;
-}
-
-div.sphinxsidebar h4 {
- font-size: 18px;
-}
-
-div.sphinxsidebar h3 a {
- color: #444;
-}
-
-div.sphinxsidebar p.logo a,
-div.sphinxsidebar h3 a,
-div.sphinxsidebar p.logo a:hover,
-div.sphinxsidebar h3 a:hover {
- border: none;
-}
-
-div.sphinxsidebar p {
- color: #555;
- margin: 10px 0;
-}
-
-div.sphinxsidebar ul {
- margin: 10px 0;
- padding: 0;
- color: #000;
-}
-
-div.sphinxsidebar input {
- border: 1px solid #ccc;
- font-family: {{ base_font }};
- font-size: 1em;
-}
-
-/* -- body styles ----------------------------------------------------------- */
-
-a {
- color: {{ link_color }};
- text-decoration: underline;
-}
-
-a:hover {
- color: {{ link_hover_color }};
- text-decoration: underline;
-}
-
-a.reference.internal em {
- font-style: normal;
-}
-
-div.body h1,
-div.body h2,
-div.body h3,
-div.body h4,
-div.body h5,
-div.body h6 {
- font-family: {{ header_font }};
- font-weight: normal;
- margin: 30px 0px 10px 0px;
- padding: 0;
-}
-
-{% if theme_index_logo %}
-div.indexwrapper h1 {
- text-indent: -999999px;
- background: url({{ theme_index_logo }}) no-repeat center center;
- height: {{ theme_index_logo_height }};
-}
-{% else %}
-div.indexwrapper div.body h1 {
- font-size: 200%;
-}
-{% endif %}
-div.body h1 { margin-top: 0; padding-top: 0; font-size: 240%; }
-div.body h2 { font-size: 180%; }
-div.body h3 { font-size: 150%; }
-div.body h4 { font-size: 130%; }
-div.body h5 { font-size: 100%; }
-div.body h6 { font-size: 100%; }
-
-a.headerlink {
- color: #ddd;
- padding: 0 4px;
- text-decoration: none;
-}
-
-a.headerlink:hover {
- color: #444;
- background: #eaeaea;
-}
-
-div.body p, div.body dd, div.body li {
- line-height: 1.4em;
-}
-
-ul.simple li {
- margin-bottom: 0.5em;
-}
-
-div.topic ul.simple li {
- margin-bottom: 0;
-}
-
-div.topic li > p:first-child {
- margin-top: 0;
- margin-bottom: 0;
-}
-
-div.admonition {
- background: #fafafa;
- padding: 10px 20px;
- border-top: 1px solid #ccc;
- border-bottom: 1px solid #ccc;
-}
-
-div.admonition tt.xref, div.admonition a tt {
- border-bottom: 1px solid #fafafa;
-}
-
-div.admonition p.admonition-title {
- font-family: {{ header_font }};
- font-weight: normal;
- font-size: 24px;
- margin: 0 0 10px 0;
- padding: 0;
- line-height: 1;
-}
-
-div.admonition :last-child {
- margin-bottom: 0;
-}
-
-div.highlight {
- background-color: white;
-}
-
-dt:target, .highlight {
- background: #FAF3E8;
-}
-
-div.note, div.warning {
- background-color: #eee;
- border: 1px solid #ccc;
-}
-
-div.seealso {
- background-color: #ffc;
- border: 1px solid #ff6;
-}
-
-div.topic {
- background-color: #eee;
-}
-
-div.topic a {
- text-decoration: none;
- border-bottom: none;
-}
-
-p.admonition-title {
- display: inline;
-}
-
-p.admonition-title:after {
- content: ":";
-}
-
-pre, tt, code {
- font-family: 'Consolas', 'Menlo', 'Deja Vu Sans Mono', 'Bitstream Vera Sans Mono', monospace;
- font-size: 0.9em;
- background: #eee;
-}
-
-img.screenshot {
-}
-
-tt.descname, tt.descclassname {
- font-size: 0.95em;
-}
-
-tt.descname {
- padding-right: 0.08em;
-}
-
-img.screenshot {
- -moz-box-shadow: 2px 2px 4px #eee;
- -webkit-box-shadow: 2px 2px 4px #eee;
- box-shadow: 2px 2px 4px #eee;
-}
-
-table.docutils {
- border: 1px solid #888;
- -moz-box-shadow: 2px 2px 4px #eee;
- -webkit-box-shadow: 2px 2px 4px #eee;
- box-shadow: 2px 2px 4px #eee;
-}
-
-table.docutils td, table.docutils th {
- border: 1px solid #888;
- padding: 0.25em 0.7em;
-}
-
-table.field-list, table.footnote {
- border: none;
- -moz-box-shadow: none;
- -webkit-box-shadow: none;
- box-shadow: none;
-}
-
-table.footnote {
- margin: 15px 0;
- width: 100%;
- border: 1px solid #eee;
- background: #fdfdfd;
- font-size: 0.9em;
-}
-
-table.footnote + table.footnote {
- margin-top: -15px;
- border-top: none;
-}
-
-table.field-list th {
- padding: 0 0.8em 0 0;
-}
-
-table.field-list td {
- padding: 0;
-}
-
-table.footnote td.label {
- width: 0px;
- padding: 0.3em 0 0.3em 0.5em;
-}
-
-table.footnote td {
- padding: 0.3em 0.5em;
-}
-
-dl {
- margin: 0;
- padding: 0;
-}
-
-dl dd {
- margin-left: 30px;
-}
-
-blockquote {
- margin: 0 0 0 30px;
- padding: 0;
-}
-
-ul, ol {
- margin: 10px 0 10px 30px;
- padding: 0;
-}
-
-pre {
- background: #eee;
- padding: 7px 12px;
- line-height: 1.3em;
-}
-
-tt {
- background-color: #ecf0f3;
- color: #222;
- /* padding: 1px 2px; */
-}
-
-tt.xref, a tt {
- background-color: #FBFBFB;
- border-bottom: 1px solid white;
-}
-
-a.reference {
- text-decoration: none;
- border-bottom: 1px dotted {{ link_color }};
-}
-
-a.reference:hover {
- border-bottom: 1px solid {{ link_hover_color }};
-}
-
-li.toctree-l1 a.reference,
-li.toctree-l2 a.reference,
-li.toctree-l3 a.reference,
-li.toctree-l4 a.reference {
- border-bottom: none;
-}
-
-li.toctree-l1 a.reference:hover,
-li.toctree-l2 a.reference:hover,
-li.toctree-l3 a.reference:hover,
-li.toctree-l4 a.reference:hover {
- border-bottom: 1px solid {{ link_hover_color }};
-}
-
-a.footnote-reference {
- text-decoration: none;
- font-size: 0.7em;
- vertical-align: top;
- border-bottom: 1px dotted {{ link_color }};
-}
-
-a.footnote-reference:hover {
- border-bottom: 1px solid {{ link_hover_color }};
-}
-
-a:hover tt {
- background: #EEE;
-}
-
-#reference div.section h2 {
- /* separate code elements in the reference section */
- border-top: 2px solid #ccc;
- padding-top: 0.5em;
-}
-
-#reference div.section h3 {
- /* separate code elements in the reference section */
- border-top: 1px solid #ccc;
- padding-top: 0.5em;
-}
-
-dl.class, dl.function {
- margin-top: 1em;
- margin-bottom: 1em;
-}
-
-dl.class > dd {
- border-left: 3px solid #ccc;
- margin-left: 0px;
- padding-left: 30px;
-}
-
-dl.field-list {
- flex-direction: column;
-}
-
-dl.field-list dd {
- padding-left: 4em;
- border-left: 3px solid #ccc;
- margin-bottom: 0.5em;
-}
-
-dl.field-list dd > ul {
- list-style: none;
- padding-left: 0px;
-}
-
-dl.field-list dd > ul > li li :first-child {
- text-indent: 0;
-}
-
-dl.field-list dd > ul > li :first-child {
- text-indent: -2em;
- padding-left: 0px;
-}
-
-dl.field-list dd > p:first-child {
- text-indent: -2em;
-}
-
-@media screen and (max-width: 870px) {
-
- div.sphinxsidebar {
- display: none;
- }
-
- div.document {
- width: 100%;
-
- }
-
- div.documentwrapper {
- margin-left: 0;
- margin-top: 0;
- margin-right: 0;
- margin-bottom: 0;
- }
-
- div.bodywrapper {
- margin-top: 0;
- margin-right: 0;
- margin-bottom: 0;
- margin-left: 0;
- }
-
- ul {
- margin-left: 0;
- }
-
- .document {
- width: auto;
- }
-
- .footer {
- width: auto;
- }
-
- .bodywrapper {
- margin: 0;
- }
-
- .footer {
- width: auto;
- }
-
- .github {
- display: none;
- }
-
-
-
-}
-
-
-
-@media screen and (max-width: 875px) {
-
- body {
- margin: 0;
- padding: 20px 30px;
- }
-
- div.documentwrapper {
- float: none;
- background: white;
- }
-
- div.sphinxsidebar {
- display: block;
- float: none;
- width: 102.5%;
- margin: 50px -30px -20px -30px;
- padding: 10px 20px;
- background: #333;
- color: white;
- }
-
- div.sphinxsidebar h3, div.sphinxsidebar h4, div.sphinxsidebar p,
- div.sphinxsidebar h3 a, div.sphinxsidebar ul {
- color: white;
- }
-
- div.sphinxsidebar a {
- color: #aaa;
- }
-
- div.sphinxsidebar p.logo {
- display: none;
- }
-
- div.document {
- width: 100%;
- margin: 0;
- }
-
- div.related {
- display: block;
- margin: 0;
- padding: 10px 0 20px 0;
- }
-
- div.related ul,
- div.related ul li {
- margin: 0;
- padding: 0;
- }
-
- div.footer {
- display: none;
- }
-
- div.bodywrapper {
- margin: 0;
- }
-
- div.body {
- min-height: 0;
- padding: 0;
- }
-
- .rtd_doc_footer {
- display: none;
- }
-
- .document {
- width: auto;
- }
-
- .footer {
- width: auto;
- }
-
- .footer {
- width: auto;
- }
-
- .github {
- display: none;
- }
-}
-
-/* misc. */
-
-.revsys-inline {
- display: none!important;
-}
diff --git a/doc/en/_themes/flask/theme.conf b/doc/en/_themes/flask/theme.conf
deleted file mode 100644
index 372b00283..000000000
--- a/doc/en/_themes/flask/theme.conf
+++ /dev/null
@@ -1,9 +0,0 @@
-[theme]
-inherit = basic
-stylesheet = flasky.css
-pygments_style = flask_theme_support.FlaskyStyle
-
-[options]
-index_logo = ''
-index_logo_height = 120px
-touch_icon =
diff --git a/doc/en/_themes/flask_theme_support.py b/doc/en/_themes/flask_theme_support.py
deleted file mode 100644
index b107f2c89..000000000
--- a/doc/en/_themes/flask_theme_support.py
+++ /dev/null
@@ -1,87 +0,0 @@
-# flasky extensions. flasky pygments style based on tango style
-from pygments.style import Style
-from pygments.token import Comment
-from pygments.token import Error
-from pygments.token import Generic
-from pygments.token import Keyword
-from pygments.token import Literal
-from pygments.token import Name
-from pygments.token import Number
-from pygments.token import Operator
-from pygments.token import Other
-from pygments.token import Punctuation
-from pygments.token import String
-from pygments.token import Whitespace
-
-
-class FlaskyStyle(Style):
- background_color = "#f8f8f8"
- default_style = ""
-
- styles = {
- # No corresponding class for the following:
- # Text: "", # class: ''
- Whitespace: "underline #f8f8f8", # class: 'w'
- Error: "#a40000 border:#ef2929", # class: 'err'
- Other: "#000000", # class 'x'
- Comment: "italic #8f5902", # class: 'c'
- Comment.Preproc: "noitalic", # class: 'cp'
- Keyword: "bold #004461", # class: 'k'
- Keyword.Constant: "bold #004461", # class: 'kc'
- Keyword.Declaration: "bold #004461", # class: 'kd'
- Keyword.Namespace: "bold #004461", # class: 'kn'
- Keyword.Pseudo: "bold #004461", # class: 'kp'
- Keyword.Reserved: "bold #004461", # class: 'kr'
- Keyword.Type: "bold #004461", # class: 'kt'
- Operator: "#582800", # class: 'o'
- Operator.Word: "bold #004461", # class: 'ow' - like keywords
- Punctuation: "bold #000000", # class: 'p'
- # because special names such as Name.Class, Name.Function, etc.
- # are not recognized as such later in the parsing, we choose them
- # to look the same as ordinary variables.
- Name: "#000000", # class: 'n'
- Name.Attribute: "#c4a000", # class: 'na' - to be revised
- Name.Builtin: "#004461", # class: 'nb'
- Name.Builtin.Pseudo: "#3465a4", # class: 'bp'
- Name.Class: "#000000", # class: 'nc' - to be revised
- Name.Constant: "#000000", # class: 'no' - to be revised
- Name.Decorator: "#888", # class: 'nd' - to be revised
- Name.Entity: "#ce5c00", # class: 'ni'
- Name.Exception: "bold #cc0000", # class: 'ne'
- Name.Function: "#000000", # class: 'nf'
- Name.Property: "#000000", # class: 'py'
- Name.Label: "#f57900", # class: 'nl'
- Name.Namespace: "#000000", # class: 'nn' - to be revised
- Name.Other: "#000000", # class: 'nx'
- Name.Tag: "bold #004461", # class: 'nt' - like a keyword
- Name.Variable: "#000000", # class: 'nv' - to be revised
- Name.Variable.Class: "#000000", # class: 'vc' - to be revised
- Name.Variable.Global: "#000000", # class: 'vg' - to be revised
- Name.Variable.Instance: "#000000", # class: 'vi' - to be revised
- Number: "#990000", # class: 'm'
- Literal: "#000000", # class: 'l'
- Literal.Date: "#000000", # class: 'ld'
- String: "#4e9a06", # class: 's'
- String.Backtick: "#4e9a06", # class: 'sb'
- String.Char: "#4e9a06", # class: 'sc'
- String.Doc: "italic #8f5902", # class: 'sd' - like a comment
- String.Double: "#4e9a06", # class: 's2'
- String.Escape: "#4e9a06", # class: 'se'
- String.Heredoc: "#4e9a06", # class: 'sh'
- String.Interpol: "#4e9a06", # class: 'si'
- String.Other: "#4e9a06", # class: 'sx'
- String.Regex: "#4e9a06", # class: 'sr'
- String.Single: "#4e9a06", # class: 's1'
- String.Symbol: "#4e9a06", # class: 'ss'
- Generic: "#000000", # class: 'g'
- Generic.Deleted: "#a40000", # class: 'gd'
- Generic.Emph: "italic #000000", # class: 'ge'
- Generic.Error: "#ef2929", # class: 'gr'
- Generic.Heading: "bold #000080", # class: 'gh'
- Generic.Inserted: "#00A000", # class: 'gi'
- Generic.Output: "#888", # class: 'go'
- Generic.Prompt: "#745334", # class: 'gp'
- Generic.Strong: "bold #000000", # class: 'gs'
- Generic.Subheading: "bold #800080", # class: 'gu'
- Generic.Traceback: "bold #a40000", # class: 'gt'
- }
diff --git a/doc/en/capture.rst b/doc/en/capture.rst
index 7c8c25cc5..44d3a3bd1 100644
--- a/doc/en/capture.rst
+++ b/doc/en/capture.rst
@@ -145,8 +145,7 @@ The return value from ``readouterr`` changed to a ``namedtuple`` with two attrib
If the code under test writes non-textual data, you can capture this using
the ``capsysbinary`` fixture which instead returns ``bytes`` from
-the ``readouterr`` method. The ``capfsysbinary`` fixture is currently only
-available in python 3.
+the ``readouterr`` method.
diff --git a/doc/en/conf.py b/doc/en/conf.py
index e62bc157a..72e2d4f20 100644
--- a/doc/en/conf.py
+++ b/doc/en/conf.py
@@ -43,6 +43,7 @@ todo_include_todos = 1
# Add any Sphinx extension module names here, as strings. They can be extensions
# coming with Sphinx (named 'sphinx.ext.*') or your custom ones.
extensions = [
+ "pallets_sphinx_themes",
"pygments_pytest",
"sphinx.ext.autodoc",
"sphinx.ext.autosummary",
@@ -142,7 +143,7 @@ html_theme = "flask"
# Theme options are theme-specific and customize the look and feel of a theme
# further. For a list of options available for each theme, see the
# documentation.
-html_theme_options = {"index_logo": None}
+# html_theme_options = {"index_logo": None}
# Add any paths that contain custom themes here, relative to this directory.
# html_theme_path = []
diff --git a/doc/en/development_guide.rst b/doc/en/development_guide.rst
index 2f9762f2a..77076d483 100644
--- a/doc/en/development_guide.rst
+++ b/doc/en/development_guide.rst
@@ -2,59 +2,6 @@
Development Guide
=================
-Some general guidelines regarding development in pytest for maintainers and contributors. Nothing here
-is set in stone and can't be changed, feel free to suggest improvements or changes in the workflow.
-
-
-Code Style
-----------
-
-* `PEP-8 `_
-* `flake8 `_ for quality checks
-* `invoke `_ to automate development tasks
-
-
-Branches
---------
-
-We have two long term branches:
-
-* ``master``: contains the code for the next bug-fix release.
-* ``features``: contains the code with new features for the next minor release.
-
-The official repository usually does not contain topic branches, developers and contributors should create topic
-branches in their own forks.
-
-Exceptions can be made for cases where more than one contributor is working on the same
-topic or where it makes sense to use some automatic capability of the main repository, such as automatic docs from
-`readthedocs `_ for a branch dealing with documentation refactoring.
-
-Issues
-------
-
-Any question, feature, bug or proposal is welcome as an issue. Users are encouraged to use them whenever they need.
-
-GitHub issues should use labels to categorize them. Labels should be created sporadically, to fill a niche; we should
-avoid creating labels just for the sake of creating them.
-
-Each label should include a description in the GitHub's interface stating its purpose.
-
-Labels are managed using `labels `_. All the labels in the repository
-are kept in ``.github/labels.toml``, so any changes should be via PRs to that file.
-After a PR is accepted and merged, one of the maintainers must manually synchronize the labels file with the
-GitHub repository.
-
-Temporary labels
-~~~~~~~~~~~~~~~~
-
-To classify issues for a special event it is encouraged to create a temporary label. This helps those involved to find
-the relevant issues to work on. Examples of that are sprints in Python events or global hacking events.
-
-* ``temporary: EP2017 sprint``: candidate issues or PRs tackled during the EuroPython 2017
-
-Issues created at those events should have other relevant labels added as well.
-
-Those labels should be removed after they are no longer relevant.
-
-
-.. include:: ../../RELEASING.rst
+The contributing guidelines are to be found :ref:`here `.
+The release procedure for pytest is documented on
+`GitHub `_.
diff --git a/doc/en/getting-started.rst b/doc/en/getting-started.rst
index 56057434e..61a0baf19 100644
--- a/doc/en/getting-started.rst
+++ b/doc/en/getting-started.rst
@@ -153,6 +153,57 @@ Once you develop multiple tests, you may want to group them into a class. pytest
The first test passed and the second failed. You can easily see the intermediate values in the assertion to help you understand the reason for the failure.
+Grouping tests in classes can be beneficial for the following reasons:
+
+ * Test organization
+ * Sharing fixtures for tests only in that particular class
+ * Applying marks at the class level and having them implicitly apply to all tests
+
+Something to be aware of when grouping tests inside classes is that each test has a unique instance of the class.
+Having each test share the same class instance would be very detrimental to test isolation and would promote poor test practices.
+This is outlined below:
+
+.. code-block:: python
+
+ class TestClassDemoInstance:
+ def test_one(self):
+ assert 0
+
+ def test_two(self):
+ assert 0
+
+
+.. code-block:: pytest
+
+ $ pytest -k TestClassDemoInstance -q
+
+ FF [100%]
+ ================================== FAILURES ===================================
+ _______________________ TestClassDemoInstance.test_one ________________________
+
+ self =
+ request = >
+
+ def test_one(self, request):
+ > assert 0
+ E assert 0
+
+ testing\test_example.py:4: AssertionError
+ _______________________ TestClassDemoInstance.test_two ________________________
+
+ self =
+ request = >
+
+ def test_two(self, request):
+ > assert 0
+ E assert 0
+
+ testing\test_example.py:7: AssertionError
+ =========================== short test summary info ===========================
+ FAILED testing/test_example.py::TestClassDemoInstance::test_one - assert 0
+ FAILED testing/test_example.py::TestClassDemoInstance::test_two - assert 0
+ 2 failed in 0.11s
+
Request a unique temporary directory for functional tests
--------------------------------------------------------------
diff --git a/doc/en/reference.rst b/doc/en/reference.rst
index 0059b4cb2..7348636a2 100644
--- a/doc/en/reference.rst
+++ b/doc/en/reference.rst
@@ -711,6 +711,7 @@ Session related reporting hooks:
.. autofunction:: pytest_fixture_setup
.. autofunction:: pytest_fixture_post_finalizer
.. autofunction:: pytest_warning_captured
+.. autofunction:: pytest_warning_recorded
Central hook for reporting about test execution:
@@ -1447,6 +1448,11 @@ passed multiple times. The expected format is ``name=value``. For example::
slow
serial
+ .. note::
+ The use of ``--strict-markers`` is highly preferred. ``--strict`` was kept for
+ backward compatibility only and may be confusing for others as it only applies to
+ markers and not to other options.
+
.. confval:: minversion
Specifies a minimal pytest version required for running tests.
diff --git a/doc/en/requirements.txt b/doc/en/requirements.txt
index be22b7db8..1e5e7efdc 100644
--- a/doc/en/requirements.txt
+++ b/doc/en/requirements.txt
@@ -1,4 +1,5 @@
+pallets-sphinx-themes
pygments-pytest>=1.1.0
+sphinx-removed-in>=0.2.0
sphinx>=1.8.2,<2.1
sphinxcontrib-trio
-sphinx-removed-in>=0.2.0
diff --git a/scripts/towncrier-draft-to-file.py b/scripts/towncrier-draft-to-file.py
new file mode 100644
index 000000000..81507b40b
--- /dev/null
+++ b/scripts/towncrier-draft-to-file.py
@@ -0,0 +1,15 @@
+import sys
+from subprocess import call
+
+
+def main():
+ """
+ Platform agnostic wrapper script for towncrier.
+ Fixes the issue (#7251) where windows users are unable to natively run tox -e docs to build pytest docs.
+ """
+ with open("doc/en/_changelog_towncrier_draft.rst", "w") as draft_file:
+ return call(("towncrier", "--draft"), stdout=draft_file)
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/setup.py b/setup.py
index 6ebfd67fb..cd2ecbe07 100644
--- a/setup.py
+++ b/setup.py
@@ -12,7 +12,6 @@ INSTALL_REQUIRES = [
'colorama;sys_platform=="win32"',
"pluggy>=0.12,<1.0",
'importlib-metadata>=0.12;python_version<"3.8"',
- "wcwidth",
]
diff --git a/src/_pytest/_code/code.py b/src/_pytest/_code/code.py
index 2075fd0eb..7b17d7612 100644
--- a/src/_pytest/_code/code.py
+++ b/src/_pytest/_code/code.py
@@ -46,7 +46,7 @@ if TYPE_CHECKING:
from typing_extensions import Literal
from weakref import ReferenceType
- _TracebackStyle = Literal["long", "short", "line", "no", "native"]
+ _TracebackStyle = Literal["long", "short", "line", "no", "native", "value"]
class Code:
@@ -268,10 +268,6 @@ class TracebackEntry:
return tbh
def __str__(self) -> str:
- try:
- fn = str(self.path)
- except py.error.Error:
- fn = "???"
name = self.frame.code.name
try:
line = str(self.statement).lstrip()
@@ -279,7 +275,7 @@ class TracebackEntry:
raise
except BaseException:
line = "???"
- return " File %r:%d in %s\n %s\n" % (fn, self.lineno + 1, name, line)
+ return " File %r:%d in %s\n %s\n" % (self.path, self.lineno + 1, name, line)
@property
def name(self) -> str:
@@ -587,7 +583,7 @@ class ExceptionInfo(Generic[_E]):
Show locals per traceback entry.
Ignored if ``style=="native"``.
- :param str style: long|short|no|native traceback style
+ :param str style: long|short|no|native|value traceback style
:param bool abspath:
If paths should be changed to absolute or left unchanged.
@@ -762,16 +758,15 @@ class FormattedExcinfo:
def repr_traceback_entry(
self, entry: TracebackEntry, excinfo: Optional[ExceptionInfo] = None
) -> "ReprEntry":
- source = self._getentrysource(entry)
- if source is None:
- source = Source("???")
- line_index = 0
- else:
- line_index = entry.lineno - entry.getfirstlinesource()
-
lines = [] # type: List[str]
style = entry._repr_style if entry._repr_style is not None else self.style
if style in ("short", "long"):
+ source = self._getentrysource(entry)
+ if source is None:
+ source = Source("???")
+ line_index = 0
+ else:
+ line_index = entry.lineno - entry.getfirstlinesource()
short = style == "short"
reprargs = self.repr_args(entry) if not short else None
s = self.get_source(source, line_index, excinfo, short=short)
@@ -784,9 +779,14 @@ class FormattedExcinfo:
reprfileloc = ReprFileLocation(path, entry.lineno + 1, message)
localsrepr = self.repr_locals(entry.locals)
return ReprEntry(lines, reprargs, localsrepr, reprfileloc, style)
- if excinfo:
- lines.extend(self.get_exconly(excinfo, indent=4))
- return ReprEntry(lines, None, None, None, style)
+ elif style == "value":
+ if excinfo:
+ lines.extend(str(excinfo.value).split("\n"))
+ return ReprEntry(lines, None, None, None, style)
+ else:
+ if excinfo:
+ lines.extend(self.get_exconly(excinfo, indent=4))
+ return ReprEntry(lines, None, None, None, style)
def _makepath(self, path):
if not self.abspath:
@@ -810,6 +810,11 @@ class FormattedExcinfo:
last = traceback[-1]
entries = []
+ if self.style == "value":
+ reprentry = self.repr_traceback_entry(last, excinfo)
+ entries.append(reprentry)
+ return ReprTraceback(entries, None, style=self.style)
+
for index, entry in enumerate(traceback):
einfo = (last == entry) and excinfo or None
reprentry = self.repr_traceback_entry(entry, einfo)
@@ -869,7 +874,9 @@ class FormattedExcinfo:
seen.add(id(e))
if excinfo_:
reprtraceback = self.repr_traceback(excinfo_)
- reprcrash = excinfo_._getreprcrash() # type: Optional[ReprFileLocation]
+ reprcrash = (
+ excinfo_._getreprcrash() if self.style != "value" else None
+ ) # type: Optional[ReprFileLocation]
else:
# fallback to native repr if the exception doesn't have a traceback:
# ExceptionInfo objects require a full traceback to work
@@ -1052,8 +1059,11 @@ class ReprEntry(TerminalRepr):
"Unexpected failure lines between source lines:\n"
+ "\n".join(self.lines)
)
- indents.append(line[:indent_size])
- source_lines.append(line[indent_size:])
+ if self.style == "value":
+ source_lines.append(line)
+ else:
+ indents.append(line[:indent_size])
+ source_lines.append(line[indent_size:])
else:
seeing_failures = True
failure_lines.append(line)
diff --git a/src/_pytest/_io/terminalwriter.py b/src/_pytest/_io/terminalwriter.py
index 4f22f5a7a..a285cf4fc 100644
--- a/src/_pytest/_io/terminalwriter.py
+++ b/src/_pytest/_io/terminalwriter.py
@@ -2,12 +2,12 @@
import os
import shutil
import sys
-import unicodedata
-from functools import lru_cache
from typing import Optional
from typing import Sequence
from typing import TextIO
+from .wcwidth import wcswidth
+
# This code was initially copied from py 1.8.1, file _io/terminalwriter.py.
@@ -22,17 +22,6 @@ def get_terminal_width() -> int:
return width
-@lru_cache(100)
-def char_width(c: str) -> int:
- # Fullwidth and Wide -> 2, all else (including Ambiguous) -> 1.
- return 2 if unicodedata.east_asian_width(c) in ("F", "W") else 1
-
-
-def get_line_width(text: str) -> int:
- text = unicodedata.normalize("NFC", text)
- return sum(char_width(c) for c in text)
-
-
def should_do_markup(file: TextIO) -> bool:
if os.environ.get("PY_COLORS") == "1":
return True
@@ -99,7 +88,7 @@ class TerminalWriter:
@property
def width_of_current_line(self) -> int:
"""Return an estimate of the width so far in the current line."""
- return get_line_width(self._current_line)
+ return wcswidth(self._current_line)
def markup(self, text: str, **markup: bool) -> str:
for name in markup:
diff --git a/src/_pytest/_io/wcwidth.py b/src/_pytest/_io/wcwidth.py
new file mode 100644
index 000000000..e5c7bf4d8
--- /dev/null
+++ b/src/_pytest/_io/wcwidth.py
@@ -0,0 +1,55 @@
+import unicodedata
+from functools import lru_cache
+
+
+@lru_cache(100)
+def wcwidth(c: str) -> int:
+ """Determine how many columns are needed to display a character in a terminal.
+
+ Returns -1 if the character is not printable.
+ Returns 0, 1 or 2 for other characters.
+ """
+ o = ord(c)
+
+ # ASCII fast path.
+ if 0x20 <= o < 0x07F:
+ return 1
+
+ # Some Cf/Zp/Zl characters which should be zero-width.
+ if (
+ o == 0x0000
+ or 0x200B <= o <= 0x200F
+ or 0x2028 <= o <= 0x202E
+ or 0x2060 <= o <= 0x2063
+ ):
+ return 0
+
+ category = unicodedata.category(c)
+
+ # Control characters.
+ if category == "Cc":
+ return -1
+
+ # Combining characters with zero width.
+ if category in ("Me", "Mn"):
+ return 0
+
+ # Full/Wide east asian characters.
+ if unicodedata.east_asian_width(c) in ("F", "W"):
+ return 2
+
+ return 1
+
+
+def wcswidth(s: str) -> int:
+ """Determine how many columns are needed to display a string in a terminal.
+
+ Returns -1 if the string contains non-printable characters.
+ """
+ width = 0
+ for c in unicodedata.normalize("NFC", s):
+ wc = wcwidth(c)
+ if wc < 0:
+ return -1
+ width += wc
+ return width
diff --git a/src/_pytest/capture.py b/src/_pytest/capture.py
index 7eafeb3e4..64f4b8b92 100644
--- a/src/_pytest/capture.py
+++ b/src/_pytest/capture.py
@@ -9,22 +9,19 @@ import os
import sys
from io import UnsupportedOperation
from tempfile import TemporaryFile
-from typing import Generator
from typing import Optional
from typing import TextIO
+from typing import Tuple
import pytest
from _pytest.compat import TYPE_CHECKING
from _pytest.config import Config
-from _pytest.fixtures import FixtureRequest
if TYPE_CHECKING:
from typing_extensions import Literal
_CaptureMethod = Literal["fd", "sys", "no", "tee-sys"]
-patchsysdict = {0: "stdin", 1: "stdout", 2: "stderr"}
-
def pytest_addoption(parser):
group = parser.getgroup("general")
@@ -45,693 +42,6 @@ def pytest_addoption(parser):
)
-@pytest.hookimpl(hookwrapper=True)
-def pytest_load_initial_conftests(early_config: Config):
- ns = early_config.known_args_namespace
- if ns.capture == "fd":
- _py36_windowsconsoleio_workaround(sys.stdout)
- _colorama_workaround()
- _readline_workaround()
- pluginmanager = early_config.pluginmanager
- capman = CaptureManager(ns.capture)
- pluginmanager.register(capman, "capturemanager")
-
- # make sure that capturemanager is properly reset at final shutdown
- early_config.add_cleanup(capman.stop_global_capturing)
-
- # finally trigger conftest loading but while capturing (issue93)
- capman.start_global_capturing()
- outcome = yield
- capman.suspend_global_capture()
- if outcome.excinfo is not None:
- out, err = capman.read_global_capture()
- sys.stdout.write(out)
- sys.stderr.write(err)
-
-
-def _get_multicapture(method: "_CaptureMethod") -> "MultiCapture":
- if method == "fd":
- return MultiCapture(out=True, err=True, Capture=FDCapture)
- elif method == "sys":
- return MultiCapture(out=True, err=True, Capture=SysCapture)
- elif method == "no":
- return MultiCapture(out=False, err=False, in_=False)
- elif method == "tee-sys":
- return MultiCapture(out=True, err=True, in_=False, Capture=TeeSysCapture)
- raise ValueError("unknown capturing method: {!r}".format(method))
-
-
-class CaptureManager:
- """
- Capture plugin, manages that the appropriate capture method is enabled/disabled during collection and each
- test phase (setup, call, teardown). After each of those points, the captured output is obtained and
- attached to the collection/runtest report.
-
- There are two levels of capture:
- * global: which is enabled by default and can be suppressed by the ``-s`` option. This is always enabled/disabled
- during collection and each test phase.
- * fixture: when a test function or one of its fixture depend on the ``capsys`` or ``capfd`` fixtures. In this
- case special handling is needed to ensure the fixtures take precedence over the global capture.
- """
-
- def __init__(self, method: "_CaptureMethod") -> None:
- self._method = method
- self._global_capturing = None
- self._capture_fixture = None # type: Optional[CaptureFixture]
-
- def __repr__(self):
- return "".format(
- self._method, self._global_capturing, self._capture_fixture
- )
-
- def is_capturing(self):
- if self.is_globally_capturing():
- return "global"
- if self._capture_fixture:
- return "fixture %s" % self._capture_fixture.request.fixturename
- return False
-
- # Global capturing control
-
- def is_globally_capturing(self):
- return self._method != "no"
-
- def start_global_capturing(self):
- assert self._global_capturing is None
- self._global_capturing = _get_multicapture(self._method)
- self._global_capturing.start_capturing()
-
- def stop_global_capturing(self):
- if self._global_capturing is not None:
- self._global_capturing.pop_outerr_to_orig()
- self._global_capturing.stop_capturing()
- self._global_capturing = None
-
- def resume_global_capture(self):
- # During teardown of the python process, and on rare occasions, capture
- # attributes can be `None` while trying to resume global capture.
- if self._global_capturing is not None:
- self._global_capturing.resume_capturing()
-
- def suspend_global_capture(self, in_=False):
- cap = getattr(self, "_global_capturing", None)
- if cap is not None:
- cap.suspend_capturing(in_=in_)
-
- def suspend(self, in_=False):
- # Need to undo local capsys-et-al if it exists before disabling global capture.
- self.suspend_fixture()
- self.suspend_global_capture(in_)
-
- def resume(self):
- self.resume_global_capture()
- self.resume_fixture()
-
- def read_global_capture(self):
- return self._global_capturing.readouterr()
-
- # Fixture Control (it's just forwarding, think about removing this later)
-
- @contextlib.contextmanager
- def _capturing_for_request(
- self, request: FixtureRequest
- ) -> Generator["CaptureFixture", None, None]:
- """
- Context manager that creates a ``CaptureFixture`` instance for the
- given ``request``, ensuring there is only a single one being requested
- at the same time.
-
- This is used as a helper with ``capsys``, ``capfd`` etc.
- """
- if self._capture_fixture:
- other_name = next(
- k
- for k, v in map_fixname_class.items()
- if v is self._capture_fixture.captureclass
- )
- raise request.raiseerror(
- "cannot use {} and {} at the same time".format(
- request.fixturename, other_name
- )
- )
- capture_class = map_fixname_class[request.fixturename]
- self._capture_fixture = CaptureFixture(capture_class, request)
- self.activate_fixture()
- yield self._capture_fixture
- self._capture_fixture.close()
- self._capture_fixture = None
-
- def activate_fixture(self):
- """If the current item is using ``capsys`` or ``capfd``, activate them so they take precedence over
- the global capture.
- """
- if self._capture_fixture:
- self._capture_fixture._start()
-
- def deactivate_fixture(self):
- """Deactivates the ``capsys`` or ``capfd`` fixture of this item, if any."""
- if self._capture_fixture:
- self._capture_fixture.close()
-
- def suspend_fixture(self):
- if self._capture_fixture:
- self._capture_fixture._suspend()
-
- def resume_fixture(self):
- if self._capture_fixture:
- self._capture_fixture._resume()
-
- # Helper context managers
-
- @contextlib.contextmanager
- def global_and_fixture_disabled(self):
- """Context manager to temporarily disable global and current fixture capturing."""
- self.suspend()
- try:
- yield
- finally:
- self.resume()
-
- @contextlib.contextmanager
- def item_capture(self, when, item):
- self.resume_global_capture()
- self.activate_fixture()
- try:
- yield
- finally:
- self.deactivate_fixture()
- self.suspend_global_capture(in_=False)
-
- out, err = self.read_global_capture()
- item.add_report_section(when, "stdout", out)
- item.add_report_section(when, "stderr", err)
-
- # Hooks
-
- @pytest.hookimpl(hookwrapper=True)
- def pytest_make_collect_report(self, collector):
- if isinstance(collector, pytest.File):
- self.resume_global_capture()
- outcome = yield
- self.suspend_global_capture()
- out, err = self.read_global_capture()
- rep = outcome.get_result()
- if out:
- rep.sections.append(("Captured stdout", out))
- if err:
- rep.sections.append(("Captured stderr", err))
- else:
- yield
-
- @pytest.hookimpl(hookwrapper=True)
- def pytest_runtest_setup(self, item):
- with self.item_capture("setup", item):
- yield
-
- @pytest.hookimpl(hookwrapper=True)
- def pytest_runtest_call(self, item):
- with self.item_capture("call", item):
- yield
-
- @pytest.hookimpl(hookwrapper=True)
- def pytest_runtest_teardown(self, item):
- with self.item_capture("teardown", item):
- yield
-
- @pytest.hookimpl(tryfirst=True)
- def pytest_keyboard_interrupt(self, excinfo):
- self.stop_global_capturing()
-
- @pytest.hookimpl(tryfirst=True)
- def pytest_internalerror(self, excinfo):
- self.stop_global_capturing()
-
-
-@pytest.fixture
-def capsys(request):
- """Enable text capturing of writes to ``sys.stdout`` and ``sys.stderr``.
-
- The captured output is made available via ``capsys.readouterr()`` method
- calls, which return a ``(out, err)`` namedtuple.
- ``out`` and ``err`` will be ``text`` objects.
- """
- capman = request.config.pluginmanager.getplugin("capturemanager")
- with capman._capturing_for_request(request) as fixture:
- yield fixture
-
-
-@pytest.fixture
-def capsysbinary(request):
- """Enable bytes capturing of writes to ``sys.stdout`` and ``sys.stderr``.
-
- The captured output is made available via ``capsysbinary.readouterr()``
- method calls, which return a ``(out, err)`` namedtuple.
- ``out`` and ``err`` will be ``bytes`` objects.
- """
- capman = request.config.pluginmanager.getplugin("capturemanager")
- with capman._capturing_for_request(request) as fixture:
- yield fixture
-
-
-@pytest.fixture
-def capfd(request):
- """Enable text capturing of writes to file descriptors ``1`` and ``2``.
-
- The captured output is made available via ``capfd.readouterr()`` method
- calls, which return a ``(out, err)`` namedtuple.
- ``out`` and ``err`` will be ``text`` objects.
- """
- capman = request.config.pluginmanager.getplugin("capturemanager")
- with capman._capturing_for_request(request) as fixture:
- yield fixture
-
-
-@pytest.fixture
-def capfdbinary(request):
- """Enable bytes capturing of writes to file descriptors ``1`` and ``2``.
-
- The captured output is made available via ``capfd.readouterr()`` method
- calls, which return a ``(out, err)`` namedtuple.
- ``out`` and ``err`` will be ``byte`` objects.
- """
- capman = request.config.pluginmanager.getplugin("capturemanager")
- with capman._capturing_for_request(request) as fixture:
- yield fixture
-
-
-class CaptureIO(io.TextIOWrapper):
- def __init__(self) -> None:
- super().__init__(io.BytesIO(), encoding="UTF-8", newline="", write_through=True)
-
- def getvalue(self) -> str:
- assert isinstance(self.buffer, io.BytesIO)
- return self.buffer.getvalue().decode("UTF-8")
-
-
-class TeeCaptureIO(CaptureIO):
- def __init__(self, other: TextIO) -> None:
- self._other = other
- super().__init__()
-
- def write(self, s: str) -> int:
- super().write(s)
- return self._other.write(s)
-
-
-class CaptureFixture:
- """
- Object returned by :py:func:`capsys`, :py:func:`capsysbinary`, :py:func:`capfd` and :py:func:`capfdbinary`
- fixtures.
- """
-
- def __init__(self, captureclass, request):
- self.captureclass = captureclass
- self.request = request
- self._capture = None
- self._captured_out = self.captureclass.EMPTY_BUFFER
- self._captured_err = self.captureclass.EMPTY_BUFFER
-
- def _start(self):
- if self._capture is None:
- self._capture = MultiCapture(
- out=True, err=True, in_=False, Capture=self.captureclass
- )
- self._capture.start_capturing()
-
- def close(self):
- if self._capture is not None:
- out, err = self._capture.pop_outerr_to_orig()
- self._captured_out += out
- self._captured_err += err
- self._capture.stop_capturing()
- self._capture = None
-
- def readouterr(self):
- """Read and return the captured output so far, resetting the internal buffer.
-
- :return: captured content as a namedtuple with ``out`` and ``err`` string attributes
- """
- captured_out, captured_err = self._captured_out, self._captured_err
- if self._capture is not None:
- out, err = self._capture.readouterr()
- captured_out += out
- captured_err += err
- self._captured_out = self.captureclass.EMPTY_BUFFER
- self._captured_err = self.captureclass.EMPTY_BUFFER
- return CaptureResult(captured_out, captured_err)
-
- def _suspend(self):
- """Suspends this fixture's own capturing temporarily."""
- if self._capture is not None:
- self._capture.suspend_capturing()
-
- def _resume(self):
- """Resumes this fixture's own capturing temporarily."""
- if self._capture is not None:
- self._capture.resume_capturing()
-
- @contextlib.contextmanager
- def disabled(self):
- """Temporarily disables capture while inside the 'with' block."""
- capmanager = self.request.config.pluginmanager.getplugin("capturemanager")
- with capmanager.global_and_fixture_disabled():
- yield
-
-
-class EncodedFile(io.TextIOWrapper):
- __slots__ = ()
-
- @property
- def name(self) -> str:
- # Ensure that file.name is a string. Workaround for a Python bug
- # fixed in >=3.7.4: https://bugs.python.org/issue36015
- return repr(self.buffer)
-
- @property
- def mode(self) -> str:
- # TextIOWrapper doesn't expose a mode, but at least some of our
- # tests check it.
- return self.buffer.mode.replace("b", "")
-
-
-CaptureResult = collections.namedtuple("CaptureResult", ["out", "err"])
-
-
-class MultiCapture:
- out = err = in_ = None
- _state = None
- _in_suspended = False
-
- def __init__(self, out=True, err=True, in_=True, Capture=None):
- if in_:
- self.in_ = Capture(0)
- if out:
- self.out = Capture(1)
- if err:
- self.err = Capture(2)
-
- def __repr__(self):
- return "".format(
- self.out, self.err, self.in_, self._state, self._in_suspended,
- )
-
- def start_capturing(self):
- self._state = "started"
- if self.in_:
- self.in_.start()
- if self.out:
- self.out.start()
- if self.err:
- self.err.start()
-
- def pop_outerr_to_orig(self):
- """ pop current snapshot out/err capture and flush to orig streams. """
- out, err = self.readouterr()
- if out:
- self.out.writeorg(out)
- if err:
- self.err.writeorg(err)
- return out, err
-
- def suspend_capturing(self, in_=False):
- self._state = "suspended"
- if self.out:
- self.out.suspend()
- if self.err:
- self.err.suspend()
- if in_ and self.in_:
- self.in_.suspend()
- self._in_suspended = True
-
- def resume_capturing(self):
- self._state = "resumed"
- if self.out:
- self.out.resume()
- if self.err:
- self.err.resume()
- if self._in_suspended:
- self.in_.resume()
- self._in_suspended = False
-
- def stop_capturing(self):
- """ stop capturing and reset capturing streams """
- if self._state == "stopped":
- raise ValueError("was already stopped")
- self._state = "stopped"
- if self.out:
- self.out.done()
- if self.err:
- self.err.done()
- if self.in_:
- self.in_.done()
-
- def readouterr(self) -> CaptureResult:
- if self.out:
- out = self.out.snap()
- else:
- out = ""
- if self.err:
- err = self.err.snap()
- else:
- err = ""
- return CaptureResult(out, err)
-
-
-class NoCapture:
- EMPTY_BUFFER = None
- __init__ = start = done = suspend = resume = lambda *args: None
-
-
-class FDCaptureBinary:
- """Capture IO to/from a given os-level filedescriptor.
-
- snap() produces `bytes`
- """
-
- EMPTY_BUFFER = b""
- _state = None
-
- def __init__(self, targetfd, tmpfile=None):
- self.targetfd = targetfd
- try:
- self.targetfd_save = os.dup(self.targetfd)
- except OSError:
- self.start = lambda: None
- self.done = lambda: None
- else:
- self.start = self._start
- self.done = self._done
- if targetfd == 0:
- assert not tmpfile, "cannot set tmpfile with stdin"
- tmpfile = open(os.devnull)
- self.syscapture = SysCapture(targetfd)
- else:
- if tmpfile is None:
- tmpfile = EncodedFile(
- TemporaryFile(buffering=0),
- encoding="utf-8",
- errors="replace",
- write_through=True,
- )
- if targetfd in patchsysdict:
- self.syscapture = SysCapture(targetfd, tmpfile)
- else:
- self.syscapture = NoCapture()
- self.tmpfile = tmpfile
- self.tmpfile_fd = tmpfile.fileno()
-
- def __repr__(self):
- return "<{} {} oldfd={} _state={!r} tmpfile={}>".format(
- self.__class__.__name__,
- self.targetfd,
- getattr(self, "targetfd_save", ""),
- self._state,
- hasattr(self, "tmpfile") and repr(self.tmpfile) or "",
- )
-
- def _start(self):
- """ Start capturing on targetfd using memorized tmpfile. """
- try:
- os.fstat(self.targetfd_save)
- except (AttributeError, OSError):
- raise ValueError("saved filedescriptor not valid anymore")
- os.dup2(self.tmpfile_fd, self.targetfd)
- self.syscapture.start()
- self._state = "started"
-
- def snap(self):
- self.tmpfile.seek(0)
- res = self.tmpfile.buffer.read()
- self.tmpfile.seek(0)
- self.tmpfile.truncate()
- return res
-
- def _done(self):
- """ stop capturing, restore streams, return original capture file,
- seeked to position zero. """
- targetfd_save = self.__dict__.pop("targetfd_save")
- os.dup2(targetfd_save, self.targetfd)
- os.close(targetfd_save)
- self.syscapture.done()
- self.tmpfile.close()
- self._state = "done"
-
- def suspend(self):
- self.syscapture.suspend()
- os.dup2(self.targetfd_save, self.targetfd)
- self._state = "suspended"
-
- def resume(self):
- self.syscapture.resume()
- os.dup2(self.tmpfile_fd, self.targetfd)
- self._state = "resumed"
-
- def writeorg(self, data):
- """ write to original file descriptor. """
- os.write(self.targetfd_save, data)
-
-
-class FDCapture(FDCaptureBinary):
- """Capture IO to/from a given os-level filedescriptor.
-
- snap() produces text
- """
-
- # Ignore type because it doesn't match the type in the superclass (bytes).
- EMPTY_BUFFER = "" # type: ignore
-
- def snap(self):
- self.tmpfile.seek(0)
- res = self.tmpfile.read()
- self.tmpfile.seek(0)
- self.tmpfile.truncate()
- return res
-
- def writeorg(self, data):
- """ write to original file descriptor. """
- data = data.encode("utf-8") # XXX use encoding of original stream
- os.write(self.targetfd_save, data)
-
-
-class SysCaptureBinary:
-
- EMPTY_BUFFER = b""
- _state = None
-
- def __init__(self, fd, tmpfile=None):
- name = patchsysdict[fd]
- self._old = getattr(sys, name)
- self.name = name
- if tmpfile is None:
- if name == "stdin":
- tmpfile = DontReadFromInput()
- else:
- tmpfile = CaptureIO()
- self.tmpfile = tmpfile
-
- def __repr__(self):
- return "<{} {} _old={} _state={!r} tmpfile={!r}>".format(
- self.__class__.__name__,
- self.name,
- hasattr(self, "_old") and repr(self._old) or "",
- self._state,
- self.tmpfile,
- )
-
- def start(self):
- setattr(sys, self.name, self.tmpfile)
- self._state = "started"
-
- def snap(self):
- res = self.tmpfile.buffer.getvalue()
- self.tmpfile.seek(0)
- self.tmpfile.truncate()
- return res
-
- def done(self):
- setattr(sys, self.name, self._old)
- del self._old
- self.tmpfile.close()
- self._state = "done"
-
- def suspend(self):
- setattr(sys, self.name, self._old)
- self._state = "suspended"
-
- def resume(self):
- setattr(sys, self.name, self.tmpfile)
- self._state = "resumed"
-
- def writeorg(self, data):
- self._old.flush()
- self._old.buffer.write(data)
- self._old.buffer.flush()
-
-
-class SysCapture(SysCaptureBinary):
- EMPTY_BUFFER = "" # type: ignore[assignment]
-
- def snap(self):
- res = self.tmpfile.getvalue()
- self.tmpfile.seek(0)
- self.tmpfile.truncate()
- return res
-
- def writeorg(self, data):
- self._old.write(data)
- self._old.flush()
-
-
-class TeeSysCapture(SysCapture):
- def __init__(self, fd, tmpfile=None):
- name = patchsysdict[fd]
- self._old = getattr(sys, name)
- self.name = name
- if tmpfile is None:
- if name == "stdin":
- tmpfile = DontReadFromInput()
- else:
- tmpfile = TeeCaptureIO(self._old)
- self.tmpfile = tmpfile
-
-
-map_fixname_class = {
- "capfd": FDCapture,
- "capfdbinary": FDCaptureBinary,
- "capsys": SysCapture,
- "capsysbinary": SysCaptureBinary,
-}
-
-
-class DontReadFromInput:
- encoding = None
-
- def read(self, *args):
- raise OSError(
- "pytest: reading from stdin while output is captured! Consider using `-s`."
- )
-
- readline = read
- readlines = read
- __next__ = read
-
- def __iter__(self):
- return self
-
- def fileno(self):
- raise UnsupportedOperation("redirected stdin is pseudofile, has no fileno()")
-
- def isatty(self):
- return False
-
- def close(self):
- pass
-
- @property
- def buffer(self):
- return self
-
-
def _colorama_workaround():
"""
Ensure colorama is imported so that it attaches to the correct stdio
@@ -829,3 +139,740 @@ def _py36_windowsconsoleio_workaround(stream):
sys.stdin = _reopen_stdio(sys.stdin, "rb")
sys.stdout = _reopen_stdio(sys.stdout, "wb")
sys.stderr = _reopen_stdio(sys.stderr, "wb")
+
+
+@pytest.hookimpl(hookwrapper=True)
+def pytest_load_initial_conftests(early_config: Config):
+ ns = early_config.known_args_namespace
+ if ns.capture == "fd":
+ _py36_windowsconsoleio_workaround(sys.stdout)
+ _colorama_workaround()
+ _readline_workaround()
+ pluginmanager = early_config.pluginmanager
+ capman = CaptureManager(ns.capture)
+ pluginmanager.register(capman, "capturemanager")
+
+ # make sure that capturemanager is properly reset at final shutdown
+ early_config.add_cleanup(capman.stop_global_capturing)
+
+ # finally trigger conftest loading but while capturing (issue93)
+ capman.start_global_capturing()
+ outcome = yield
+ capman.suspend_global_capture()
+ if outcome.excinfo is not None:
+ out, err = capman.read_global_capture()
+ sys.stdout.write(out)
+ sys.stderr.write(err)
+
+
+# IO Helpers.
+
+
+class EncodedFile(io.TextIOWrapper):
+ __slots__ = ()
+
+ @property
+ def name(self) -> str:
+ # Ensure that file.name is a string. Workaround for a Python bug
+ # fixed in >=3.7.4: https://bugs.python.org/issue36015
+ return repr(self.buffer)
+
+ @property
+ def mode(self) -> str:
+ # TextIOWrapper doesn't expose a mode, but at least some of our
+ # tests check it.
+ return self.buffer.mode.replace("b", "")
+
+
+class CaptureIO(io.TextIOWrapper):
+ def __init__(self) -> None:
+ super().__init__(io.BytesIO(), encoding="UTF-8", newline="", write_through=True)
+
+ def getvalue(self) -> str:
+ assert isinstance(self.buffer, io.BytesIO)
+ return self.buffer.getvalue().decode("UTF-8")
+
+
+class TeeCaptureIO(CaptureIO):
+ def __init__(self, other: TextIO) -> None:
+ self._other = other
+ super().__init__()
+
+ def write(self, s) -> int:
+ super().write(s)
+ return self._other.write(s)
+
+
+class DontReadFromInput:
+ encoding = None
+
+ def read(self, *args):
+ raise OSError(
+ "pytest: reading from stdin while output is captured! Consider using `-s`."
+ )
+
+ readline = read
+ readlines = read
+ __next__ = read
+
+ def __iter__(self):
+ return self
+
+ def fileno(self):
+ raise UnsupportedOperation("redirected stdin is pseudofile, has no fileno()")
+
+ def isatty(self):
+ return False
+
+ def close(self):
+ pass
+
+ @property
+ def buffer(self):
+ return self
+
+
+# Capture classes.
+
+
+patchsysdict = {0: "stdin", 1: "stdout", 2: "stderr"}
+
+
+class NoCapture:
+ EMPTY_BUFFER = None
+ __init__ = start = done = suspend = resume = lambda *args: None
+
+
+class SysCaptureBinary:
+
+ EMPTY_BUFFER = b""
+
+ def __init__(self, fd, tmpfile=None, *, tee=False):
+ name = patchsysdict[fd]
+ self._old = getattr(sys, name)
+ self.name = name
+ if tmpfile is None:
+ if name == "stdin":
+ tmpfile = DontReadFromInput()
+ else:
+ tmpfile = CaptureIO() if not tee else TeeCaptureIO(self._old)
+ self.tmpfile = tmpfile
+ self._state = "initialized"
+
+ def repr(self, class_name: str) -> str:
+ return "<{} {} _old={} _state={!r} tmpfile={!r}>".format(
+ class_name,
+ self.name,
+ hasattr(self, "_old") and repr(self._old) or "",
+ self._state,
+ self.tmpfile,
+ )
+
+ def __repr__(self) -> str:
+ return "<{} {} _old={} _state={!r} tmpfile={!r}>".format(
+ self.__class__.__name__,
+ self.name,
+ hasattr(self, "_old") and repr(self._old) or "",
+ self._state,
+ self.tmpfile,
+ )
+
+ def _assert_state(self, op: str, states: Tuple[str, ...]) -> None:
+ assert (
+ self._state in states
+ ), "cannot {} in state {!r}: expected one of {}".format(
+ op, self._state, ", ".join(states)
+ )
+
+ def start(self):
+ self._assert_state("start", ("initialized",))
+ setattr(sys, self.name, self.tmpfile)
+ self._state = "started"
+
+ def snap(self):
+ self._assert_state("snap", ("started", "suspended"))
+ self.tmpfile.seek(0)
+ res = self.tmpfile.buffer.read()
+ self.tmpfile.seek(0)
+ self.tmpfile.truncate()
+ return res
+
+ def done(self):
+ self._assert_state("done", ("initialized", "started", "suspended", "done"))
+ if self._state == "done":
+ return
+ setattr(sys, self.name, self._old)
+ del self._old
+ self.tmpfile.close()
+ self._state = "done"
+
+ def suspend(self):
+ self._assert_state("suspend", ("started", "suspended"))
+ setattr(sys, self.name, self._old)
+ self._state = "suspended"
+
+ def resume(self):
+ self._assert_state("resume", ("started", "suspended"))
+ if self._state == "started":
+ return
+ setattr(sys, self.name, self.tmpfile)
+ self._state = "started"
+
+ def writeorg(self, data):
+ self._assert_state("writeorg", ("started", "suspended"))
+ self._old.flush()
+ self._old.buffer.write(data)
+ self._old.buffer.flush()
+
+
+class SysCapture(SysCaptureBinary):
+ EMPTY_BUFFER = "" # type: ignore[assignment]
+
+ def snap(self):
+ res = self.tmpfile.getvalue()
+ self.tmpfile.seek(0)
+ self.tmpfile.truncate()
+ return res
+
+ def writeorg(self, data):
+ self._assert_state("writeorg", ("started", "suspended"))
+ self._old.write(data)
+ self._old.flush()
+
+
+class FDCaptureBinary:
+ """Capture IO to/from a given os-level filedescriptor.
+
+ snap() produces `bytes`
+ """
+
+ EMPTY_BUFFER = b""
+
+ def __init__(self, targetfd):
+ self.targetfd = targetfd
+
+ try:
+ os.fstat(targetfd)
+ except OSError:
+ # FD capturing is conceptually simple -- create a temporary file,
+ # redirect the FD to it, redirect back when done. But when the
+ # target FD is invalid it throws a wrench into this loveley scheme.
+ #
+ # Tests themselves shouldn't care if the FD is valid, FD capturing
+ # should work regardless of external circumstances. So falling back
+ # to just sys capturing is not a good option.
+ #
+ # Further complications are the need to support suspend() and the
+ # possibility of FD reuse (e.g. the tmpfile getting the very same
+ # target FD). The following approach is robust, I believe.
+ self.targetfd_invalid = os.open(os.devnull, os.O_RDWR)
+ os.dup2(self.targetfd_invalid, targetfd)
+ else:
+ self.targetfd_invalid = None
+ self.targetfd_save = os.dup(targetfd)
+
+ if targetfd == 0:
+ self.tmpfile = open(os.devnull)
+ self.syscapture = SysCapture(targetfd)
+ else:
+ self.tmpfile = EncodedFile(
+ TemporaryFile(buffering=0),
+ encoding="utf-8",
+ errors="replace",
+ write_through=True,
+ )
+ if targetfd in patchsysdict:
+ self.syscapture = SysCapture(targetfd, self.tmpfile)
+ else:
+ self.syscapture = NoCapture()
+
+ self._state = "initialized"
+
+ def __repr__(self):
+ return "<{} {} oldfd={} _state={!r} tmpfile={!r}>".format(
+ self.__class__.__name__,
+ self.targetfd,
+ self.targetfd_save,
+ self._state,
+ self.tmpfile,
+ )
+
+ def _assert_state(self, op: str, states: Tuple[str, ...]) -> None:
+ assert (
+ self._state in states
+ ), "cannot {} in state {!r}: expected one of {}".format(
+ op, self._state, ", ".join(states)
+ )
+
+ def start(self):
+ """ Start capturing on targetfd using memorized tmpfile. """
+ self._assert_state("start", ("initialized",))
+ os.dup2(self.tmpfile.fileno(), self.targetfd)
+ self.syscapture.start()
+ self._state = "started"
+
+ def snap(self):
+ self._assert_state("snap", ("started", "suspended"))
+ self.tmpfile.seek(0)
+ res = self.tmpfile.buffer.read()
+ self.tmpfile.seek(0)
+ self.tmpfile.truncate()
+ return res
+
+ def done(self):
+ """ stop capturing, restore streams, return original capture file,
+ seeked to position zero. """
+ self._assert_state("done", ("initialized", "started", "suspended", "done"))
+ if self._state == "done":
+ return
+ os.dup2(self.targetfd_save, self.targetfd)
+ os.close(self.targetfd_save)
+ if self.targetfd_invalid is not None:
+ if self.targetfd_invalid != self.targetfd:
+ os.close(self.targetfd)
+ os.close(self.targetfd_invalid)
+ self.syscapture.done()
+ self.tmpfile.close()
+ self._state = "done"
+
+ def suspend(self):
+ self._assert_state("suspend", ("started", "suspended"))
+ if self._state == "suspended":
+ return
+ self.syscapture.suspend()
+ os.dup2(self.targetfd_save, self.targetfd)
+ self._state = "suspended"
+
+ def resume(self):
+ self._assert_state("resume", ("started", "suspended"))
+ if self._state == "started":
+ return
+ self.syscapture.resume()
+ os.dup2(self.tmpfile.fileno(), self.targetfd)
+ self._state = "started"
+
+ def writeorg(self, data):
+ """ write to original file descriptor. """
+ self._assert_state("writeorg", ("started", "suspended"))
+ os.write(self.targetfd_save, data)
+
+
+class FDCapture(FDCaptureBinary):
+ """Capture IO to/from a given os-level filedescriptor.
+
+ snap() produces text
+ """
+
+ # Ignore type because it doesn't match the type in the superclass (bytes).
+ EMPTY_BUFFER = "" # type: ignore
+
+ def snap(self):
+ self._assert_state("snap", ("started", "suspended"))
+ self.tmpfile.seek(0)
+ res = self.tmpfile.read()
+ self.tmpfile.seek(0)
+ self.tmpfile.truncate()
+ return res
+
+ def writeorg(self, data):
+ """ write to original file descriptor. """
+ super().writeorg(data.encode("utf-8")) # XXX use encoding of original stream
+
+
+# MultiCapture
+
+CaptureResult = collections.namedtuple("CaptureResult", ["out", "err"])
+
+
+class MultiCapture:
+ _state = None
+ _in_suspended = False
+
+ def __init__(self, in_, out, err) -> None:
+ self.in_ = in_
+ self.out = out
+ self.err = err
+
+ def __repr__(self):
+ return "".format(
+ self.out, self.err, self.in_, self._state, self._in_suspended,
+ )
+
+ def start_capturing(self):
+ self._state = "started"
+ if self.in_:
+ self.in_.start()
+ if self.out:
+ self.out.start()
+ if self.err:
+ self.err.start()
+
+ def pop_outerr_to_orig(self):
+ """ pop current snapshot out/err capture and flush to orig streams. """
+ out, err = self.readouterr()
+ if out:
+ self.out.writeorg(out)
+ if err:
+ self.err.writeorg(err)
+ return out, err
+
+ def suspend_capturing(self, in_=False):
+ self._state = "suspended"
+ if self.out:
+ self.out.suspend()
+ if self.err:
+ self.err.suspend()
+ if in_ and self.in_:
+ self.in_.suspend()
+ self._in_suspended = True
+
+ def resume_capturing(self):
+ self._state = "resumed"
+ if self.out:
+ self.out.resume()
+ if self.err:
+ self.err.resume()
+ if self._in_suspended:
+ self.in_.resume()
+ self._in_suspended = False
+
+ def stop_capturing(self):
+ """ stop capturing and reset capturing streams """
+ if self._state == "stopped":
+ raise ValueError("was already stopped")
+ self._state = "stopped"
+ if self.out:
+ self.out.done()
+ if self.err:
+ self.err.done()
+ if self.in_:
+ self.in_.done()
+
+ def readouterr(self) -> CaptureResult:
+ if self.out:
+ out = self.out.snap()
+ else:
+ out = ""
+ if self.err:
+ err = self.err.snap()
+ else:
+ err = ""
+ return CaptureResult(out, err)
+
+
+def _get_multicapture(method: "_CaptureMethod") -> MultiCapture:
+ if method == "fd":
+ return MultiCapture(in_=FDCapture(0), out=FDCapture(1), err=FDCapture(2))
+ elif method == "sys":
+ return MultiCapture(in_=SysCapture(0), out=SysCapture(1), err=SysCapture(2))
+ elif method == "no":
+ return MultiCapture(in_=None, out=None, err=None)
+ elif method == "tee-sys":
+ return MultiCapture(
+ in_=None, out=SysCapture(1, tee=True), err=SysCapture(2, tee=True)
+ )
+ raise ValueError("unknown capturing method: {!r}".format(method))
+
+
+# CaptureManager and CaptureFixture
+
+
+class CaptureManager:
+ """
+ Capture plugin, manages that the appropriate capture method is enabled/disabled during collection and each
+ test phase (setup, call, teardown). After each of those points, the captured output is obtained and
+ attached to the collection/runtest report.
+
+ There are two levels of capture:
+ * global: which is enabled by default and can be suppressed by the ``-s`` option. This is always enabled/disabled
+ during collection and each test phase.
+ * fixture: when a test function or one of its fixture depend on the ``capsys`` or ``capfd`` fixtures. In this
+ case special handling is needed to ensure the fixtures take precedence over the global capture.
+ """
+
+ def __init__(self, method: "_CaptureMethod") -> None:
+ self._method = method
+ self._global_capturing = None
+ self._capture_fixture = None # type: Optional[CaptureFixture]
+
+ def __repr__(self):
+ return "".format(
+ self._method, self._global_capturing, self._capture_fixture
+ )
+
+ def is_capturing(self):
+ if self.is_globally_capturing():
+ return "global"
+ if self._capture_fixture:
+ return "fixture %s" % self._capture_fixture.request.fixturename
+ return False
+
+ # Global capturing control
+
+ def is_globally_capturing(self):
+ return self._method != "no"
+
+ def start_global_capturing(self):
+ assert self._global_capturing is None
+ self._global_capturing = _get_multicapture(self._method)
+ self._global_capturing.start_capturing()
+
+ def stop_global_capturing(self):
+ if self._global_capturing is not None:
+ self._global_capturing.pop_outerr_to_orig()
+ self._global_capturing.stop_capturing()
+ self._global_capturing = None
+
+ def resume_global_capture(self):
+ # During teardown of the python process, and on rare occasions, capture
+ # attributes can be `None` while trying to resume global capture.
+ if self._global_capturing is not None:
+ self._global_capturing.resume_capturing()
+
+ def suspend_global_capture(self, in_=False):
+ if self._global_capturing is not None:
+ self._global_capturing.suspend_capturing(in_=in_)
+
+ def suspend(self, in_=False):
+ # Need to undo local capsys-et-al if it exists before disabling global capture.
+ self.suspend_fixture()
+ self.suspend_global_capture(in_)
+
+ def resume(self):
+ self.resume_global_capture()
+ self.resume_fixture()
+
+ def read_global_capture(self):
+ return self._global_capturing.readouterr()
+
+ # Fixture Control
+
+ def set_fixture(self, capture_fixture: "CaptureFixture") -> None:
+ if self._capture_fixture:
+ current_fixture = self._capture_fixture.request.fixturename
+ requested_fixture = capture_fixture.request.fixturename
+ capture_fixture.request.raiseerror(
+ "cannot use {} and {} at the same time".format(
+ requested_fixture, current_fixture
+ )
+ )
+ self._capture_fixture = capture_fixture
+
+ def unset_fixture(self) -> None:
+ self._capture_fixture = None
+
+ def activate_fixture(self):
+ """If the current item is using ``capsys`` or ``capfd``, activate them so they take precedence over
+ the global capture.
+ """
+ if self._capture_fixture:
+ self._capture_fixture._start()
+
+ def deactivate_fixture(self):
+ """Deactivates the ``capsys`` or ``capfd`` fixture of this item, if any."""
+ if self._capture_fixture:
+ self._capture_fixture.close()
+
+ def suspend_fixture(self):
+ if self._capture_fixture:
+ self._capture_fixture._suspend()
+
+ def resume_fixture(self):
+ if self._capture_fixture:
+ self._capture_fixture._resume()
+
+ # Helper context managers
+
+ @contextlib.contextmanager
+ def global_and_fixture_disabled(self):
+ """Context manager to temporarily disable global and current fixture capturing."""
+ self.suspend()
+ try:
+ yield
+ finally:
+ self.resume()
+
+ @contextlib.contextmanager
+ def item_capture(self, when, item):
+ self.resume_global_capture()
+ self.activate_fixture()
+ try:
+ yield
+ finally:
+ self.deactivate_fixture()
+ self.suspend_global_capture(in_=False)
+
+ out, err = self.read_global_capture()
+ item.add_report_section(when, "stdout", out)
+ item.add_report_section(when, "stderr", err)
+
+ # Hooks
+
+ @pytest.hookimpl(hookwrapper=True)
+ def pytest_make_collect_report(self, collector):
+ if isinstance(collector, pytest.File):
+ self.resume_global_capture()
+ outcome = yield
+ self.suspend_global_capture()
+ out, err = self.read_global_capture()
+ rep = outcome.get_result()
+ if out:
+ rep.sections.append(("Captured stdout", out))
+ if err:
+ rep.sections.append(("Captured stderr", err))
+ else:
+ yield
+
+ @pytest.hookimpl(hookwrapper=True)
+ def pytest_runtest_setup(self, item):
+ with self.item_capture("setup", item):
+ yield
+
+ @pytest.hookimpl(hookwrapper=True)
+ def pytest_runtest_call(self, item):
+ with self.item_capture("call", item):
+ yield
+
+ @pytest.hookimpl(hookwrapper=True)
+ def pytest_runtest_teardown(self, item):
+ with self.item_capture("teardown", item):
+ yield
+
+ @pytest.hookimpl(tryfirst=True)
+ def pytest_keyboard_interrupt(self, excinfo):
+ self.stop_global_capturing()
+
+ @pytest.hookimpl(tryfirst=True)
+ def pytest_internalerror(self, excinfo):
+ self.stop_global_capturing()
+
+
+class CaptureFixture:
+ """
+ Object returned by :py:func:`capsys`, :py:func:`capsysbinary`, :py:func:`capfd` and :py:func:`capfdbinary`
+ fixtures.
+ """
+
+ def __init__(self, captureclass, request):
+ self.captureclass = captureclass
+ self.request = request
+ self._capture = None
+ self._captured_out = self.captureclass.EMPTY_BUFFER
+ self._captured_err = self.captureclass.EMPTY_BUFFER
+
+ def _start(self):
+ if self._capture is None:
+ self._capture = MultiCapture(
+ in_=None, out=self.captureclass(1), err=self.captureclass(2),
+ )
+ self._capture.start_capturing()
+
+ def close(self):
+ if self._capture is not None:
+ out, err = self._capture.pop_outerr_to_orig()
+ self._captured_out += out
+ self._captured_err += err
+ self._capture.stop_capturing()
+ self._capture = None
+
+ def readouterr(self):
+ """Read and return the captured output so far, resetting the internal buffer.
+
+ :return: captured content as a namedtuple with ``out`` and ``err`` string attributes
+ """
+ captured_out, captured_err = self._captured_out, self._captured_err
+ if self._capture is not None:
+ out, err = self._capture.readouterr()
+ captured_out += out
+ captured_err += err
+ self._captured_out = self.captureclass.EMPTY_BUFFER
+ self._captured_err = self.captureclass.EMPTY_BUFFER
+ return CaptureResult(captured_out, captured_err)
+
+ def _suspend(self):
+ """Suspends this fixture's own capturing temporarily."""
+ if self._capture is not None:
+ self._capture.suspend_capturing()
+
+ def _resume(self):
+ """Resumes this fixture's own capturing temporarily."""
+ if self._capture is not None:
+ self._capture.resume_capturing()
+
+ @contextlib.contextmanager
+ def disabled(self):
+ """Temporarily disables capture while inside the 'with' block."""
+ capmanager = self.request.config.pluginmanager.getplugin("capturemanager")
+ with capmanager.global_and_fixture_disabled():
+ yield
+
+
+# The fixtures.
+
+
+@pytest.fixture
+def capsys(request):
+ """Enable text capturing of writes to ``sys.stdout`` and ``sys.stderr``.
+
+ The captured output is made available via ``capsys.readouterr()`` method
+ calls, which return a ``(out, err)`` namedtuple.
+ ``out`` and ``err`` will be ``text`` objects.
+ """
+ capman = request.config.pluginmanager.getplugin("capturemanager")
+ capture_fixture = CaptureFixture(SysCapture, request)
+ capman.set_fixture(capture_fixture)
+ capture_fixture._start()
+ yield capture_fixture
+ capture_fixture.close()
+ capman.unset_fixture()
+
+
+@pytest.fixture
+def capsysbinary(request):
+ """Enable bytes capturing of writes to ``sys.stdout`` and ``sys.stderr``.
+
+ The captured output is made available via ``capsysbinary.readouterr()``
+ method calls, which return a ``(out, err)`` namedtuple.
+ ``out`` and ``err`` will be ``bytes`` objects.
+ """
+ capman = request.config.pluginmanager.getplugin("capturemanager")
+ capture_fixture = CaptureFixture(SysCaptureBinary, request)
+ capman.set_fixture(capture_fixture)
+ capture_fixture._start()
+ yield capture_fixture
+ capture_fixture.close()
+ capman.unset_fixture()
+
+
+@pytest.fixture
+def capfd(request):
+ """Enable text capturing of writes to file descriptors ``1`` and ``2``.
+
+ The captured output is made available via ``capfd.readouterr()`` method
+ calls, which return a ``(out, err)`` namedtuple.
+ ``out`` and ``err`` will be ``text`` objects.
+ """
+ capman = request.config.pluginmanager.getplugin("capturemanager")
+ capture_fixture = CaptureFixture(FDCapture, request)
+ capman.set_fixture(capture_fixture)
+ capture_fixture._start()
+ yield capture_fixture
+ capture_fixture.close()
+ capman.unset_fixture()
+
+
+@pytest.fixture
+def capfdbinary(request):
+ """Enable bytes capturing of writes to file descriptors ``1`` and ``2``.
+
+ The captured output is made available via ``capfd.readouterr()`` method
+ calls, which return a ``(out, err)`` namedtuple.
+ ``out`` and ``err`` will be ``byte`` objects.
+ """
+ capman = request.config.pluginmanager.getplugin("capturemanager")
+ capture_fixture = CaptureFixture(FDCaptureBinary, request)
+ capman.set_fixture(capture_fixture)
+ capture_fixture._start()
+ yield capture_fixture
+ capture_fixture.close()
+ capman.unset_fixture()
diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py
index 68c3822d0..5fc23716d 100644
--- a/src/_pytest/config/__init__.py
+++ b/src/_pytest/config/__init__.py
@@ -23,7 +23,6 @@ from typing import Union
import attr
import py
-from packaging.version import Version
from pluggy import HookimplMarker
from pluggy import HookspecMarker
from pluggy import PluginManager
@@ -1031,6 +1030,7 @@ class Config:
self.known_args_namespace = ns = self._parser.parse_known_args(
args, namespace=copy.copy(self.option)
)
+ self._validatekeys()
if self.known_args_namespace.confcutdir is None and self.inifile:
confcutdir = py.path.local(self.inifile).dirname
self.known_args_namespace.confcutdir = confcutdir
@@ -1059,6 +1059,9 @@ class Config:
minver = self.inicfg.get("minversion", None)
if minver:
+ # Imported lazily to improve start-up time.
+ from packaging.version import Version
+
if Version(minver) > Version(pytest.__version__):
raise pytest.UsageError(
"%s:%d: requires pytest-%s, actual pytest-%s'"
@@ -1070,6 +1073,17 @@ class Config:
)
)
+ def _validatekeys(self):
+ for key in self._get_unknown_ini_keys():
+ message = "Unknown config ini key: {}\n".format(key)
+ 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:
# parse given cmdline arguments into this config object.
assert not hasattr(
diff --git a/src/_pytest/debugging.py b/src/_pytest/debugging.py
index 17915db73..26c3095dc 100644
--- a/src/_pytest/debugging.py
+++ b/src/_pytest/debugging.py
@@ -4,6 +4,7 @@ import functools
import sys
from _pytest import outcomes
+from _pytest.config import ConftestImportFailure
from _pytest.config import hookimpl
from _pytest.config.exceptions import UsageError
@@ -338,6 +339,10 @@ def _postmortem_traceback(excinfo):
# A doctest.UnexpectedException is not useful for post_mortem.
# Use the underlying exception instead:
return excinfo.value.exc_info[2]
+ elif isinstance(excinfo.value, ConftestImportFailure):
+ # A config.ConftestImportFailure is not useful for post_mortem.
+ # Use the underlying exception instead:
+ return excinfo.value.excinfo[2]
else:
return excinfo._excinfo[2]
diff --git a/src/_pytest/deprecated.py b/src/_pytest/deprecated.py
index f981a4a4b..1ce4e1e39 100644
--- a/src/_pytest/deprecated.py
+++ b/src/_pytest/deprecated.py
@@ -80,3 +80,8 @@ MINUS_K_COLON = PytestDeprecationWarning(
"The `-k 'expr:'` syntax to -k is deprecated.\n"
"Please open an issue if you use this and want a replacement."
)
+
+WARNING_CAPTURED_HOOK = PytestDeprecationWarning(
+ "The pytest_warning_captured is deprecated and will be removed in a future release.\n"
+ "Please use pytest_warning_recorded instead."
+)
diff --git a/src/_pytest/helpconfig.py b/src/_pytest/helpconfig.py
index 11fd02462..402ffae66 100644
--- a/src/_pytest/helpconfig.py
+++ b/src/_pytest/helpconfig.py
@@ -41,8 +41,11 @@ def pytest_addoption(parser):
group.addoption(
"--version",
"-V",
- action="store_true",
- help="display pytest version and information about plugins.",
+ action="count",
+ default=0,
+ dest="version",
+ help="display pytest version and information about plugins."
+ "When given twice, also display information about plugins.",
)
group._addoption(
"-h",
@@ -116,19 +119,22 @@ def pytest_cmdline_parse():
def showversion(config):
- sys.stderr.write(
- "This is pytest version {}, imported from {}\n".format(
- pytest.__version__, pytest.__file__
+ if config.option.version > 1:
+ sys.stderr.write(
+ "This is pytest version {}, imported from {}\n".format(
+ pytest.__version__, pytest.__file__
+ )
)
- )
- plugininfo = getpluginversioninfo(config)
- if plugininfo:
- for line in plugininfo:
- sys.stderr.write(line + "\n")
+ plugininfo = getpluginversioninfo(config)
+ if plugininfo:
+ for line in plugininfo:
+ sys.stderr.write(line + "\n")
+ else:
+ sys.stderr.write("pytest {}\n".format(pytest.__version__))
def pytest_cmdline_main(config):
- if config.option.version:
+ if config.option.version > 0:
showversion(config)
return 0
elif config.option.help:
diff --git a/src/_pytest/hookspec.py b/src/_pytest/hookspec.py
index b4fab332d..341f0a250 100644
--- a/src/_pytest/hookspec.py
+++ b/src/_pytest/hookspec.py
@@ -8,9 +8,11 @@ from typing import Union
from pluggy import HookspecMarker
from .deprecated import COLLECT_DIRECTORY_HOOK
+from .deprecated import WARNING_CAPTURED_HOOK
from _pytest.compat import TYPE_CHECKING
if TYPE_CHECKING:
+ import warnings
from _pytest.config import Config
from _pytest.main import Session
from _pytest.reports import BaseReport
@@ -620,8 +622,40 @@ def pytest_terminal_summary(terminalreporter, exitstatus, config):
"""
-@hookspec(historic=True)
+@hookspec(historic=True, warn_on_impl=WARNING_CAPTURED_HOOK)
def pytest_warning_captured(warning_message, when, item, location):
+ """(**Deprecated**) Process a warning captured by the internal pytest warnings plugin.
+
+ This hook is considered deprecated and will be removed in a future pytest version.
+ Use :func:`pytest_warning_recorded` instead.
+
+ :param warnings.WarningMessage warning_message:
+ The captured warning. This is the same object produced by :py:func:`warnings.catch_warnings`, and contains
+ the same attributes as the parameters of :py:func:`warnings.showwarning`.
+
+ :param str when:
+ Indicates when the warning was captured. Possible values:
+
+ * ``"config"``: during pytest configuration/initialization stage.
+ * ``"collect"``: during test collection.
+ * ``"runtest"``: during test execution.
+
+ :param pytest.Item|None item:
+ The item being executed if ``when`` is ``"runtest"``, otherwise ``None``.
+
+ :param tuple location:
+ Holds information about the execution context of the captured warning (filename, linenumber, function).
+ ``function`` evaluates to when the execution context is at the module level.
+ """
+
+
+@hookspec(historic=True)
+def pytest_warning_recorded(
+ warning_message: "warnings.WarningMessage",
+ when: str,
+ nodeid: str,
+ location: Tuple[str, int, str],
+):
"""
Process a warning captured by the internal pytest warnings plugin.
@@ -636,11 +670,7 @@ def pytest_warning_captured(warning_message, when, item, location):
* ``"collect"``: during test collection.
* ``"runtest"``: during test execution.
- :param pytest.Item|None item:
- **DEPRECATED**: This parameter is incompatible with ``pytest-xdist``, and will always receive ``None``
- in a future release.
-
- The item being executed if ``when`` is ``"runtest"``, otherwise ``None``.
+ :param str nodeid: full id of the item
:param tuple location:
Holds information about the execution context of the captured warning (filename, linenumber, function).
diff --git a/src/_pytest/junitxml.py b/src/_pytest/junitxml.py
index 4f9831e06..b26112ac1 100644
--- a/src/_pytest/junitxml.py
+++ b/src/_pytest/junitxml.py
@@ -202,10 +202,8 @@ class _NodeReporter:
if hasattr(report, "wasxfail"):
self._add_simple(Junit.skipped, "xfail-marked test passes unexpectedly")
else:
- if hasattr(report.longrepr, "reprcrash"):
+ if getattr(report.longrepr, "reprcrash", None) is not None:
message = report.longrepr.reprcrash.message
- elif isinstance(report.longrepr, str):
- message = report.longrepr
else:
message = str(report.longrepr)
message = bin_xml_escape(message)
diff --git a/src/_pytest/logging.py b/src/_pytest/logging.py
index e2f691a31..f6a206327 100644
--- a/src/_pytest/logging.py
+++ b/src/_pytest/logging.py
@@ -312,6 +312,14 @@ class LogCaptureHandler(logging.StreamHandler):
self.records = []
self.stream = StringIO()
+ def handleError(self, record: logging.LogRecord) -> None:
+ if logging.raiseExceptions:
+ # Fail the test if the log message is bad (emit failed).
+ # The default behavior of logging is to print "Logging error"
+ # to stderr with the call stack and some extra details.
+ # pytest wants to make such mistakes visible during testing.
+ raise
+
class LogCaptureFixture:
"""Provides access and control of log capturing."""
@@ -499,9 +507,7 @@ class LoggingPlugin:
# File logging.
self.log_file_level = get_log_level_for_setting(config, "log_file_level")
log_file = get_option_ini(config, "log_file") or os.devnull
- self.log_file_handler = logging.FileHandler(
- log_file, mode="w", encoding="UTF-8"
- )
+ self.log_file_handler = _FileHandler(log_file, mode="w", encoding="UTF-8")
log_file_format = get_option_ini(config, "log_file_format", "log_format")
log_file_date_format = get_option_ini(
config, "log_file_date_format", "log_date_format"
@@ -687,6 +693,16 @@ class LoggingPlugin:
self.log_file_handler.close()
+class _FileHandler(logging.FileHandler):
+ """
+ Custom FileHandler with pytest tweaks.
+ """
+
+ def handleError(self, record: logging.LogRecord) -> None:
+ # Handled by LogCaptureHandler.
+ pass
+
+
class _LiveLoggingStreamHandler(logging.StreamHandler):
"""
Custom StreamHandler used by the live logging feature: it will write a newline before the first log message
@@ -737,6 +753,10 @@ class _LiveLoggingStreamHandler(logging.StreamHandler):
self._section_name_shown = True
super().emit(record)
+ def handleError(self, record: logging.LogRecord) -> None:
+ # Handled by LogCaptureHandler.
+ pass
+
class _LiveLoggingNullHandler(logging.NullHandler):
"""A handler used when live logging is disabled."""
@@ -746,3 +766,7 @@ class _LiveLoggingNullHandler(logging.NullHandler):
def set_when(self, when):
pass
+
+ def handleError(self, record: logging.LogRecord) -> None:
+ # Handled by LogCaptureHandler.
+ pass
diff --git a/src/_pytest/main.py b/src/_pytest/main.py
index de7e16744..4eb47be2c 100644
--- a/src/_pytest/main.py
+++ b/src/_pytest/main.py
@@ -70,6 +70,11 @@ def pytest_addoption(parser):
default=0,
help="exit after first num failures or errors.",
)
+ group._addoption(
+ "--strict-config",
+ action="store_true",
+ help="invalid ini keys for the `pytest` section of the configuration file raise errors.",
+ )
group._addoption(
"--strict-markers",
"--strict",
diff --git a/src/_pytest/nodes.py b/src/_pytest/nodes.py
index 448e67127..7a8c28cd4 100644
--- a/src/_pytest/nodes.py
+++ b/src/_pytest/nodes.py
@@ -19,6 +19,7 @@ from _pytest._code.code import ReprExceptionInfo
from _pytest.compat import cached_property
from _pytest.compat import TYPE_CHECKING
from _pytest.config import Config
+from _pytest.config import ConftestImportFailure
from _pytest.config import PytestPluginManager
from _pytest.deprecated import NODE_USE_FROM_PARENT
from _pytest.fixtures import FixtureDef
@@ -28,7 +29,7 @@ from _pytest.mark.structures import Mark
from _pytest.mark.structures import MarkDecorator
from _pytest.mark.structures import NodeKeywords
from _pytest.outcomes import fail
-from _pytest.outcomes import Failed
+from _pytest.pathlib import Path
from _pytest.store import Store
if TYPE_CHECKING:
@@ -331,11 +332,13 @@ class Node(metaclass=NodeMeta):
pass
def _repr_failure_py(
- self, excinfo: ExceptionInfo[Union[Failed, FixtureLookupError]], style=None
+ self, excinfo: ExceptionInfo[BaseException], style=None,
) -> Union[str, ReprExceptionInfo, ExceptionChainRepr, FixtureLookupErrorRepr]:
+ if isinstance(excinfo.value, ConftestImportFailure):
+ excinfo = ExceptionInfo(excinfo.value.excinfo)
if isinstance(excinfo.value, fail.Exception):
if not excinfo.value.pytrace:
- return str(excinfo.value)
+ style = "value"
if isinstance(excinfo.value, FixtureLookupError):
return excinfo.value.formatrepr()
if self.config.getoption("fulltrace", False):
@@ -359,9 +362,14 @@ class Node(metaclass=NodeMeta):
else:
truncate_locals = True
+ # excinfo.getrepr() formats paths relative to the CWD if `abspath` is False.
+ # It is possible for a fixture/test to change the CWD while this code runs, which
+ # would then result in the user seeing confusing paths in the failure message.
+ # To fix this, if the CWD changed, always display the full absolute path.
+ # It will be better to just always display paths relative to invocation_dir, but
+ # this requires a lot of plumbing (#6428).
try:
- os.getcwd()
- abspath = False
+ abspath = Path(os.getcwd()) != Path(self.config.invocation_dir)
except OSError:
abspath = True
@@ -456,10 +464,7 @@ def _check_initialpaths_for_relpath(session, fspath):
class FSHookProxy:
- def __init__(
- self, fspath: py.path.local, pm: PytestPluginManager, remove_mods
- ) -> None:
- self.fspath = fspath
+ def __init__(self, pm: PytestPluginManager, remove_mods) -> None:
self.pm = pm
self.remove_mods = remove_mods
@@ -510,7 +515,7 @@ class FSCollector(Collector):
remove_mods = pm._conftest_plugins.difference(my_conftestmodules)
if remove_mods:
# one or more conftests are not in use at this fspath
- proxy = FSHookProxy(fspath, pm, remove_mods)
+ proxy = FSHookProxy(pm, remove_mods)
else:
# all plugins are active for this fspath
proxy = self.config.hook
diff --git a/src/_pytest/outcomes.py b/src/_pytest/outcomes.py
index 7d7e9df7a..751cf9474 100644
--- a/src/_pytest/outcomes.py
+++ b/src/_pytest/outcomes.py
@@ -9,8 +9,6 @@ from typing import cast
from typing import Optional
from typing import TypeVar
-from packaging.version import Version
-
TYPE_CHECKING = False # avoid circular import through compat
if TYPE_CHECKING:
@@ -217,6 +215,9 @@ def importorskip(
return mod
verattr = getattr(mod, "__version__", None)
if minversion is not None:
+ # Imported lazily to improve start-up time.
+ from packaging.version import Version
+
if verattr is None or Version(verattr) < Version(minversion):
raise Skipped(
"module %r has __version__ %r, required is: %r"
diff --git a/src/_pytest/pathlib.py b/src/_pytest/pathlib.py
index 21ec61e2c..90a7460b0 100644
--- a/src/_pytest/pathlib.py
+++ b/src/_pytest/pathlib.py
@@ -100,10 +100,41 @@ def on_rm_rf_error(func, path: str, exc, *, start_path: Path) -> bool:
return True
+def ensure_extended_length_path(path: Path) -> Path:
+ """Get the extended-length version of a path (Windows).
+
+ On Windows, by default, the maximum length of a path (MAX_PATH) is 260
+ characters, and operations on paths longer than that fail. But it is possible
+ to overcome this by converting the path to "extended-length" form before
+ performing the operation:
+ https://docs.microsoft.com/en-us/windows/win32/fileio/naming-a-file#maximum-path-length-limitation
+
+ On Windows, this function returns the extended-length absolute version of path.
+ On other platforms it returns path unchanged.
+ """
+ if sys.platform.startswith("win32"):
+ path = path.resolve()
+ path = Path(get_extended_length_path_str(str(path)))
+ return path
+
+
+def get_extended_length_path_str(path: str) -> str:
+ """Converts to extended length path as a str"""
+ long_path_prefix = "\\\\?\\"
+ unc_long_path_prefix = "\\\\?\\UNC\\"
+ if path.startswith((long_path_prefix, unc_long_path_prefix)):
+ return path
+ # UNC
+ if path.startswith("\\\\"):
+ return unc_long_path_prefix + path[2:]
+ return long_path_prefix + path
+
+
def rm_rf(path: Path) -> None:
"""Remove the path contents recursively, even if some elements
are read-only.
"""
+ path = ensure_extended_length_path(path)
onerror = partial(on_rm_rf_error, start_path=path)
shutil.rmtree(str(path), onerror=onerror)
@@ -220,6 +251,7 @@ def register_cleanup_lock_removal(lock_path: Path, register=atexit.register):
def maybe_delete_a_numbered_dir(path: Path) -> None:
"""removes a numbered directory if its lock can be obtained and it does not seem to be in use"""
+ path = ensure_extended_length_path(path)
lock_path = None
try:
lock_path = create_cleanup_lock(path)
diff --git a/src/_pytest/pytester.py b/src/_pytest/pytester.py
index 07250b245..32ec4d0d9 100644
--- a/src/_pytest/pytester.py
+++ b/src/_pytest/pytester.py
@@ -25,8 +25,7 @@ import py
import pytest
from _pytest import timing
from _pytest._code import Source
-from _pytest.capture import MultiCapture
-from _pytest.capture import SysCapture
+from _pytest.capture import _get_multicapture
from _pytest.compat import TYPE_CHECKING
from _pytest.config import _PluggyPlugin
from _pytest.config import Config
@@ -687,11 +686,41 @@ class Testdir:
return py.iniconfig.IniConfig(p)["pytest"]
def makepyfile(self, *args, **kwargs):
- """Shortcut for .makefile() with a .py extension."""
+ r"""Shortcut for .makefile() with a .py extension.
+ Defaults to the test name with a '.py' extension, e.g test_foobar.py, overwriting
+ existing files.
+
+ Examples:
+
+ .. code-block:: python
+
+ def test_something(testdir):
+ # initial file is created test_something.py
+ testdir.makepyfile("foobar")
+ # to create multiple files, pass kwargs accordingly
+ testdir.makepyfile(custom="foobar")
+ # at this point, both 'test_something.py' & 'custom.py' exist in the test directory
+
+ """
return self._makefile(".py", args, kwargs)
def maketxtfile(self, *args, **kwargs):
- """Shortcut for .makefile() with a .txt extension."""
+ r"""Shortcut for .makefile() with a .txt extension.
+ Defaults to the test name with a '.txt' extension, e.g test_foobar.txt, overwriting
+ existing files.
+
+ Examples:
+
+ .. code-block:: python
+
+ def test_something(testdir):
+ # initial file is created test_something.txt
+ testdir.maketxtfile("foobar")
+ # to create multiple files, pass kwargs accordingly
+ testdir.maketxtfile(custom="foobar")
+ # at this point, both 'test_something.txt' & 'custom.txt' exist in the test directory
+
+ """
return self._makefile(".txt", args, kwargs)
def syspathinsert(self, path=None):
@@ -942,7 +971,7 @@ class Testdir:
if syspathinsert:
self.syspathinsert()
now = timing.time()
- capture = MultiCapture(Capture=SysCapture)
+ capture = _get_multicapture("sys")
capture.start_capturing()
try:
try:
diff --git a/src/_pytest/terminal.py b/src/_pytest/terminal.py
index c09621628..e384e02b2 100644
--- a/src/_pytest/terminal.py
+++ b/src/_pytest/terminal.py
@@ -27,6 +27,7 @@ import pytest
from _pytest import nodes
from _pytest import timing
from _pytest._io import TerminalWriter
+from _pytest._io.wcwidth import wcswidth
from _pytest.compat import order_preserving_dict
from _pytest.config import Config
from _pytest.config import ExitCode
@@ -227,7 +228,7 @@ def pytest_report_teststatus(report: TestReport) -> Tuple[str, str, str]:
@attr.s
class WarningReport:
"""
- Simple structure to hold warnings information captured by ``pytest_warning_captured``.
+ Simple structure to hold warnings information captured by ``pytest_warning_recorded``.
:ivar str message: user friendly message about the warning
:ivar str|None nodeid: node id that generated the warning (see ``get_location``).
@@ -411,14 +412,12 @@ class TerminalReporter:
self.write_line("INTERNALERROR> " + line)
return 1
- def pytest_warning_captured(self, warning_message, item):
- # from _pytest.nodes import get_fslocation_from_item
+ def pytest_warning_recorded(self, warning_message, nodeid):
from _pytest.warnings import warning_record_to_str
fslocation = warning_message.filename, warning_message.lineno
message = warning_record_to_str(warning_message)
- nodeid = item.nodeid if item is not None else ""
warning_report = WarningReport(
fslocation=fslocation, message=message, nodeid=nodeid
)
@@ -443,8 +442,7 @@ class TerminalReporter:
self.write_ensure_prefix(line, "")
self.flush()
elif self.showfspath:
- fsid = nodeid.split("::")[0]
- self.write_fspath_result(fsid, "")
+ self.write_fspath_result(nodeid, "")
self.flush()
def pytest_runtest_logreport(self, report: TestReport) -> None:
@@ -474,10 +472,7 @@ class TerminalReporter:
else:
markup = {}
if self.verbosity <= 0:
- if not running_xdist and self.showfspath:
- self.write_fspath_result(rep.nodeid, letter, **markup)
- else:
- self._tw.write(letter, **markup)
+ self._tw.write(letter, **markup)
else:
self._progress_nodeids_reported.add(rep.nodeid)
line = self._locationline(rep.nodeid, *rep.location)
@@ -1126,8 +1121,6 @@ def _get_pos(config, rep):
def _get_line_with_reprcrash_message(config, rep, termwidth):
"""Get summary line for a report, trying to add reprcrash message."""
- from wcwidth import wcswidth
-
verbose_word = rep._get_verbose_word(config)
pos = _get_pos(config, rep)
diff --git a/src/_pytest/unittest.py b/src/_pytest/unittest.py
index 773f545af..0d9133f60 100644
--- a/src/_pytest/unittest.py
+++ b/src/_pytest/unittest.py
@@ -41,7 +41,7 @@ class UnitTestCase(Class):
if not getattr(cls, "__test__", True):
return
- skipped = getattr(cls, "__unittest_skip__", False)
+ skipped = _is_skipped(cls)
if not skipped:
self._inject_setup_teardown_fixtures(cls)
self._inject_setup_class_fixture()
@@ -89,7 +89,7 @@ def _make_xunit_fixture(obj, setup_name, teardown_name, scope, pass_self):
@pytest.fixture(scope=scope, autouse=True)
def fixture(self, request):
- if getattr(self, "__unittest_skip__", None):
+ if _is_skipped(self):
reason = self.__unittest_skip_why__
pytest.skip(reason)
if setup is not None:
@@ -220,7 +220,7 @@ class TestCaseFunction(Function):
# arguably we could always postpone tearDown(), but this changes the moment where the
# TestCase instance interacts with the results object, so better to only do it
# when absolutely needed
- if self.config.getoption("usepdb"):
+ if self.config.getoption("usepdb") and not _is_skipped(self.obj):
self._explicit_tearDown = self._testcase.tearDown
setattr(self._testcase, "tearDown", lambda *args: None)
@@ -301,3 +301,8 @@ def check_testcase_implements_trial_reporter(done=[]):
classImplements(TestCaseFunction, IReporter)
done.append(1)
+
+
+def _is_skipped(obj) -> bool:
+ """Return True if the given object has been marked with @unittest.skip"""
+ return bool(getattr(obj, "__unittest_skip__", False))
diff --git a/src/_pytest/warnings.py b/src/_pytest/warnings.py
index 527bb03b0..8828a53d6 100644
--- a/src/_pytest/warnings.py
+++ b/src/_pytest/warnings.py
@@ -81,7 +81,7 @@ def catch_warnings_for_item(config, ihook, when, item):
``item`` can be None if we are not in the context of an item execution.
- Each warning captured triggers the ``pytest_warning_captured`` hook.
+ Each warning captured triggers the ``pytest_warning_recorded`` hook.
"""
cmdline_filters = config.getoption("pythonwarnings") or []
inifilters = config.getini("filterwarnings")
@@ -102,6 +102,7 @@ def catch_warnings_for_item(config, ihook, when, item):
for arg in cmdline_filters:
warnings.filterwarnings(*_parse_filter(arg, escape=True))
+ nodeid = "" if item is None else item.nodeid
if item is not None:
for mark in item.iter_markers(name="filterwarnings"):
for arg in mark.args:
@@ -113,6 +114,14 @@ def catch_warnings_for_item(config, ihook, when, item):
ihook.pytest_warning_captured.call_historic(
kwargs=dict(warning_message=warning_message, when=when, item=item)
)
+ ihook.pytest_warning_recorded.call_historic(
+ kwargs=dict(
+ warning_message=warning_message,
+ nodeid=nodeid,
+ when=when,
+ location=None,
+ )
+ )
def warning_record_to_str(warning_message):
@@ -166,7 +175,7 @@ def pytest_sessionfinish(session):
def _issue_warning_captured(warning, hook, stacklevel):
"""
This function should be used instead of calling ``warnings.warn`` directly when we are in the "configure" stage:
- at this point the actual options might not have been set, so we manually trigger the pytest_warning_captured
+ at this point the actual options might not have been set, so we manually trigger the pytest_warning_recorded
hook so we can display these warnings in the terminal. This is a hack until we can sort out #2891.
:param warning: the warning instance.
@@ -185,3 +194,8 @@ def _issue_warning_captured(warning, hook, stacklevel):
warning_message=records[0], when="config", item=None, location=location
)
)
+ hook.pytest_warning_recorded.call_historic(
+ kwargs=dict(
+ warning_message=records[0], when="config", nodeid="", location=location
+ )
+ )
diff --git a/testing/io/test_wcwidth.py b/testing/io/test_wcwidth.py
new file mode 100644
index 000000000..7cc74df5d
--- /dev/null
+++ b/testing/io/test_wcwidth.py
@@ -0,0 +1,38 @@
+import pytest
+from _pytest._io.wcwidth import wcswidth
+from _pytest._io.wcwidth import wcwidth
+
+
+@pytest.mark.parametrize(
+ ("c", "expected"),
+ [
+ ("\0", 0),
+ ("\n", -1),
+ ("a", 1),
+ ("1", 1),
+ ("Χ", 1),
+ ("\u200B", 0),
+ ("\u1ABE", 0),
+ ("\u0591", 0),
+ ("π", 2),
+ ("οΌ", 2),
+ ],
+)
+def test_wcwidth(c: str, expected: int) -> None:
+ assert wcwidth(c) == expected
+
+
+@pytest.mark.parametrize(
+ ("s", "expected"),
+ [
+ ("", 0),
+ ("hello, world!", 13),
+ ("hello, world!\n", -1),
+ ("0123456789", 10),
+ ("Χ©ΧΧΧ, Χ’ΧΧΧ!", 11),
+ ("Χ©Φ°ΧΦ»Χ’ΦΈΧΧΧ", 6),
+ ("πππ", 6),
+ ],
+)
+def test_wcswidth(s: str, expected: int) -> None:
+ assert wcswidth(s) == expected
diff --git a/testing/logging/test_reporting.py b/testing/logging/test_reporting.py
index c1335b180..709df2b57 100644
--- a/testing/logging/test_reporting.py
+++ b/testing/logging/test_reporting.py
@@ -3,6 +3,7 @@ import os
import re
import pytest
+from _pytest.pytester import Testdir
def test_nothing_logged(testdir):
@@ -1101,3 +1102,48 @@ def test_colored_ansi_esc_caplogtext(testdir):
)
result = testdir.runpytest("--log-level=INFO", "--color=yes")
assert result.ret == 0
+
+
+def test_logging_emit_error(testdir: Testdir) -> None:
+ """
+ An exception raised during emit() should fail the test.
+
+ The default behavior of logging is to print "Logging error"
+ to stderr with the call stack and some extra details.
+
+ pytest overrides this behavior to propagate the exception.
+ """
+ testdir.makepyfile(
+ """
+ import logging
+
+ def test_bad_log():
+ logging.warning('oops', 'first', 2)
+ """
+ )
+ result = testdir.runpytest()
+ result.assert_outcomes(failed=1)
+ result.stdout.fnmatch_lines(
+ [
+ "====* FAILURES *====",
+ "*not all arguments converted during string formatting*",
+ ]
+ )
+
+
+def test_logging_emit_error_supressed(testdir: Testdir) -> None:
+ """
+ If logging is configured to silently ignore errors, pytest
+ doesn't propagate errors either.
+ """
+ testdir.makepyfile(
+ """
+ import logging
+
+ def test_bad_log(monkeypatch):
+ monkeypatch.setattr(logging, 'raiseExceptions', False)
+ logging.warning('oops', 'first', 2)
+ """
+ )
+ result = testdir.runpytest()
+ result.assert_outcomes(passed=1)
diff --git a/testing/python/collect.py b/testing/python/collect.py
index 2807cacc9..cbc798ad8 100644
--- a/testing/python/collect.py
+++ b/testing/python/collect.py
@@ -1251,7 +1251,7 @@ def test_syntax_error_with_non_ascii_chars(testdir):
result.stdout.fnmatch_lines(["*ERROR collecting*", "*SyntaxError*", "*1 error in*"])
-def test_collecterror_with_fulltrace(testdir):
+def test_collect_error_with_fulltrace(testdir):
testdir.makepyfile("assert 0")
result = testdir.runpytest("--fulltrace")
result.stdout.fnmatch_lines(
@@ -1259,15 +1259,12 @@ def test_collecterror_with_fulltrace(testdir):
"collected 0 items / 1 error",
"",
"*= ERRORS =*",
- "*_ ERROR collecting test_collecterror_with_fulltrace.py _*",
- "",
- "*/_pytest/python.py:*: ",
- "_ _ _ _ _ _ _ _ *",
+ "*_ ERROR collecting test_collect_error_with_fulltrace.py _*",
"",
"> assert 0",
"E assert 0",
"",
- "test_collecterror_with_fulltrace.py:1: AssertionError",
+ "test_collect_error_with_fulltrace.py:1: AssertionError",
"*! Interrupted: 1 error during collection !*",
]
)
diff --git a/testing/test_capture.py b/testing/test_capture.py
index c064614d2..1301a0e69 100644
--- a/testing/test_capture.py
+++ b/testing/test_capture.py
@@ -19,16 +19,28 @@ from _pytest.config import ExitCode
# pylib 1.4.20.dev2 (rev 13d9af95547e)
-def StdCaptureFD(out=True, err=True, in_=True):
- return capture.MultiCapture(out, err, in_, Capture=capture.FDCapture)
+def StdCaptureFD(out: bool = True, err: bool = True, in_: bool = True) -> MultiCapture:
+ return capture.MultiCapture(
+ in_=capture.FDCapture(0) if in_ else None,
+ out=capture.FDCapture(1) if out else None,
+ err=capture.FDCapture(2) if err else None,
+ )
-def StdCapture(out=True, err=True, in_=True):
- return capture.MultiCapture(out, err, in_, Capture=capture.SysCapture)
+def StdCapture(out: bool = True, err: bool = True, in_: bool = True) -> MultiCapture:
+ return capture.MultiCapture(
+ in_=capture.SysCapture(0) if in_ else None,
+ out=capture.SysCapture(1) if out else None,
+ err=capture.SysCapture(2) if err else None,
+ )
-def TeeStdCapture(out=True, err=True, in_=True):
- return capture.MultiCapture(out, err, in_, Capture=capture.TeeSysCapture)
+def TeeStdCapture(out: bool = True, err: bool = True, in_: bool = True) -> MultiCapture:
+ return capture.MultiCapture(
+ in_=capture.SysCapture(0, tee=True) if in_ else None,
+ out=capture.SysCapture(1, tee=True) if out else None,
+ err=capture.SysCapture(2, tee=True) if err else None,
+ )
class TestCaptureManager:
@@ -866,9 +878,8 @@ class TestFDCapture:
cap = capture.FDCapture(fd)
data = b"hello"
os.write(fd, data)
- s = cap.snap()
+ pytest.raises(AssertionError, cap.snap)
cap.done()
- assert not s
cap = capture.FDCapture(fd)
cap.start()
os.write(fd, data)
@@ -889,7 +900,7 @@ class TestFDCapture:
fd = tmpfile.fileno()
cap = capture.FDCapture(fd)
cap.done()
- pytest.raises(ValueError, cap.start)
+ pytest.raises(AssertionError, cap.start)
def test_stderr(self):
cap = capture.FDCapture(2)
@@ -940,11 +951,11 @@ class TestFDCapture:
assert s == "but now yes\n"
cap.suspend()
cap.done()
- pytest.raises(AttributeError, cap.suspend)
+ pytest.raises(AssertionError, cap.suspend)
assert repr(cap) == (
- " _state='done' tmpfile={!r}>".format(
- cap.tmpfile
+ "".format(
+ cap.targetfd_save, cap.tmpfile
)
)
# Should not crash with missing "_old".
@@ -1142,6 +1153,7 @@ class TestStdCaptureFD(TestStdCapture):
with lsof_check():
for i in range(10):
cap = StdCaptureFD()
+ cap.start_capturing()
cap.stop_capturing()
@@ -1150,27 +1162,38 @@ class TestStdCaptureFDinvalidFD:
testdir.makepyfile(
"""
import os
+ from fnmatch import fnmatch
from _pytest import capture
def StdCaptureFD(out=True, err=True, in_=True):
- return capture.MultiCapture(out, err, in_, Capture=capture.FDCapture)
+ return capture.MultiCapture(
+ in_=capture.FDCapture(0) if in_ else None,
+ out=capture.FDCapture(1) if out else None,
+ err=capture.FDCapture(2) if err else None,
+ )
def test_stdout():
os.close(1)
cap = StdCaptureFD(out=True, err=False, in_=False)
- assert repr(cap.out) == " _state=None tmpfile=>"
+ assert fnmatch(repr(cap.out), "")
+ cap.start_capturing()
+ os.write(1, b"stdout")
+ assert cap.readouterr() == ("stdout", "")
cap.stop_capturing()
def test_stderr():
os.close(2)
cap = StdCaptureFD(out=False, err=True, in_=False)
- assert repr(cap.err) == " _state=None tmpfile=>"
+ assert fnmatch(repr(cap.err), "")
+ cap.start_capturing()
+ os.write(2, b"stderr")
+ assert cap.readouterr() == ("", "stderr")
cap.stop_capturing()
def test_stdin():
os.close(0)
cap = StdCaptureFD(out=False, err=False, in_=True)
- assert repr(cap.in_) == " _state=None tmpfile=>"
+ assert fnmatch(repr(cap.in_), "")
cap.stop_capturing()
"""
)
@@ -1178,6 +1201,37 @@ class TestStdCaptureFDinvalidFD:
assert result.ret == 0
assert result.parseoutcomes()["passed"] == 3
+ def test_fdcapture_invalid_fd_with_fd_reuse(self, testdir):
+ with saved_fd(1):
+ os.close(1)
+ cap = capture.FDCaptureBinary(1)
+ cap.start()
+ os.write(1, b"started")
+ cap.suspend()
+ os.write(1, b" suspended")
+ cap.resume()
+ os.write(1, b" resumed")
+ assert cap.snap() == b"started resumed"
+ cap.done()
+ with pytest.raises(OSError):
+ os.write(1, b"done")
+
+ def test_fdcapture_invalid_fd_without_fd_reuse(self, testdir):
+ with saved_fd(1), saved_fd(2):
+ os.close(1)
+ os.close(2)
+ cap = capture.FDCaptureBinary(2)
+ cap.start()
+ os.write(2, b"started")
+ cap.suspend()
+ os.write(2, b" suspended")
+ cap.resume()
+ os.write(2, b" resumed")
+ assert cap.snap() == b"started resumed"
+ cap.done()
+ with pytest.raises(OSError):
+ os.write(2, b"done")
+
def test_capture_not_started_but_reset():
capsys = StdCapture()
@@ -1201,11 +1255,8 @@ def test_capsys_results_accessible_by_attribute(capsys):
assert capture_result.err == "eggs"
-@pytest.mark.parametrize("use", [True, False])
-def test_fdcapture_tmpfile_remains_the_same(tmpfile, use):
- if not use:
- tmpfile = True
- cap = StdCaptureFD(out=False, err=tmpfile)
+def test_fdcapture_tmpfile_remains_the_same() -> None:
+ cap = StdCaptureFD(out=False, err=True)
try:
cap.start_capturing()
capfile = cap.err.tmpfile
@@ -1238,16 +1289,21 @@ def test_close_and_capture_again(testdir):
)
-@pytest.mark.parametrize("method", ["SysCapture", "FDCapture", "TeeSysCapture"])
-def test_capturing_and_logging_fundamentals(testdir, method):
+@pytest.mark.parametrize(
+ "method", ["SysCapture(2)", "SysCapture(2, tee=True)", "FDCapture(2)"]
+)
+def test_capturing_and_logging_fundamentals(testdir, method: str) -> None:
# here we check a fundamental feature
p = testdir.makepyfile(
"""
import sys, os
import py, logging
from _pytest import capture
- cap = capture.MultiCapture(out=False, in_=False,
- Capture=capture.%s)
+ cap = capture.MultiCapture(
+ in_=None,
+ out=None,
+ err=capture.%s,
+ )
cap.start_capturing()
logging.warning("hello1")
diff --git a/testing/test_config.py b/testing/test_config.py
index 7d553e63b..6a08e93f3 100644
--- a/testing/test_config.py
+++ b/testing/test_config.py
@@ -147,6 +147,70 @@ class TestParseIni:
result = testdir.inline_run("--confcutdir=.")
assert result.ret == 0
+ @pytest.mark.parametrize(
+ "ini_file_text, invalid_keys, stderr_output, exception_text",
+ [
+ (
+ """
+ [pytest]
+ unknown_ini = value1
+ another_unknown_ini = value2
+ """,
+ ["unknown_ini", "another_unknown_ini"],
+ [
+ "WARNING: Unknown config ini key: unknown_ini",
+ "WARNING: Unknown config ini key: another_unknown_ini",
+ ],
+ "Unknown config ini key: unknown_ini",
+ ),
+ (
+ """
+ [pytest]
+ unknown_ini = value1
+ minversion = 5.0.0
+ """,
+ ["unknown_ini"],
+ ["WARNING: Unknown config ini key: unknown_ini"],
+ "Unknown config ini key: unknown_ini",
+ ),
+ (
+ """
+ [some_other_header]
+ unknown_ini = value1
+ [pytest]
+ minversion = 5.0.0
+ """,
+ [],
+ [],
+ "",
+ ),
+ (
+ """
+ [pytest]
+ minversion = 5.0.0
+ """,
+ [],
+ [],
+ "",
+ ),
+ ],
+ )
+ def test_invalid_ini_keys(
+ self, testdir, ini_file_text, invalid_keys, stderr_output, exception_text
+ ):
+ testdir.tmpdir.join("pytest.ini").write(textwrap.dedent(ini_file_text))
+ config = testdir.parseconfig()
+ assert config._get_unknown_ini_keys() == invalid_keys, str(
+ config._get_unknown_ini_keys()
+ )
+
+ result = testdir.runpytest()
+ result.stderr.fnmatch_lines(stderr_output)
+
+ if stderr_output:
+ with pytest.raises(pytest.fail.Exception, match=exception_text):
+ testdir.runpytest("--strict-config")
+
class TestConfigCmdlineParsing:
def test_parsing_again_fails(self, testdir):
@@ -1243,9 +1307,7 @@ def test_help_and_version_after_argument_error(testdir):
assert result.ret == ExitCode.USAGE_ERROR
result = testdir.runpytest("--version")
- result.stderr.fnmatch_lines(
- ["*pytest*{}*imported from*".format(pytest.__version__)]
- )
+ result.stderr.fnmatch_lines(["pytest {}".format(pytest.__version__)])
assert result.ret == ExitCode.USAGE_ERROR
diff --git a/testing/test_debugging.py b/testing/test_debugging.py
index 719d6477b..00af4a088 100644
--- a/testing/test_debugging.py
+++ b/testing/test_debugging.py
@@ -342,6 +342,15 @@ class TestPDB:
child.sendeof()
self.flush(child)
+ def test_pdb_prevent_ConftestImportFailure_hiding_exception(self, testdir):
+ testdir.makepyfile("def test_func(): pass")
+ sub_dir = testdir.tmpdir.join("ns").ensure_dir()
+ sub_dir.join("conftest").new(ext=".py").write("import unknown")
+ sub_dir.join("test_file").new(ext=".py").write("def test_func(): pass")
+
+ result = testdir.runpytest_subprocess("--pdb", ".")
+ result.stdout.fnmatch_lines(["-> import unknown"])
+
def test_pdb_interaction_capturing_simple(self, testdir):
p1 = testdir.makepyfile(
"""
diff --git a/testing/test_helpconfig.py b/testing/test_helpconfig.py
index 5e4f85228..24590dd3b 100644
--- a/testing/test_helpconfig.py
+++ b/testing/test_helpconfig.py
@@ -2,11 +2,10 @@ import pytest
from _pytest.config import ExitCode
-def test_version(testdir, pytestconfig):
+def test_version_verbose(testdir, pytestconfig):
testdir.monkeypatch.delenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD")
- result = testdir.runpytest("--version")
+ result = testdir.runpytest("--version", "--version")
assert result.ret == 0
- # p = py.path.local(py.__file__).dirpath()
result.stderr.fnmatch_lines(
["*pytest*{}*imported from*".format(pytest.__version__)]
)
@@ -14,6 +13,14 @@ def test_version(testdir, pytestconfig):
result.stderr.fnmatch_lines(["*setuptools registered plugins:", "*at*"])
+def test_version_less_verbose(testdir, pytestconfig):
+ testdir.monkeypatch.delenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD")
+ result = testdir.runpytest("--version")
+ assert result.ret == 0
+ # p = py.path.local(py.__file__).dirpath()
+ result.stderr.fnmatch_lines(["pytest {}".format(pytest.__version__)])
+
+
def test_help(testdir):
result = testdir.runpytest("--help")
assert result.ret == 0
diff --git a/testing/test_nodes.py b/testing/test_nodes.py
index dbb3e2e8f..5bd31b342 100644
--- a/testing/test_nodes.py
+++ b/testing/test_nodes.py
@@ -58,3 +58,30 @@ def test__check_initialpaths_for_relpath():
outside = py.path.local("/outside")
assert nodes._check_initialpaths_for_relpath(FakeSession, outside) is None
+
+
+def test_failure_with_changed_cwd(testdir):
+ """
+ Test failure lines should use absolute paths if cwd has changed since
+ invocation, so the path is correct (#6428).
+ """
+ p = testdir.makepyfile(
+ """
+ import os
+ import pytest
+
+ @pytest.fixture
+ def private_dir():
+ out_dir = 'ddd'
+ os.mkdir(out_dir)
+ old_dir = os.getcwd()
+ os.chdir(out_dir)
+ yield out_dir
+ os.chdir(old_dir)
+
+ def test_show_wrong_path(private_dir):
+ assert False
+ """
+ )
+ result = testdir.runpytest()
+ result.stdout.fnmatch_lines([str(p) + ":*: AssertionError", "*1 failed in *"])
diff --git a/testing/test_pathlib.py b/testing/test_pathlib.py
index 45daeaed7..03bed26ec 100644
--- a/testing/test_pathlib.py
+++ b/testing/test_pathlib.py
@@ -5,6 +5,7 @@ import py
import pytest
from _pytest.pathlib import fnmatch_ex
+from _pytest.pathlib import get_extended_length_path_str
from _pytest.pathlib import get_lock_path
from _pytest.pathlib import maybe_delete_a_numbered_dir
from _pytest.pathlib import Path
@@ -89,3 +90,26 @@ def test_access_denied_during_cleanup(tmp_path, monkeypatch):
lock_path = get_lock_path(path)
maybe_delete_a_numbered_dir(path)
assert not lock_path.is_file()
+
+
+def test_long_path_during_cleanup(tmp_path):
+ """Ensure that deleting long path works (particularly on Windows (#6775))."""
+ path = (tmp_path / ("a" * 250)).resolve()
+ if sys.platform == "win32":
+ # make sure that the full path is > 260 characters without any
+ # component being over 260 characters
+ assert len(str(path)) > 260
+ extended_path = "\\\\?\\" + str(path)
+ else:
+ extended_path = str(path)
+ os.mkdir(extended_path)
+ assert os.path.isdir(extended_path)
+ maybe_delete_a_numbered_dir(path)
+ assert not os.path.isdir(extended_path)
+
+
+def test_get_extended_length_path_str():
+ assert get_extended_length_path_str(r"c:\foo") == r"\\?\c:\foo"
+ assert get_extended_length_path_str(r"\\share\foo") == r"\\?\UNC\share\foo"
+ assert get_extended_length_path_str(r"\\?\UNC\share\foo") == r"\\?\UNC\share\foo"
+ assert get_extended_length_path_str(r"\\?\c:\foo") == r"\\?\c:\foo"
diff --git a/testing/test_reports.py b/testing/test_reports.py
index 13f593215..81778e27d 100644
--- a/testing/test_reports.py
+++ b/testing/test_reports.py
@@ -396,6 +396,14 @@ class TestReportSerialization:
# for same reasons as previous test, ensure we don't blow up here
loaded_report.longrepr.toterminal(tw_mock)
+ def test_report_prevent_ConftestImportFailure_hiding_exception(self, testdir):
+ sub_dir = testdir.tmpdir.join("ns").ensure_dir()
+ sub_dir.join("conftest").new(ext=".py").write("import unknown")
+
+ result = testdir.runpytest_subprocess(".")
+ result.stdout.fnmatch_lines(["E *Error: No module named 'unknown'"])
+ result.stdout.no_fnmatch_line("ERROR - *ConftestImportFailure*")
+
class TestHooks:
"""Test that the hooks are working correctly for plugins"""
diff --git a/testing/test_runner.py b/testing/test_runner.py
index 00732d03b..7b0b27a4b 100644
--- a/testing/test_runner.py
+++ b/testing/test_runner.py
@@ -1002,6 +1002,17 @@ class TestReportContents:
assert rep.capstdout == ""
assert rep.capstderr == ""
+ def test_longrepr_type(self, testdir) -> None:
+ reports = testdir.runitem(
+ """
+ import pytest
+ def test_func():
+ pytest.fail(pytrace=False)
+ """
+ )
+ rep = reports[1]
+ assert isinstance(rep.longrepr, _pytest._code.code.ExceptionRepr)
+
def test_outcome_exception_bad_msg() -> None:
"""Check that OutcomeExceptions validate their input to prevent confusing errors (#5578)"""
diff --git a/testing/test_skipping.py b/testing/test_skipping.py
index 32634d784..f48e78364 100644
--- a/testing/test_skipping.py
+++ b/testing/test_skipping.py
@@ -194,7 +194,7 @@ class TestXFail:
assert len(reports) == 3
callreport = reports[1]
assert callreport.failed
- assert callreport.longrepr == "[XPASS(strict)] nope"
+ assert str(callreport.longrepr) == "[XPASS(strict)] nope"
assert not hasattr(callreport, "wasxfail")
def test_xfail_run_anyway(self, testdir):
diff --git a/testing/test_terminal.py b/testing/test_terminal.py
index 0f5b4cb68..17fd29238 100644
--- a/testing/test_terminal.py
+++ b/testing/test_terminal.py
@@ -14,7 +14,9 @@ import pluggy
import py
import _pytest.config
+import _pytest.terminal
import pytest
+from _pytest._io.wcwidth import wcswidth
from _pytest.config import ExitCode
from _pytest.pytester import Testdir
from _pytest.reports import BaseReport
@@ -2027,9 +2029,6 @@ def test_skip_reasons_folding():
def test_line_with_reprcrash(monkeypatch):
- import _pytest.terminal
- from wcwidth import wcswidth
-
mocked_verbose_word = "FAILED"
mocked_pos = "some::nodeid"
@@ -2079,19 +2078,19 @@ def test_line_with_reprcrash(monkeypatch):
check("some\nmessage", 80, "FAILED some::nodeid - some")
# Test unicode safety.
- check("πππππ\n2nd line", 25, "FAILED some::nodeid - ...")
- check("πππππ\n2nd line", 26, "FAILED some::nodeid - ...")
- check("πππππ\n2nd line", 27, "FAILED some::nodeid - π...")
- check("πππππ\n2nd line", 28, "FAILED some::nodeid - π...")
- check("πππππ\n2nd line", 29, "FAILED some::nodeid - ππ...")
+ check("πππππ\n2nd line", 25, "FAILED some::nodeid - ...")
+ check("πππππ\n2nd line", 26, "FAILED some::nodeid - ...")
+ check("πππππ\n2nd line", 27, "FAILED some::nodeid - π...")
+ check("πππππ\n2nd line", 28, "FAILED some::nodeid - π...")
+ check("πππππ\n2nd line", 29, "FAILED some::nodeid - ππ...")
# NOTE: constructed, not sure if this is supported.
- mocked_pos = "nodeid::π::withunicode"
- check("πππππ\n2nd line", 29, "FAILED nodeid::π::withunicode")
- check("πππππ\n2nd line", 40, "FAILED nodeid::π::withunicode - ππ...")
- check("πππππ\n2nd line", 41, "FAILED nodeid::π::withunicode - ππ...")
- check("πππππ\n2nd line", 42, "FAILED nodeid::π::withunicode - πππ...")
- check("πππππ\n2nd line", 80, "FAILED nodeid::π::withunicode - πππππ")
+ mocked_pos = "nodeid::π::withunicode"
+ check("πππππ\n2nd line", 29, "FAILED nodeid::π::withunicode")
+ check("πππππ\n2nd line", 40, "FAILED nodeid::π::withunicode - ππ...")
+ check("πππππ\n2nd line", 41, "FAILED nodeid::π::withunicode - ππ...")
+ check("πππππ\n2nd line", 42, "FAILED nodeid::π::withunicode - πππ...")
+ check("πππππ\n2nd line", 80, "FAILED nodeid::π::withunicode - πππππ")
@pytest.mark.parametrize(
diff --git a/testing/test_unittest.py b/testing/test_unittest.py
index 83f1b6b2a..74a36c41b 100644
--- a/testing/test_unittest.py
+++ b/testing/test_unittest.py
@@ -1193,6 +1193,40 @@ def test_pdb_teardown_called(testdir, monkeypatch):
]
+@pytest.mark.parametrize("mark", ["@unittest.skip", "@pytest.mark.skip"])
+def test_pdb_teardown_skipped(testdir, monkeypatch, mark):
+ """
+ With --pdb, setUp and tearDown should not be called for skipped tests.
+ """
+ tracked = []
+ monkeypatch.setattr(pytest, "test_pdb_teardown_skipped", tracked, raising=False)
+
+ testdir.makepyfile(
+ """
+ import unittest
+ import pytest
+
+ class MyTestCase(unittest.TestCase):
+
+ def setUp(self):
+ pytest.test_pdb_teardown_skipped.append("setUp:" + self.id())
+
+ def tearDown(self):
+ pytest.test_pdb_teardown_skipped.append("tearDown:" + self.id())
+
+ {mark}("skipped for reasons")
+ def test_1(self):
+ pass
+
+ """.format(
+ mark=mark
+ )
+ )
+ result = testdir.runpytest_inprocess("--pdb")
+ result.stdout.fnmatch_lines("* 1 skipped in *")
+ assert tracked == []
+
+
def test_async_support(testdir):
pytest.importorskip("unittest.async_case")
diff --git a/testing/test_warnings.py b/testing/test_warnings.py
index 51d1286b4..ea7ab397d 100644
--- a/testing/test_warnings.py
+++ b/testing/test_warnings.py
@@ -268,9 +268,8 @@ def test_warning_captured_hook(testdir):
collected = []
class WarningCollector:
- def pytest_warning_captured(self, warning_message, when, item):
- imge_name = item.name if item is not None else ""
- collected.append((str(warning_message.message), when, imge_name))
+ def pytest_warning_recorded(self, warning_message, when, nodeid, location):
+ collected.append((str(warning_message.message), when, nodeid, location))
result = testdir.runpytest(plugins=[WarningCollector()])
result.stdout.fnmatch_lines(["*1 passed*"])
@@ -278,11 +277,27 @@ def test_warning_captured_hook(testdir):
expected = [
("config warning", "config", ""),
("collect warning", "collect", ""),
- ("setup warning", "runtest", "test_func"),
- ("call warning", "runtest", "test_func"),
- ("teardown warning", "runtest", "test_func"),
+ ("setup warning", "runtest", "test_warning_captured_hook.py::test_func"),
+ ("call warning", "runtest", "test_warning_captured_hook.py::test_func"),
+ ("teardown warning", "runtest", "test_warning_captured_hook.py::test_func"),
]
- assert collected == expected
+ for index in range(len(expected)):
+ collected_result = collected[index]
+ expected_result = expected[index]
+
+ assert collected_result[0] == expected_result[0], str(collected)
+ assert collected_result[1] == expected_result[1], str(collected)
+ assert collected_result[2] == expected_result[2], str(collected)
+
+ # NOTE: collected_result[3] is location, which differs based on the platform you are on
+ # thus, the best we can do here is assert the types of the paremeters match what we expect
+ # and not try and preload it in the expected array
+ if collected_result[3] is not None:
+ assert type(collected_result[3][0]) is str, str(collected)
+ assert type(collected_result[3][1]) is int, str(collected)
+ assert type(collected_result[3][2]) is str, str(collected)
+ else:
+ assert collected_result[3] is None, str(collected)
@pytest.mark.filterwarnings("always")
@@ -649,7 +664,7 @@ class TestStackLevel:
captured = []
@classmethod
- def pytest_warning_captured(cls, warning_message, when, item, location):
+ def pytest_warning_recorded(cls, warning_message, when, nodeid, location):
cls.captured.append((warning_message, location))
testdir.plugins = [CapturedWarnings()]
diff --git a/tox.ini b/tox.ini
index f363f5701..8e1a51ca7 100644
--- a/tox.ini
+++ b/tox.ini
@@ -80,9 +80,8 @@ usedevelop = True
deps =
-r{toxinidir}/doc/en/requirements.txt
towncrier
-whitelist_externals = sh
commands =
- sh -c 'towncrier --draft > doc/en/_changelog_towncrier_draft.rst'
+ python scripts/towncrier-draft-to-file.py
# the '-t changelog_towncrier_draft' tags makes sphinx include the draft
# changelog in the docs; this does not happen on ReadTheDocs because it uses
# the standard sphinx command so the 'changelog_towncrier_draft' is never set there