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