From b13fcb23d79b3f38e497824c438c926a0a015561 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Tue, 19 May 2020 10:41:33 +0300 Subject: [PATCH 01/56] logging: propagate errors during log message emits Currently, a bad logging call, e.g. logger.info('oops', 'first', 2) triggers the default logging handling, which is printing an error to stderr but otherwise continuing. For regular programs this behavior makes sense, a bad log message shouldn't take down the program. But during tests, it is better not to skip over such mistakes, but propagate them to the user. --- changelog/6433.feature.rst | 10 +++++++ src/_pytest/logging.py | 30 ++++++++++++++++++-- testing/logging/test_reporting.py | 46 +++++++++++++++++++++++++++++++ 3 files changed, 83 insertions(+), 3 deletions(-) create mode 100644 changelog/6433.feature.rst 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/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/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) From eaeafd7c3050da48ca71be0c1781ff36f60ca4f7 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Sun, 15 Mar 2020 23:32:59 +0200 Subject: [PATCH 02/56] Perform FD capturing even if the FD is invalid The `FDCapture`/`FDCaptureBinary` classes, used by `capfd`/`capfdbinary` fixtures and the `--capture=fd` option (set by default), redirect FDs 1/2 (stdout/stderr) to a temporary file. To do this, they need to save the old file by duplicating the FD before redirecting it, to be restored once finished. Previously, if this duplicating (`os.dup()`) failed, most likely due to that FD being invalid, the FD redirection would silently not be done. The FD capturing also performs python-level redirection (monkeypatching `sys.stdout`/`sys.stderr`) which would still be done, but direct writes to the FDs would fail. This is not great. If pytest is run with `--capture=fd`, or a test is using `capfd`, it expects writes to the FD to work and be captured, regardless of external circumstances. So, instead of disabling FD capturing, keep the redirection to a temporary file, just don't restore it after closing, because there is nothing to restore to. --- changelog/7091.improvement.rst | 4 ++ src/_pytest/capture.py | 86 +++++++++++++++++++--------------- testing/test_capture.py | 48 +++++++++++++++++-- 3 files changed, 95 insertions(+), 43 deletions(-) create mode 100644 changelog/7091.improvement.rst 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/src/_pytest/capture.py b/src/_pytest/capture.py index 7eafeb3e4..323881151 100644 --- a/src/_pytest/capture.py +++ b/src/_pytest/capture.py @@ -513,49 +513,57 @@ class FDCaptureBinary: def __init__(self, targetfd, tmpfile=None): self.targetfd = targetfd + try: - self.targetfd_save = os.dup(self.targetfd) + os.fstat(targetfd) except OSError: - self.start = lambda: None - self.done = lambda: None + # 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.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) + self.targetfd_invalid = None + self.targetfd_save = os.dup(targetfd) + + 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: - 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() + self.syscapture = NoCapture() + self.tmpfile = tmpfile def __repr__(self): - return "<{} {} oldfd={} _state={!r} tmpfile={}>".format( + return "<{} {} oldfd={} _state={!r} tmpfile={!r}>".format( self.__class__.__name__, self.targetfd, - getattr(self, "targetfd_save", ""), + self.targetfd_save, self._state, - hasattr(self, "tmpfile") and repr(self.tmpfile) or "", + self.tmpfile, ) - def _start(self): + 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) + os.dup2(self.tmpfile.fileno(), self.targetfd) self.syscapture.start() self._state = "started" @@ -566,12 +574,15 @@ class FDCaptureBinary: self.tmpfile.truncate() return res - def _done(self): + 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) + 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" @@ -583,7 +594,7 @@ class FDCaptureBinary: def resume(self): self.syscapture.resume() - os.dup2(self.tmpfile_fd, self.targetfd) + os.dup2(self.tmpfile.fileno(), self.targetfd) self._state = "resumed" def writeorg(self, data): @@ -609,8 +620,7 @@ class FDCapture(FDCaptureBinary): 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) + super().writeorg(data.encode("utf-8")) # XXX use encoding of original stream class SysCaptureBinary: diff --git a/testing/test_capture.py b/testing/test_capture.py index c064614d2..177d72ebc 100644 --- a/testing/test_capture.py +++ b/testing/test_capture.py @@ -943,8 +943,8 @@ class TestFDCapture: pytest.raises(AttributeError, cap.suspend) assert repr(cap) == ( - " _state='done' tmpfile={!r}>".format( - cap.tmpfile + "".format( + cap.targetfd_save, cap.tmpfile ) ) # Should not crash with missing "_old". @@ -1150,6 +1150,7 @@ class TestStdCaptureFDinvalidFD: testdir.makepyfile( """ import os + from fnmatch import fnmatch from _pytest import capture def StdCaptureFD(out=True, err=True, in_=True): @@ -1158,19 +1159,25 @@ class TestStdCaptureFDinvalidFD: 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 +1185,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() From 8d841ab0b89e08777390577584e081780e1ad32d Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Tue, 19 May 2020 20:52:04 +0300 Subject: [PATCH 03/56] nodes: remove unused argument from FSHookProxy --- src/_pytest/nodes.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/_pytest/nodes.py b/src/_pytest/nodes.py index 448e67127..2f5f9bdb8 100644 --- a/src/_pytest/nodes.py +++ b/src/_pytest/nodes.py @@ -456,10 +456,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 +507,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 From 139a029b5e885fd3756c2fe0013035a4e84c4fe9 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Tue, 19 May 2020 21:34:06 +0300 Subject: [PATCH 04/56] terminal: remove a redundant line `write_fspath_result` already does this split. --- src/_pytest/terminal.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/_pytest/terminal.py b/src/_pytest/terminal.py index 3de0612bf..a8122aafd 100644 --- a/src/_pytest/terminal.py +++ b/src/_pytest/terminal.py @@ -443,8 +443,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: From 796fba67880c9ce2011d4183dd574339e26618fa Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Tue, 19 May 2020 21:41:28 +0300 Subject: [PATCH 05/56] terminal: remove redundant write_fspath_result call This is already done in pytest_runtest_logstart, so the fspath is already guaranteed to have been printed (for xdist, it is disabled anyway). write_fspath_result is mildly expensive so it is worth avoiding calling it twice. --- src/_pytest/terminal.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/_pytest/terminal.py b/src/_pytest/terminal.py index a8122aafd..8ecb5a16b 100644 --- a/src/_pytest/terminal.py +++ b/src/_pytest/terminal.py @@ -473,10 +473,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) From f1f9c7792bd66413dad208d461975571789d4f10 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Wed, 13 May 2020 16:59:44 +0300 Subject: [PATCH 06/56] Import `packaging` package lazily --- src/_pytest/config/__init__.py | 4 +++- src/_pytest/outcomes.py | 5 +++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index 68c3822d0..bb5034ab1 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 @@ -1059,6 +1058,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'" 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" From 62d3577435ccd532b9ad41c197e6149eafd713f0 Mon Sep 17 00:00:00 2001 From: Florian Dahlitz Date: Fri, 22 May 2020 16:33:50 +0200 Subject: [PATCH 07/56] Add note about --strict and --strict-markers to references --- AUTHORS | 1 + changelog/7233.doc.rst | 1 + doc/en/reference.rst | 5 +++++ 3 files changed, 7 insertions(+) create mode 100644 changelog/7233.doc.rst diff --git a/AUTHORS b/AUTHORS index 5822c74f2..f7b87fd46 100644 --- a/AUTHORS +++ b/AUTHORS @@ -102,6 +102,7 @@ Fabio Zadrozny Felix Nieuwenhuizen Feng Ma Florian Bruhin +Florian Dahlitz Floris Bruynooghe Gabriel Reis Gene Wood 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/doc/en/reference.rst b/doc/en/reference.rst index 0059b4cb2..6bc7657c5 100644 --- a/doc/en/reference.rst +++ b/doc/en/reference.rst @@ -1447,6 +1447,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. From d0eb86cfa671057c678a90ac10c1a43be11bdf6a Mon Sep 17 00:00:00 2001 From: Florian Dahlitz Date: Fri, 22 May 2020 22:58:35 +0200 Subject: [PATCH 08/56] Prevent hiding underlying exception when ConfTestImportFailure is raised --- changelog/7150.bugfix.rst | 1 + src/_pytest/debugging.py | 5 +++++ 2 files changed, 6 insertions(+) create mode 100644 changelog/7150.bugfix.rst 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/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] From 05c22ff82363c1d11fa6d593dd4f404819abc69e Mon Sep 17 00:00:00 2001 From: Simon K Date: Sat, 23 May 2020 15:27:06 +0100 Subject: [PATCH 09/56] 7154-Improve-testdir-documentation-on-makefiles (#7239) --- src/_pytest/pytester.py | 34 ++++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/src/_pytest/pytester.py b/src/_pytest/pytester.py index 32b03bd4a..1da478736 100644 --- a/src/_pytest/pytester.py +++ b/src/_pytest/pytester.py @@ -687,11 +687,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): From 79701c65ed47d9fd9dbc8949a49136f25506d9e0 Mon Sep 17 00:00:00 2001 From: Claire Cecil Date: Sat, 23 May 2020 10:27:58 -0400 Subject: [PATCH 10/56] Added support for less verbose version information (#7169) --- AUTHORS | 1 + changelog/7128.improvement.rst | 1 + src/_pytest/helpconfig.py | 28 +++++++++++++++++----------- testing/test_config.py | 4 +--- testing/test_helpconfig.py | 13 ++++++++++--- 5 files changed, 30 insertions(+), 17 deletions(-) create mode 100644 changelog/7128.improvement.rst diff --git a/AUTHORS b/AUTHORS index 5822c74f2..64907c1d8 100644 --- a/AUTHORS +++ b/AUTHORS @@ -63,6 +63,7 @@ Christian Tismer Christoph Buelter Christopher Dignam Christopher Gilling +Claire Cecil Claudio Madotto CrazyMerlyn Cyrus Maden 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/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/testing/test_config.py b/testing/test_config.py index 7d553e63b..17385dc17 100644 --- a/testing/test_config.py +++ b/testing/test_config.py @@ -1243,9 +1243,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_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 From 35e6dd01173de0b58e175d79e58d6295c359a0bd Mon Sep 17 00:00:00 2001 From: Florian Dahlitz Date: Sat, 23 May 2020 18:19:33 +0200 Subject: [PATCH 11/56] Add test for exposure of underlying exception --- testing/test_debugging.py | 9 +++++++++ 1 file changed, 9 insertions(+) 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( """ From c9abdaf3811600bfd552151102cf19b5798410d1 Mon Sep 17 00:00:00 2001 From: Anthony Sottile Date: Sat, 23 May 2020 13:29:36 -0700 Subject: [PATCH 12/56] Use deadsnakes/action@v1.0.0 to install python3.9 nightly --- .github/workflows/main.yml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 45e386e57..6d1910014 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" @@ -132,14 +132,14 @@ jobs: - run: git fetch --prune --unshallow - 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 From bad7a0207fdd776307f29bc0563fcdc81bd57459 Mon Sep 17 00:00:00 2001 From: symonk Date: Sun, 24 May 2020 11:34:54 +0100 Subject: [PATCH 13/56] document new class instance per test --- doc/en/getting-started.rst | 49 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/doc/en/getting-started.rst b/doc/en/getting-started.rst index 56057434e..55238ffa3 100644 --- a/doc/en/getting-started.rst +++ b/doc/en/getting-started.rst @@ -153,6 +153,55 @@ 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. +Some reasons why grouping tests in a class can be useful is: + + * Structural or organizational reasons + * 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 does not have the same 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.17s + Request a unique temporary directory for functional tests -------------------------------------------------------------- From 568e00af15ea5e8783a4ee4eccf3ae7575119f6e Mon Sep 17 00:00:00 2001 From: symonk Date: Sun, 24 May 2020 11:43:29 +0100 Subject: [PATCH 14/56] fixing up formatting inline with a smaller shell and typos --- doc/en/getting-started.rst | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/doc/en/getting-started.rst b/doc/en/getting-started.rst index 55238ffa3..7dac08892 100644 --- a/doc/en/getting-started.rst +++ b/doc/en/getting-started.rst @@ -153,9 +153,9 @@ 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. -Some reasons why grouping tests in a class can be useful is: +Grouping tests in classes can be beneficial for the following reasons: - * Structural or organizational 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 @@ -177,30 +177,32 @@ This is outlined below: $ pytest -k TestClassDemoInstance -q - FF [100%] - ============================================================================================================== FAILURES =============================================================================================================== - ___________________________________________________________________________________________________ TestClassDemoInstance.test_one ____________________________________________________________________________________________________ + FF [100%] + ================================== FAILURES =================================== + _______________________ TestClassDemoInstance.test_one ________________________ - self = , request = > + self = + request = > def test_one(self, request): > assert 0 E assert 0 testing\test_example.py:4: AssertionError - ___________________________________________________________________________________________________ TestClassDemoInstance.test_two ____________________________________________________________________________________________________ + _______________________ TestClassDemoInstance.test_two ________________________ - self = , request = > + self = + request = > def test_two(self, request): > assert 0 E assert 0 testing\test_example.py:7: AssertionError - ======================================================================================================= short test summary info ======================================================================================================= + =========================== 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.17s + 2 failed in 0.11s Request a unique temporary directory for functional tests -------------------------------------------------------------- From 4f93bc01af057e4bc08bce2c6674c0c7dce95002 Mon Sep 17 00:00:00 2001 From: symonk Date: Sun, 24 May 2020 16:31:51 +0100 Subject: [PATCH 15/56] update terminology of class individuality as per PR feedback --- doc/en/getting-started.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/en/getting-started.rst b/doc/en/getting-started.rst index 7dac08892..e8b033fe6 100644 --- a/doc/en/getting-started.rst +++ b/doc/en/getting-started.rst @@ -159,7 +159,7 @@ Grouping tests in classes can be beneficial for the following reasons: * 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 does not have the same instance of the class. +Something to be aware of when grouping tests inside classes is that each 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: From 5061a47de8d1674a1ad6fa62500def7316a2bab0 Mon Sep 17 00:00:00 2001 From: symonk Date: Sun, 24 May 2020 16:33:17 +0100 Subject: [PATCH 16/56] add missing test text to docs --- doc/en/getting-started.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/en/getting-started.rst b/doc/en/getting-started.rst index e8b033fe6..61a0baf19 100644 --- a/doc/en/getting-started.rst +++ b/doc/en/getting-started.rst @@ -159,7 +159,7 @@ Grouping tests in classes can be beneficial for the following reasons: * 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 has a unique instance of the class. +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: From 9ee65501813cfc00c3dbe262c08d58377dd05c31 Mon Sep 17 00:00:00 2001 From: Gleb Nikonorov Date: Sun, 24 May 2020 02:12:40 -0400 Subject: [PATCH 17/56] Add in a new hook pytest_warning_recorded for warning capture communication --- doc/en/reference.rst | 1 + src/_pytest/hookspec.py | 33 ++++++++++++++++++++++++++++----- src/_pytest/terminal.py | 7 ++++--- src/_pytest/warnings.py | 17 ++++++++++------- testing/test_warnings.py | 15 +++++++-------- 5 files changed, 50 insertions(+), 23 deletions(-) diff --git a/doc/en/reference.rst b/doc/en/reference.rst index 6bc7657c5..13d2484bb 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_record Central hook for reporting about test execution: diff --git a/src/_pytest/hookspec.py b/src/_pytest/hookspec.py index b4fab332d..c983f87f9 100644 --- a/src/_pytest/hookspec.py +++ b/src/_pytest/hookspec.py @@ -622,6 +622,33 @@ def pytest_terminal_summary(terminalreporter, exitstatus, config): @hookspec(historic=True) 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_record` 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_record(warning_message, when, nodeid, location): """ Process a warning captured by the internal pytest warnings plugin. @@ -636,11 +663,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/terminal.py b/src/_pytest/terminal.py index 8ecb5a16b..2524fb21f 100644 --- a/src/_pytest/terminal.py +++ b/src/_pytest/terminal.py @@ -227,7 +227,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_record``. :ivar str message: user friendly message about the warning :ivar str|None nodeid: node id that generated the warning (see ``get_location``). @@ -412,13 +412,14 @@ class TerminalReporter: return 1 def pytest_warning_captured(self, warning_message, item): - # from _pytest.nodes import get_fslocation_from_item + pass + + def pytest_warning_record(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 ) diff --git a/src/_pytest/warnings.py b/src/_pytest/warnings.py index 527bb03b0..16d8de8f8 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_record`` 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: @@ -110,8 +111,9 @@ def catch_warnings_for_item(config, ihook, when, item): yield for warning_message in log: - ihook.pytest_warning_captured.call_historic( - kwargs=dict(warning_message=warning_message, when=when, item=item) + # raise ValueError(ihook.pytest_warning_record) + ihook.pytest_warning_record.call_historic( + kwargs=dict(warning_message=warning_message, nodeid=nodeid, when=when) ) @@ -166,8 +168,9 @@ 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 - hook so we can display these warnings in the terminal. This is a hack until we can sort out #2891. + at this point the actual options might not have been set, so we manually trigger the pytest_warning_record hooks + so we can display these warnings in the terminal. + This is a hack until we can sort out #2891. :param warning: the warning instance. :param hook: the hook caller @@ -180,8 +183,8 @@ def _issue_warning_captured(warning, hook, stacklevel): assert records is not None frame = sys._getframe(stacklevel - 1) location = frame.f_code.co_filename, frame.f_lineno, frame.f_code.co_name - hook.pytest_warning_captured.call_historic( + hook.pytest_warning_record.call_historic( kwargs=dict( - warning_message=records[0], when="config", item=None, location=location + warning_message=records[0], when="config", nodeid="", location=location ) ) diff --git a/testing/test_warnings.py b/testing/test_warnings.py index 51d1286b4..ec75e6571 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_record(self, warning_message, when, nodeid): + collected.append((str(warning_message.message), when, nodeid)) result = testdir.runpytest(plugins=[WarningCollector()]) result.stdout.fnmatch_lines(["*1 passed*"]) @@ -278,11 +277,11 @@ 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 + assert collected == expected, str(collected) @pytest.mark.filterwarnings("always") @@ -649,7 +648,7 @@ class TestStackLevel: captured = [] @classmethod - def pytest_warning_captured(cls, warning_message, when, item, location): + def pytest_warning_record(cls, warning_message, when, nodeid, location): cls.captured.append((warning_message, location)) testdir.plugins = [CapturedWarnings()] From b02d087dbdd6f8b0a4233314356b2442da7fc6c5 Mon Sep 17 00:00:00 2001 From: Gleb Nikonorov Date: Sun, 24 May 2020 20:26:14 -0400 Subject: [PATCH 18/56] cleanup code pre pr --- src/_pytest/warnings.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/_pytest/warnings.py b/src/_pytest/warnings.py index 16d8de8f8..4aa2321aa 100644 --- a/src/_pytest/warnings.py +++ b/src/_pytest/warnings.py @@ -111,7 +111,6 @@ def catch_warnings_for_item(config, ihook, when, item): yield for warning_message in log: - # raise ValueError(ihook.pytest_warning_record) ihook.pytest_warning_record.call_historic( kwargs=dict(warning_message=warning_message, nodeid=nodeid, when=when) ) @@ -168,9 +167,8 @@ 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_record hooks - so we can display these warnings in the terminal. - This is a hack until we can sort out #2891. + at this point the actual options might not have been set, so we manually trigger the pytest_warning_record + hook so we can display these warnings in the terminal. This is a hack until we can sort out #2891. :param warning: the warning instance. :param hook: the hook caller From 088d400b2da633c3eaa7e2b9b6edef18f88dd885 Mon Sep 17 00:00:00 2001 From: Gleb Nikonorov Date: Sun, 24 May 2020 20:43:23 -0400 Subject: [PATCH 19/56] rename pytest_warning_record -> pytest_warning_recorded --- doc/en/reference.rst | 2 +- src/_pytest/hookspec.py | 4 ++-- src/_pytest/terminal.py | 4 ++-- src/_pytest/warnings.py | 8 ++++---- testing/test_warnings.py | 4 ++-- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/doc/en/reference.rst b/doc/en/reference.rst index 13d2484bb..7348636a2 100644 --- a/doc/en/reference.rst +++ b/doc/en/reference.rst @@ -711,7 +711,7 @@ Session related reporting hooks: .. autofunction:: pytest_fixture_setup .. autofunction:: pytest_fixture_post_finalizer .. autofunction:: pytest_warning_captured -.. autofunction:: pytest_warning_record +.. autofunction:: pytest_warning_recorded Central hook for reporting about test execution: diff --git a/src/_pytest/hookspec.py b/src/_pytest/hookspec.py index c983f87f9..8ccb89ca5 100644 --- a/src/_pytest/hookspec.py +++ b/src/_pytest/hookspec.py @@ -625,7 +625,7 @@ 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_record` instead. + 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 @@ -648,7 +648,7 @@ def pytest_warning_captured(warning_message, when, item, location): @hookspec(historic=True) -def pytest_warning_record(warning_message, when, nodeid, location): +def pytest_warning_recorded(warning_message, when, nodeid, location): """ Process a warning captured by the internal pytest warnings plugin. diff --git a/src/_pytest/terminal.py b/src/_pytest/terminal.py index 2524fb21f..10fb1f769 100644 --- a/src/_pytest/terminal.py +++ b/src/_pytest/terminal.py @@ -227,7 +227,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_record``. + 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``). @@ -414,7 +414,7 @@ class TerminalReporter: def pytest_warning_captured(self, warning_message, item): pass - def pytest_warning_record(self, warning_message, nodeid): + def pytest_warning_recorded(self, warning_message, nodeid): from _pytest.warnings import warning_record_to_str fslocation = warning_message.filename, warning_message.lineno diff --git a/src/_pytest/warnings.py b/src/_pytest/warnings.py index 4aa2321aa..6d383ccdd 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_record`` hook. + Each warning captured triggers the ``pytest_warning_recorded`` hook. """ cmdline_filters = config.getoption("pythonwarnings") or [] inifilters = config.getini("filterwarnings") @@ -111,7 +111,7 @@ def catch_warnings_for_item(config, ihook, when, item): yield for warning_message in log: - ihook.pytest_warning_record.call_historic( + ihook.pytest_warning_recorded.call_historic( kwargs=dict(warning_message=warning_message, nodeid=nodeid, when=when) ) @@ -167,7 +167,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_record + 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. @@ -181,7 +181,7 @@ def _issue_warning_captured(warning, hook, stacklevel): assert records is not None frame = sys._getframe(stacklevel - 1) location = frame.f_code.co_filename, frame.f_lineno, frame.f_code.co_name - hook.pytest_warning_record.call_historic( + hook.pytest_warning_recorded.call_historic( kwargs=dict( warning_message=records[0], when="config", nodeid="", location=location ) diff --git a/testing/test_warnings.py b/testing/test_warnings.py index ec75e6571..6cfdfa6bb 100644 --- a/testing/test_warnings.py +++ b/testing/test_warnings.py @@ -268,7 +268,7 @@ def test_warning_captured_hook(testdir): collected = [] class WarningCollector: - def pytest_warning_record(self, warning_message, when, nodeid): + def pytest_warning_recorded(self, warning_message, when, nodeid): collected.append((str(warning_message.message), when, nodeid)) result = testdir.runpytest(plugins=[WarningCollector()]) @@ -648,7 +648,7 @@ class TestStackLevel: captured = [] @classmethod - def pytest_warning_record(cls, warning_message, when, nodeid, location): + def pytest_warning_recorded(cls, warning_message, when, nodeid, location): cls.captured.append((warning_message, location)) testdir.plugins = [CapturedWarnings()] From 6546d1f7257d986c2598dd2ebd2bbd8d04285585 Mon Sep 17 00:00:00 2001 From: Florian Dahlitz Date: Mon, 25 May 2020 13:53:56 +0200 Subject: [PATCH 20/56] Prevent pytest from printing ConftestImportFailure traceback --- changelog/6956.bugfix.rst | 1 + src/_pytest/nodes.py | 3 +++ testing/python/collect.py | 9 +++------ testing/test_reports.py | 12 ++++++++++++ 4 files changed, 19 insertions(+), 6 deletions(-) create mode 100644 changelog/6956.bugfix.rst 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/src/_pytest/nodes.py b/src/_pytest/nodes.py index 2f5f9bdb8..4c2a0a3a7 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 @@ -340,6 +341,8 @@ class Node(metaclass=NodeMeta): return excinfo.value.formatrepr() if self.config.getoption("fulltrace", False): style = "long" + if excinfo.type is ConftestImportFailure: # type: ignore + excinfo = ExceptionInfo.from_exc_info(excinfo.value.excinfo) # type: ignore else: tb = _pytest._code.Traceback([excinfo.traceback[-1]]) self._prunetraceback(excinfo) 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_reports.py b/testing/test_reports.py index 13f593215..64d86e953 100644 --- a/testing/test_reports.py +++ b/testing/test_reports.py @@ -396,6 +396,18 @@ 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 ModuleNotFoundError: No module named 'unknown'"] + ) + result.stdout.no_fnmatch_line( + "ERROR - _pytest.config.ConftestImportFailure: ModuleNotFoundError:*" + ) + class TestHooks: """Test that the hooks are working correctly for plugins""" From 125b663f20a599e7e39fce821597a122d62a6460 Mon Sep 17 00:00:00 2001 From: Gleb Nikonorov Date: Mon, 25 May 2020 11:18:24 -0400 Subject: [PATCH 21/56] Address all feedback, minus the empty sring v None nodeid which is being discussed --- src/_pytest/hookspec.py | 7 ++++++- src/_pytest/terminal.py | 3 --- src/_pytest/warnings.py | 8 ++++++++ 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/_pytest/hookspec.py b/src/_pytest/hookspec.py index 8ccb89ca5..e2810ec78 100644 --- a/src/_pytest/hookspec.py +++ b/src/_pytest/hookspec.py @@ -620,7 +620,12 @@ def pytest_terminal_summary(terminalreporter, exitstatus, config): """ -@hookspec(historic=True) +@hookspec( + historic=True, + warn_on_impl=DeprecationWarning( + "pytest_warning_captured is deprecated and will be removed soon" + ), +) def pytest_warning_captured(warning_message, when, item, location): """(**Deprecated**) Process a warning captured by the internal pytest warnings plugin. diff --git a/src/_pytest/terminal.py b/src/_pytest/terminal.py index 10fb1f769..d26db2c51 100644 --- a/src/_pytest/terminal.py +++ b/src/_pytest/terminal.py @@ -411,9 +411,6 @@ class TerminalReporter: self.write_line("INTERNALERROR> " + line) return 1 - def pytest_warning_captured(self, warning_message, item): - pass - def pytest_warning_recorded(self, warning_message, nodeid): from _pytest.warnings import warning_record_to_str diff --git a/src/_pytest/warnings.py b/src/_pytest/warnings.py index 6d383ccdd..3ae3c8639 100644 --- a/src/_pytest/warnings.py +++ b/src/_pytest/warnings.py @@ -111,6 +111,9 @@ def catch_warnings_for_item(config, ihook, when, item): yield for warning_message in log: + 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) ) @@ -181,6 +184,11 @@ def _issue_warning_captured(warning, hook, stacklevel): assert records is not None frame = sys._getframe(stacklevel - 1) location = frame.f_code.co_filename, frame.f_lineno, frame.f_code.co_name + hook.pytest_warning_captured.call_historic( + kwargs=dict( + warning_message=records[0], when="config", item=None, location=location + ) + ) hook.pytest_warning_recorded.call_historic( kwargs=dict( warning_message=records[0], when="config", nodeid="", location=location From 5ebcb34fb595c7652ccda889f69b2d4e3ca6443b Mon Sep 17 00:00:00 2001 From: Florian Dahlitz Date: Mon, 25 May 2020 20:19:28 +0200 Subject: [PATCH 22/56] Move ConftestImportFailure check to correct position and add typing --- src/_pytest/nodes.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/_pytest/nodes.py b/src/_pytest/nodes.py index 4c2a0a3a7..5aae211cd 100644 --- a/src/_pytest/nodes.py +++ b/src/_pytest/nodes.py @@ -332,17 +332,21 @@ class Node(metaclass=NodeMeta): pass def _repr_failure_py( - self, excinfo: ExceptionInfo[Union[Failed, FixtureLookupError]], style=None + self, + excinfo: ExceptionInfo[ + Union[Failed, FixtureLookupError, ConftestImportFailure] + ], + style=None, ) -> Union[str, ReprExceptionInfo, ExceptionChainRepr, FixtureLookupErrorRepr]: if isinstance(excinfo.value, fail.Exception): if not excinfo.value.pytrace: return str(excinfo.value) if isinstance(excinfo.value, FixtureLookupError): return excinfo.value.formatrepr() + if isinstance(excinfo.value, ConftestImportFailure): + excinfo = ExceptionInfo(excinfo.value.excinfo) # type: ignore if self.config.getoption("fulltrace", False): style = "long" - if excinfo.type is ConftestImportFailure: # type: ignore - excinfo = ExceptionInfo.from_exc_info(excinfo.value.excinfo) # type: ignore else: tb = _pytest._code.Traceback([excinfo.traceback[-1]]) self._prunetraceback(excinfo) From 491239d9b27670d55cee21f9141a2bc91cf44f16 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Sun, 12 Apr 2020 20:47:28 +0300 Subject: [PATCH 23/56] capture: remove some indirection in MultiCapture Removing this indirection enables some further clean ups. --- src/_pytest/capture.py | 22 +++++++++------------- src/_pytest/pytester.py | 5 ++--- testing/test_capture.py | 37 ++++++++++++++++++++++++++++--------- 3 files changed, 39 insertions(+), 25 deletions(-) diff --git a/src/_pytest/capture.py b/src/_pytest/capture.py index 323881151..201bcd962 100644 --- a/src/_pytest/capture.py +++ b/src/_pytest/capture.py @@ -71,13 +71,13 @@ def pytest_load_initial_conftests(early_config: Config): def _get_multicapture(method: "_CaptureMethod") -> "MultiCapture": if method == "fd": - return MultiCapture(out=True, err=True, Capture=FDCapture) + return MultiCapture(in_=FDCapture(0), out=FDCapture(1), err=FDCapture(2)) elif method == "sys": - return MultiCapture(out=True, err=True, Capture=SysCapture) + return MultiCapture(in_=SysCapture(0), out=SysCapture(1), err=SysCapture(2)) elif method == "no": - return MultiCapture(out=False, err=False, in_=False) + return MultiCapture(in_=None, out=None, err=None) elif method == "tee-sys": - return MultiCapture(out=True, err=True, in_=False, Capture=TeeSysCapture) + return MultiCapture(in_=None, out=TeeSysCapture(1), err=TeeSysCapture(2)) raise ValueError("unknown capturing method: {!r}".format(method)) @@ -354,7 +354,7 @@ class CaptureFixture: def _start(self): if self._capture is None: self._capture = MultiCapture( - out=True, err=True, in_=False, Capture=self.captureclass + in_=None, out=self.captureclass(1), err=self.captureclass(2), ) self._capture.start_capturing() @@ -418,17 +418,13 @@ 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 __init__(self, in_, out, err) -> None: + self.in_ = in_ + self.out = out + self.err = err def __repr__(self): return "".format( diff --git a/src/_pytest/pytester.py b/src/_pytest/pytester.py index 1da478736..9df86a22f 100644 --- a/src/_pytest/pytester.py +++ b/src/_pytest/pytester.py @@ -25,8 +25,7 @@ import py import pytest 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 @@ -972,7 +971,7 @@ class Testdir: if syspathinsert: self.syspathinsert() now = time.time() - capture = MultiCapture(Capture=SysCapture) + capture = _get_multicapture("sys") capture.start_capturing() try: try: diff --git a/testing/test_capture.py b/testing/test_capture.py index 177d72ebc..fa4f523d9 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.TeeSysCapture(0) if in_ else None, + out=capture.TeeSysCapture(1) if out else None, + err=capture.TeeSysCapture(2) if err else None, + ) class TestCaptureManager: @@ -1154,7 +1166,11 @@ class TestStdCaptureFDinvalidFD: 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) @@ -1284,8 +1300,11 @@ def test_capturing_and_logging_fundamentals(testdir, method): 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(2), + ) cap.start_capturing() logging.warning("hello1") From 2695b41df3a0f69023c1736da45a9dbe02e8340c Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Sun, 12 Apr 2020 21:09:39 +0300 Subject: [PATCH 24/56] capture: inline _capturing_for_request to simplify the control flow With straight code, it is a little easier to understand, and simplify further. --- src/_pytest/capture.py | 75 +++++++++++++++++++----------------------- 1 file changed, 33 insertions(+), 42 deletions(-) diff --git a/src/_pytest/capture.py b/src/_pytest/capture.py index 201bcd962..3ff9455b0 100644 --- a/src/_pytest/capture.py +++ b/src/_pytest/capture.py @@ -9,14 +9,12 @@ import os import sys from io import UnsupportedOperation from tempfile import TemporaryFile -from typing import Generator from typing import Optional from typing import TextIO 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 @@ -150,35 +148,20 @@ class CaptureManager: def read_global_capture(self): return self._global_capturing.readouterr() - # Fixture Control (it's just forwarding, think about removing this later) + # Fixture Control - @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. - """ + def set_fixture(self, capture_fixture: "CaptureFixture") -> None: 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( + 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( - request.fixturename, other_name + requested_fixture, current_fixture ) ) - 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 = capture_fixture + + def unset_fixture(self) -> None: self._capture_fixture = None def activate_fixture(self): @@ -276,8 +259,12 @@ def capsys(request): ``out`` and ``err`` will be ``text`` objects. """ capman = request.config.pluginmanager.getplugin("capturemanager") - with capman._capturing_for_request(request) as fixture: - yield fixture + capture_fixture = CaptureFixture(SysCapture, request) + capman.set_fixture(capture_fixture) + capture_fixture._start() + yield capture_fixture + capture_fixture.close() + capman.unset_fixture() @pytest.fixture @@ -289,8 +276,12 @@ def capsysbinary(request): ``out`` and ``err`` will be ``bytes`` objects. """ capman = request.config.pluginmanager.getplugin("capturemanager") - with capman._capturing_for_request(request) as fixture: - yield fixture + capture_fixture = CaptureFixture(SysCaptureBinary, request) + capman.set_fixture(capture_fixture) + capture_fixture._start() + yield capture_fixture + capture_fixture.close() + capman.unset_fixture() @pytest.fixture @@ -302,8 +293,12 @@ def capfd(request): ``out`` and ``err`` will be ``text`` objects. """ capman = request.config.pluginmanager.getplugin("capturemanager") - with capman._capturing_for_request(request) as fixture: - yield fixture + capture_fixture = CaptureFixture(FDCapture, request) + capman.set_fixture(capture_fixture) + capture_fixture._start() + yield capture_fixture + capture_fixture.close() + capman.unset_fixture() @pytest.fixture @@ -315,8 +310,12 @@ def capfdbinary(request): ``out`` and ``err`` will be ``byte`` objects. """ capman = request.config.pluginmanager.getplugin("capturemanager") - with capman._capturing_for_request(request) as fixture: - yield fixture + capture_fixture = CaptureFixture(FDCaptureBinary, request) + capman.set_fixture(capture_fixture) + capture_fixture._start() + yield capture_fixture + capture_fixture.close() + capman.unset_fixture() class CaptureIO(io.TextIOWrapper): @@ -701,14 +700,6 @@ class TeeSysCapture(SysCapture): self.tmpfile = tmpfile -map_fixname_class = { - "capfd": FDCapture, - "capfdbinary": FDCaptureBinary, - "capsys": SysCapture, - "capsysbinary": SysCaptureBinary, -} - - class DontReadFromInput: encoding = None From 02c95ea624eded63b64611aee28a0b1e0bb4ad69 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Wed, 15 Apr 2020 16:24:07 +0300 Subject: [PATCH 25/56] capture: remove unused FDCapture tmpfile argument --- src/_pytest/capture.py | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/src/_pytest/capture.py b/src/_pytest/capture.py index 3ff9455b0..106b3fafb 100644 --- a/src/_pytest/capture.py +++ b/src/_pytest/capture.py @@ -506,7 +506,7 @@ class FDCaptureBinary: EMPTY_BUFFER = b"" _state = None - def __init__(self, targetfd, tmpfile=None): + def __init__(self, targetfd): self.targetfd = targetfd try: @@ -530,22 +530,19 @@ class FDCaptureBinary: self.targetfd_save = os.dup(targetfd) if targetfd == 0: - assert not tmpfile, "cannot set tmpfile with stdin" - tmpfile = open(os.devnull) + self.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, - ) + self.tmpfile = EncodedFile( + TemporaryFile(buffering=0), + encoding="utf-8", + errors="replace", + write_through=True, + ) if targetfd in patchsysdict: - self.syscapture = SysCapture(targetfd, tmpfile) + self.syscapture = SysCapture(targetfd, self.tmpfile) else: self.syscapture = NoCapture() - self.tmpfile = tmpfile def __repr__(self): return "<{} {} oldfd={} _state={!r} tmpfile={!r}>".format( From ea3f44894f8d0f944f11b782dd722232a97092ca Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Thu, 16 Apr 2020 09:49:17 +0300 Subject: [PATCH 26/56] capture: replace TeeSysCapture with SysCapture(tee=True) This is more straightforward and does not require duplicating the initialization logic. --- src/_pytest/capture.py | 21 +++++---------------- testing/test_capture.py | 14 ++++++++------ 2 files changed, 13 insertions(+), 22 deletions(-) diff --git a/src/_pytest/capture.py b/src/_pytest/capture.py index 106b3fafb..1eb5e1b41 100644 --- a/src/_pytest/capture.py +++ b/src/_pytest/capture.py @@ -75,7 +75,9 @@ def _get_multicapture(method: "_CaptureMethod") -> "MultiCapture": elif method == "no": return MultiCapture(in_=None, out=None, err=None) elif method == "tee-sys": - return MultiCapture(in_=None, out=TeeSysCapture(1), err=TeeSysCapture(2)) + return MultiCapture( + in_=None, out=SysCapture(1, tee=True), err=SysCapture(2, tee=True) + ) raise ValueError("unknown capturing method: {!r}".format(method)) @@ -620,7 +622,7 @@ class SysCaptureBinary: EMPTY_BUFFER = b"" _state = None - def __init__(self, fd, tmpfile=None): + def __init__(self, fd, tmpfile=None, *, tee=False): name = patchsysdict[fd] self._old = getattr(sys, name) self.name = name @@ -628,7 +630,7 @@ class SysCaptureBinary: if name == "stdin": tmpfile = DontReadFromInput() else: - tmpfile = CaptureIO() + tmpfile = CaptureIO() if not tee else TeeCaptureIO(self._old) self.tmpfile = tmpfile def __repr__(self): @@ -684,19 +686,6 @@ class SysCapture(SysCaptureBinary): 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 - - class DontReadFromInput: encoding = None diff --git a/testing/test_capture.py b/testing/test_capture.py index fa4f523d9..5a0998da7 100644 --- a/testing/test_capture.py +++ b/testing/test_capture.py @@ -37,9 +37,9 @@ def StdCapture(out: bool = True, err: bool = True, in_: bool = True) -> MultiCap def TeeStdCapture(out: bool = True, err: bool = True, in_: bool = True) -> MultiCapture: return capture.MultiCapture( - in_=capture.TeeSysCapture(0) if in_ else None, - out=capture.TeeSysCapture(1) if out else None, - err=capture.TeeSysCapture(2) if err else None, + 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, ) @@ -1292,8 +1292,10 @@ 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( """ @@ -1303,7 +1305,7 @@ def test_capturing_and_logging_fundamentals(testdir, method): cap = capture.MultiCapture( in_=None, out=None, - err=capture.%s(2), + err=capture.%s, ) cap.start_capturing() From 95bd232e57603329e16bb3a4c694e4fe78655c7b Mon Sep 17 00:00:00 2001 From: Florian Dahlitz Date: Tue, 26 May 2020 10:31:53 +0200 Subject: [PATCH 27/56] Apply suggestions from @bluetech --- src/_pytest/nodes.py | 11 +++-------- testing/test_reports.py | 4 +--- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/src/_pytest/nodes.py b/src/_pytest/nodes.py index 5aae211cd..2ed250610 100644 --- a/src/_pytest/nodes.py +++ b/src/_pytest/nodes.py @@ -29,7 +29,6 @@ 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.store import Store if TYPE_CHECKING: @@ -332,19 +331,15 @@ class Node(metaclass=NodeMeta): pass def _repr_failure_py( - self, - excinfo: ExceptionInfo[ - Union[Failed, FixtureLookupError, ConftestImportFailure] - ], - 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) if isinstance(excinfo.value, FixtureLookupError): return excinfo.value.formatrepr() - if isinstance(excinfo.value, ConftestImportFailure): - excinfo = ExceptionInfo(excinfo.value.excinfo) # type: ignore if self.config.getoption("fulltrace", False): style = "long" else: diff --git a/testing/test_reports.py b/testing/test_reports.py index 64d86e953..9e4e7d09d 100644 --- a/testing/test_reports.py +++ b/testing/test_reports.py @@ -404,9 +404,7 @@ class TestReportSerialization: result.stdout.fnmatch_lines( ["E ModuleNotFoundError: No module named 'unknown'"] ) - result.stdout.no_fnmatch_line( - "ERROR - _pytest.config.ConftestImportFailure: ModuleNotFoundError:*" - ) + result.stdout.no_fnmatch_line("ERROR - *ConftestImportFailure*") class TestHooks: From aca534c67dea7eb0fcddf194bc64d65bc3e07c8b Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Tue, 26 May 2020 14:59:16 +0300 Subject: [PATCH 28/56] Improve our own wcwidth implementation and remove dependency on wcwidth package `TerminalWriter`, imported recently from `py`, contains its own incomplete wcwidth (`char_with`/`get_line_width`) implementation. The `TerminalReporter` also needs this, but uses the external `wcwidth` package. This commit brings the `TerminalWriter` implementation up-to-par with `wcwidth`, moves to implementation to a new file `_pytest._io.wcwidth` which is used everywhere, and removes the dependency. The differences compared to the `wcwidth` package are: - Normalizes the string before counting. - Uses Python's `unicodedata` instead of vendored Unicode tables. This means the data corresponds to the Python's version Unicode version instead of the `wcwidth`'s package version. - Apply some optimizations. --- changelog/7264.improvement.rst | 1 + setup.py | 1 - src/_pytest/_io/terminalwriter.py | 17 ++-------- src/_pytest/_io/wcwidth.py | 55 +++++++++++++++++++++++++++++++ src/_pytest/terminal.py | 3 +- testing/io/test_wcwidth.py | 38 +++++++++++++++++++++ testing/test_terminal.py | 27 ++++++++------- 7 files changed, 111 insertions(+), 31 deletions(-) create mode 100644 changelog/7264.improvement.rst create mode 100644 src/_pytest/_io/wcwidth.py create mode 100644 testing/io/test_wcwidth.py 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/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/_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/terminal.py b/src/_pytest/terminal.py index 8ecb5a16b..646fe4cca 100644 --- a/src/_pytest/terminal.py +++ b/src/_pytest/terminal.py @@ -27,6 +27,7 @@ from more_itertools import collapse import pytest from _pytest import nodes 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 @@ -1122,8 +1123,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/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/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( From d742b386c373aeb7ab24e2f5013a537decb97a3a Mon Sep 17 00:00:00 2001 From: Gleb Nikonorov Date: Wed, 27 May 2020 00:53:31 -0400 Subject: [PATCH 29/56] provide missing location parameter, and add type annotations to the hookspec --- src/_pytest/hookspec.py | 8 ++++++- src/_pytest/warnings.py | 7 +++++- testing/test_warnings.py | 46 +++++++++++++++++++++++++++++++++------- 3 files changed, 51 insertions(+), 10 deletions(-) diff --git a/src/_pytest/hookspec.py b/src/_pytest/hookspec.py index e2810ec78..bc8a67ea3 100644 --- a/src/_pytest/hookspec.py +++ b/src/_pytest/hookspec.py @@ -11,6 +11,7 @@ from .deprecated import COLLECT_DIRECTORY_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 @@ -653,7 +654,12 @@ def pytest_warning_captured(warning_message, when, item, location): @hookspec(historic=True) -def pytest_warning_recorded(warning_message, when, nodeid, location): +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. diff --git a/src/_pytest/warnings.py b/src/_pytest/warnings.py index 3ae3c8639..8828a53d6 100644 --- a/src/_pytest/warnings.py +++ b/src/_pytest/warnings.py @@ -115,7 +115,12 @@ def catch_warnings_for_item(config, ihook, when, item): 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) + kwargs=dict( + warning_message=warning_message, + nodeid=nodeid, + when=when, + location=None, + ) ) diff --git a/testing/test_warnings.py b/testing/test_warnings.py index 6cfdfa6bb..8b7ef3296 100644 --- a/testing/test_warnings.py +++ b/testing/test_warnings.py @@ -1,4 +1,5 @@ import os +import re import warnings import pytest @@ -268,20 +269,49 @@ def test_warning_captured_hook(testdir): collected = [] class WarningCollector: - def pytest_warning_recorded(self, warning_message, when, nodeid): - collected.append((str(warning_message.message), when, nodeid)) + 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*"]) expected = [ - ("config warning", "config", ""), - ("collect warning", "collect", ""), - ("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"), + ( + "config warning", + "config", + "", + ( + r"/tmp/pytest-of-.+/pytest-\d+/test_warning_captured_hook0/conftest.py", + 3, + "pytest_configure", + ), + ), + ("collect warning", "collect", "", None), + ("setup warning", "runtest", "test_warning_captured_hook.py::test_func", None), + ("call warning", "runtest", "test_warning_captured_hook.py::test_func", None), + ( + "teardown warning", + "runtest", + "test_warning_captured_hook.py::test_func", + None, + ), ] - assert collected == expected, str(collected) + 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) + + if expected_result[3] is not None: + assert re.match(expected_result[3][0], collected_result[3][0]), str( + collected + ) + assert collected_result[3][1] == expected_result[3][1], str(collected) + assert collected_result[3][2] == expected_result[3][2], str(collected) + else: + assert expected_result[3] == collected_result[3], str(collected) @pytest.mark.filterwarnings("always") From 5b9924e1444ee662a58d50fb22eaff23c38d6c03 Mon Sep 17 00:00:00 2001 From: Florian Dahlitz Date: Wed, 27 May 2020 09:27:13 +0200 Subject: [PATCH 30/56] Fix py35 CI run --- testing/test_reports.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/testing/test_reports.py b/testing/test_reports.py index 9e4e7d09d..81778e27d 100644 --- a/testing/test_reports.py +++ b/testing/test_reports.py @@ -401,9 +401,7 @@ class TestReportSerialization: sub_dir.join("conftest").new(ext=".py").write("import unknown") result = testdir.runpytest_subprocess(".") - result.stdout.fnmatch_lines( - ["E ModuleNotFoundError: No module named 'unknown'"] - ) + result.stdout.fnmatch_lines(["E *Error: No module named 'unknown'"]) result.stdout.no_fnmatch_line("ERROR - *ConftestImportFailure*") From 97bcf5a3a2fdabfdbc61bee275e97d09be728b08 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Thu, 16 Apr 2020 11:29:13 +0300 Subject: [PATCH 31/56] capture: reorder file into sections and avoid forward references Make it easier to read the file in progression, and avoid forward references for upcoming type annotations. There is one cycle, CaptureManager <-> CaptureFixture, which is hard to untangle. (This commit should be added to `.gitblameignore`). --- src/_pytest/capture.py | 1018 ++++++++++++++++++++-------------------- 1 file changed, 521 insertions(+), 497 deletions(-) diff --git a/src/_pytest/capture.py b/src/_pytest/capture.py index 1eb5e1b41..7a5e854ef 100644 --- a/src/_pytest/capture.py +++ b/src/_pytest/capture.py @@ -21,8 +21,6 @@ if TYPE_CHECKING: _CaptureMethod = Literal["fd", "sys", "no", "tee-sys"] -patchsysdict = {0: "stdin", 1: "stdout", 2: "stderr"} - def pytest_addoption(parser): group = parser.getgroup("general") @@ -43,6 +41,105 @@ def pytest_addoption(parser): ) +def _colorama_workaround(): + """ + Ensure colorama is imported so that it attaches to the correct stdio + handles on Windows. + + colorama uses the terminal on import time. So if something does the + first import of colorama while I/O capture is active, colorama will + fail in various ways. + """ + if sys.platform.startswith("win32"): + try: + import colorama # noqa: F401 + except ImportError: + pass + + +def _readline_workaround(): + """ + Ensure readline is imported so that it attaches to the correct stdio + handles on Windows. + + Pdb uses readline support where available--when not running from the Python + prompt, the readline module is not imported until running the pdb REPL. If + running pytest with the --pdb option this means the readline module is not + imported until after I/O capture has been started. + + This is a problem for pyreadline, which is often used to implement readline + support on Windows, as it does not attach to the correct handles for stdout + and/or stdin if they have been redirected by the FDCapture mechanism. This + workaround ensures that readline is imported before I/O capture is setup so + that it can attach to the actual stdin/out for the console. + + See https://github.com/pytest-dev/pytest/pull/1281 + """ + if sys.platform.startswith("win32"): + try: + import readline # noqa: F401 + except ImportError: + pass + + +def _py36_windowsconsoleio_workaround(stream): + """ + Python 3.6 implemented unicode console handling for Windows. This works + by reading/writing to the raw console handle using + ``{Read,Write}ConsoleW``. + + The problem is that we are going to ``dup2`` over the stdio file + descriptors when doing ``FDCapture`` and this will ``CloseHandle`` the + handles used by Python to write to the console. Though there is still some + weirdness and the console handle seems to only be closed randomly and not + on the first call to ``CloseHandle``, or maybe it gets reopened with the + same handle value when we suspend capturing. + + The workaround in this case will reopen stdio with a different fd which + also means a different handle by replicating the logic in + "Py_lifecycle.c:initstdio/create_stdio". + + :param stream: in practice ``sys.stdout`` or ``sys.stderr``, but given + here as parameter for unittesting purposes. + + See https://github.com/pytest-dev/py/issues/103 + """ + if ( + not sys.platform.startswith("win32") + or sys.version_info[:2] < (3, 6) + or hasattr(sys, "pypy_version_info") + ): + return + + # bail out if ``stream`` doesn't seem like a proper ``io`` stream (#2666) + if not hasattr(stream, "buffer"): + return + + buffered = hasattr(stream.buffer, "raw") + raw_stdout = stream.buffer.raw if buffered else stream.buffer + + if not isinstance(raw_stdout, io._WindowsConsoleIO): + return + + def _reopen_stdio(f, mode): + if not buffered and mode[0] == "w": + buffering = 0 + else: + buffering = -1 + + return io.TextIOWrapper( + open(os.dup(f.fileno()), mode, buffering), + f.encoding, + f.errors, + f.newlines, + f.line_buffering, + ) + + sys.stdin = _reopen_stdio(sys.stdin, "rb") + sys.stdout = _reopen_stdio(sys.stdout, "wb") + sys.stderr = _reopen_stdio(sys.stderr, "wb") + + @pytest.hookimpl(hookwrapper=True) def pytest_load_initial_conftests(early_config: Config): ns = early_config.known_args_namespace @@ -67,7 +164,362 @@ def pytest_load_initial_conftests(early_config: Config): sys.stderr.write(err) -def _get_multicapture(method: "_CaptureMethod") -> "MultiCapture": +# 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"" + _state = None + + 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 + + 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 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 FDCaptureBinary: + """Capture IO to/from a given os-level filedescriptor. + + snap() produces `bytes` + """ + + EMPTY_BUFFER = b"" + _state = None + + 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() + + def __repr__(self): + return "<{} {} oldfd={} _state={!r} tmpfile={!r}>".format( + self.__class__.__name__, + self.targetfd, + self.targetfd_save, + self._state, + self.tmpfile, + ) + + def start(self): + """ Start capturing on targetfd using memorized tmpfile. """ + os.dup2(self.tmpfile.fileno(), 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. """ + 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.syscapture.suspend() + os.dup2(self.targetfd_save, self.targetfd) + self._state = "suspended" + + def resume(self): + self.syscapture.resume() + os.dup2(self.tmpfile.fileno(), 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. """ + 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": @@ -81,6 +533,9 @@ def _get_multicapture(method: "_CaptureMethod") -> "MultiCapture": 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 @@ -252,6 +707,69 @@ class CaptureManager: 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``. @@ -318,497 +836,3 @@ def capfdbinary(request): yield capture_fixture capture_fixture.close() capman.unset_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( - 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 - - -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: - _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) - - -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): - 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() - - def __repr__(self): - return "<{} {} oldfd={} _state={!r} tmpfile={!r}>".format( - self.__class__.__name__, - self.targetfd, - self.targetfd_save, - self._state, - self.tmpfile, - ) - - def start(self): - """ Start capturing on targetfd using memorized tmpfile. """ - os.dup2(self.tmpfile.fileno(), 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. """ - 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.syscapture.suspend() - os.dup2(self.targetfd_save, self.targetfd) - self._state = "suspended" - - def resume(self): - self.syscapture.resume() - os.dup2(self.tmpfile.fileno(), 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. """ - super().writeorg(data.encode("utf-8")) # XXX use encoding of original stream - - -class SysCaptureBinary: - - EMPTY_BUFFER = b"" - _state = None - - 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 - - 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 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 - handles on Windows. - - colorama uses the terminal on import time. So if something does the - first import of colorama while I/O capture is active, colorama will - fail in various ways. - """ - if sys.platform.startswith("win32"): - try: - import colorama # noqa: F401 - except ImportError: - pass - - -def _readline_workaround(): - """ - Ensure readline is imported so that it attaches to the correct stdio - handles on Windows. - - Pdb uses readline support where available--when not running from the Python - prompt, the readline module is not imported until running the pdb REPL. If - running pytest with the --pdb option this means the readline module is not - imported until after I/O capture has been started. - - This is a problem for pyreadline, which is often used to implement readline - support on Windows, as it does not attach to the correct handles for stdout - and/or stdin if they have been redirected by the FDCapture mechanism. This - workaround ensures that readline is imported before I/O capture is setup so - that it can attach to the actual stdin/out for the console. - - See https://github.com/pytest-dev/pytest/pull/1281 - """ - if sys.platform.startswith("win32"): - try: - import readline # noqa: F401 - except ImportError: - pass - - -def _py36_windowsconsoleio_workaround(stream): - """ - Python 3.6 implemented unicode console handling for Windows. This works - by reading/writing to the raw console handle using - ``{Read,Write}ConsoleW``. - - The problem is that we are going to ``dup2`` over the stdio file - descriptors when doing ``FDCapture`` and this will ``CloseHandle`` the - handles used by Python to write to the console. Though there is still some - weirdness and the console handle seems to only be closed randomly and not - on the first call to ``CloseHandle``, or maybe it gets reopened with the - same handle value when we suspend capturing. - - The workaround in this case will reopen stdio with a different fd which - also means a different handle by replicating the logic in - "Py_lifecycle.c:initstdio/create_stdio". - - :param stream: in practice ``sys.stdout`` or ``sys.stderr``, but given - here as parameter for unittesting purposes. - - See https://github.com/pytest-dev/py/issues/103 - """ - if ( - not sys.platform.startswith("win32") - or sys.version_info[:2] < (3, 6) - or hasattr(sys, "pypy_version_info") - ): - return - - # bail out if ``stream`` doesn't seem like a proper ``io`` stream (#2666) - if not hasattr(stream, "buffer"): - return - - buffered = hasattr(stream.buffer, "raw") - raw_stdout = stream.buffer.raw if buffered else stream.buffer - - if not isinstance(raw_stdout, io._WindowsConsoleIO): - return - - def _reopen_stdio(f, mode): - if not buffered and mode[0] == "w": - buffering = 0 - else: - buffering = -1 - - return io.TextIOWrapper( - open(os.dup(f.fileno()), mode, buffering), - f.encoding, - f.errors, - f.newlines, - f.line_buffering, - ) - - sys.stdin = _reopen_stdio(sys.stdin, "rb") - sys.stdout = _reopen_stdio(sys.stdout, "wb") - sys.stderr = _reopen_stdio(sys.stderr, "wb") From fd3ba053cfab5d376b66a880f41834638e93fa0f Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Thu, 16 Apr 2020 20:59:27 +0300 Subject: [PATCH 32/56] capture: don't assume that the tmpfile is backed by a BytesIO Since tmpfile is a parameter to SysCapture, it shouldn't assume things unnecessarily, when there is an alternative. --- src/_pytest/capture.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/_pytest/capture.py b/src/_pytest/capture.py index 7a5e854ef..a07892563 100644 --- a/src/_pytest/capture.py +++ b/src/_pytest/capture.py @@ -281,7 +281,8 @@ class SysCaptureBinary: self._state = "started" def snap(self): - res = self.tmpfile.buffer.getvalue() + self.tmpfile.seek(0) + res = self.tmpfile.buffer.read() self.tmpfile.seek(0) self.tmpfile.truncate() return res From a35800c2e1461d32dfd4322dc21d02e46c49c776 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Fri, 17 Apr 2020 10:01:51 +0300 Subject: [PATCH 33/56] capture: formalize and check allowed state transition in capture classes There are state transitions start/done/suspend/resume and two additional operations snap/writeorg. Previously it was not well defined in what order they can be called, and which operations are idempotent. Formalize this and enforce using assert checks with informative error messages if they fail (rather than random AttributeErrors). --- src/_pytest/capture.py | 48 +++++++++++++++++++++++++++++++++++++---- testing/test_capture.py | 14 ++++++------ 2 files changed, 51 insertions(+), 11 deletions(-) diff --git a/src/_pytest/capture.py b/src/_pytest/capture.py index a07892563..32e83dd21 100644 --- a/src/_pytest/capture.py +++ b/src/_pytest/capture.py @@ -11,6 +11,7 @@ from io import UnsupportedOperation from tempfile import TemporaryFile from typing import Optional from typing import TextIO +from typing import Tuple import pytest from _pytest.compat import TYPE_CHECKING @@ -245,7 +246,6 @@ class NoCapture: class SysCaptureBinary: EMPTY_BUFFER = b"" - _state = None def __init__(self, fd, tmpfile=None, *, tee=False): name = patchsysdict[fd] @@ -257,6 +257,7 @@ class SysCaptureBinary: 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( @@ -276,11 +277,20 @@ class SysCaptureBinary: 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) @@ -288,20 +298,28 @@ class SysCaptureBinary: 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 = "resumed" + 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() @@ -317,6 +335,7 @@ class SysCapture(SysCaptureBinary): return res def writeorg(self, data): + self._assert_state("writeorg", ("started", "suspended")) self._old.write(data) self._old.flush() @@ -328,7 +347,6 @@ class FDCaptureBinary: """ EMPTY_BUFFER = b"" - _state = None def __init__(self, targetfd): self.targetfd = targetfd @@ -368,6 +386,8 @@ class FDCaptureBinary: else: self.syscapture = NoCapture() + self._state = "initialized" + def __repr__(self): return "<{} {} oldfd={} _state={!r} tmpfile={!r}>".format( self.__class__.__name__, @@ -377,13 +397,22 @@ class FDCaptureBinary: 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) @@ -393,6 +422,9 @@ class FDCaptureBinary: 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: @@ -404,17 +436,24 @@ class FDCaptureBinary: 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 = "resumed" + self._state = "started" def writeorg(self, data): """ write to original file descriptor. """ + self._assert_state("writeorg", ("started", "suspended")) os.write(self.targetfd_save, data) @@ -428,6 +467,7 @@ class FDCapture(FDCaptureBinary): 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) diff --git a/testing/test_capture.py b/testing/test_capture.py index 5a0998da7..95f2d748a 100644 --- a/testing/test_capture.py +++ b/testing/test_capture.py @@ -878,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) @@ -901,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) @@ -952,7 +951,7 @@ 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) == ( "".format( @@ -1154,6 +1153,7 @@ class TestStdCaptureFD(TestStdCapture): with lsof_check(): for i in range(10): cap = StdCaptureFD() + cap.start_capturing() cap.stop_capturing() @@ -1175,7 +1175,7 @@ class TestStdCaptureFDinvalidFD: def test_stdout(): os.close(1) cap = StdCaptureFD(out=True, err=False, in_=False) - assert fnmatch(repr(cap.out), "") + assert fnmatch(repr(cap.out), "") cap.start_capturing() os.write(1, b"stdout") assert cap.readouterr() == ("stdout", "") @@ -1184,7 +1184,7 @@ class TestStdCaptureFDinvalidFD: def test_stderr(): os.close(2) cap = StdCaptureFD(out=False, err=True, in_=False) - assert fnmatch(repr(cap.err), "") + assert fnmatch(repr(cap.err), "") cap.start_capturing() os.write(2, b"stderr") assert cap.readouterr() == ("", "stderr") @@ -1193,7 +1193,7 @@ class TestStdCaptureFDinvalidFD: def test_stdin(): os.close(0) cap = StdCaptureFD(out=False, err=False, in_=True) - assert fnmatch(repr(cap.in_), "") + assert fnmatch(repr(cap.in_), "") cap.stop_capturing() """ ) From 7a704288df8ed2d416c6dd8b15df3664ad425a5a Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Fri, 17 Apr 2020 22:52:09 +0300 Subject: [PATCH 34/56] capture: remove unneeded getattr This attribute is set in __init__ and not deleted. Other methods do it already but this one wasn't updated. --- src/_pytest/capture.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/_pytest/capture.py b/src/_pytest/capture.py index 32e83dd21..64f4b8b92 100644 --- a/src/_pytest/capture.py +++ b/src/_pytest/capture.py @@ -630,9 +630,8 @@ class CaptureManager: 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_) + 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. From f93e021bc87c17528e0c1aaebceea178b22b7470 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Wed, 27 May 2020 13:04:56 +0300 Subject: [PATCH 35/56] capture: remove some unclear parametrization from a test The two cases end up doing the same (the tmpfile fixture isn't used except being truthy). --- testing/test_capture.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/testing/test_capture.py b/testing/test_capture.py index 95f2d748a..1301a0e69 100644 --- a/testing/test_capture.py +++ b/testing/test_capture.py @@ -1255,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 From 14de08011b05bffe66df6198ac4ccbac5c8aa7fe Mon Sep 17 00:00:00 2001 From: Gleb Nikonorov Date: Wed, 27 May 2020 23:03:07 -0400 Subject: [PATCH 36/56] fix the unit tests, add the proper deprecation warning, and add in a changelog entry --- changelog/7255.feature.rst | 3 +++ src/_pytest/deprecated.py | 5 +++++ src/_pytest/hookspec.py | 8 ++------ testing/test_warnings.py | 40 +++++++++++++------------------------- 4 files changed, 23 insertions(+), 33 deletions(-) create mode 100644 changelog/7255.feature.rst 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/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/hookspec.py b/src/_pytest/hookspec.py index bc8a67ea3..341f0a250 100644 --- a/src/_pytest/hookspec.py +++ b/src/_pytest/hookspec.py @@ -8,6 +8,7 @@ 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: @@ -621,12 +622,7 @@ def pytest_terminal_summary(terminalreporter, exitstatus, config): """ -@hookspec( - historic=True, - warn_on_impl=DeprecationWarning( - "pytest_warning_captured is deprecated and will be removed soon" - ), -) +@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. diff --git a/testing/test_warnings.py b/testing/test_warnings.py index 8b7ef3296..070ed72c5 100644 --- a/testing/test_warnings.py +++ b/testing/test_warnings.py @@ -1,5 +1,4 @@ import os -import re import warnings import pytest @@ -276,25 +275,11 @@ def test_warning_captured_hook(testdir): result.stdout.fnmatch_lines(["*1 passed*"]) expected = [ - ( - "config warning", - "config", - "", - ( - r"/tmp/pytest-of-.+/pytest-\d+/test_warning_captured_hook0/conftest.py", - 3, - "pytest_configure", - ), - ), - ("collect warning", "collect", "", None), - ("setup warning", "runtest", "test_warning_captured_hook.py::test_func", None), - ("call warning", "runtest", "test_warning_captured_hook.py::test_func", None), - ( - "teardown warning", - "runtest", - "test_warning_captured_hook.py::test_func", - None, - ), + ("config warning", "config", "",), + ("collect warning", "collect", ""), + ("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"), ] for index in range(len(expected)): collected_result = collected[index] @@ -304,14 +289,15 @@ def test_warning_captured_hook(testdir): assert collected_result[1] == expected_result[1], str(collected) assert collected_result[2] == expected_result[2], str(collected) - if expected_result[3] is not None: - assert re.match(expected_result[3][0], collected_result[3][0]), str( - collected - ) - assert collected_result[3][1] == expected_result[3][1], str(collected) - assert collected_result[3][2] == expected_result[3][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 expected_result[3] == collected_result[3], str(collected) + assert collected_result[3] is None, str(collected) @pytest.mark.filterwarnings("always") From 2af0d1e221739ebad1d318743d2987f3d45511f8 Mon Sep 17 00:00:00 2001 From: Gleb Nikonorov Date: Thu, 28 May 2020 00:02:28 -0400 Subject: [PATCH 37/56] remove a stray comma in a test tuple --- testing/test_warnings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testing/test_warnings.py b/testing/test_warnings.py index 070ed72c5..ea7ab397d 100644 --- a/testing/test_warnings.py +++ b/testing/test_warnings.py @@ -275,7 +275,7 @@ def test_warning_captured_hook(testdir): result.stdout.fnmatch_lines(["*1 passed*"]) expected = [ - ("config warning", "config", "",), + ("config warning", "config", ""), ("collect warning", "collect", ""), ("setup warning", "runtest", "test_warning_captured_hook.py::test_func"), ("call warning", "runtest", "test_warning_captured_hook.py::test_func"), From 2ee90887b77212e2e8f427ed6db9feab85f06b49 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Thu, 28 May 2020 12:12:10 +0300 Subject: [PATCH 38/56] code: remove last usage of py.error `str(self.path)` can't raise at all, so it can just be removed. --- src/_pytest/_code/code.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/_pytest/_code/code.py b/src/_pytest/_code/code.py index 2075fd0eb..7e8cff2ed 100644 --- a/src/_pytest/_code/code.py +++ b/src/_pytest/_code/code.py @@ -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: From 94c7b8b47cd6b5b14f463731e473929b42881073 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Katarzyna=20Kr=C3=B3l?= <38542683+CarycaKatarzyna@users.noreply.github.com> Date: Sat, 30 May 2020 13:10:58 +0200 Subject: [PATCH 39/56] Issue 1316 - longrepr is a string when pytrace=False (#7100) --- changelog/1316.breaking.rst | 1 + src/_pytest/_code/code.py | 44 ++++++++++++++++++++++++------------- src/_pytest/junitxml.py | 4 +--- src/_pytest/nodes.py | 2 +- testing/test_runner.py | 11 ++++++++++ testing/test_skipping.py | 2 +- 6 files changed, 44 insertions(+), 20 deletions(-) create mode 100644 changelog/1316.breaking.rst 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/src/_pytest/_code/code.py b/src/_pytest/_code/code.py index 7e8cff2ed..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: @@ -583,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. @@ -758,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) @@ -780,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: @@ -806,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) @@ -865,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 @@ -1048,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/junitxml.py b/src/_pytest/junitxml.py index 77e184312..4a1afc63e 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/nodes.py b/src/_pytest/nodes.py index 2ed250610..4a79bc861 100644 --- a/src/_pytest/nodes.py +++ b/src/_pytest/nodes.py @@ -337,7 +337,7 @@ class Node(metaclass=NodeMeta): 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): 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): From 56bf819c2f4eaf8b36bd8c42c06bb59d5a3bfc0f Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Sat, 30 May 2020 14:33:22 -0300 Subject: [PATCH 40/56] Do not call TestCase.tearDown for skipped tests (#7236) Fix #7215 --- changelog/7215.bugfix.rst | 2 ++ src/_pytest/unittest.py | 11 ++++++++--- testing/test_unittest.py | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 44 insertions(+), 3 deletions(-) create mode 100644 changelog/7215.bugfix.rst 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/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/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") From fb9f277a99c32dbdde251a1df1ae5bc64c4c7de6 Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Fri, 10 Jan 2020 00:50:12 +0100 Subject: [PATCH 41/56] Node._repr_failure_py: use abspath with changed cwd Fixes https://github.com/pytest-dev/pytest/issues/6428. --- src/_pytest/nodes.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/_pytest/nodes.py b/src/_pytest/nodes.py index 4a79bc861..61c6bc90a 100644 --- a/src/_pytest/nodes.py +++ b/src/_pytest/nodes.py @@ -362,8 +362,7 @@ class Node(metaclass=NodeMeta): truncate_locals = True try: - os.getcwd() - abspath = False + abspath = os.getcwd() != str(self.config.invocation_dir) except OSError: abspath = True From b98aa195e0cc3f468316358bcd49e7dbc4d13483 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Sat, 16 May 2020 11:36:22 -0300 Subject: [PATCH 42/56] Add test and changelog for #6428 --- changelog/6428.bugfix.rst | 2 ++ testing/test_nodes.py | 27 +++++++++++++++++++++++++++ 2 files changed, 29 insertions(+) create mode 100644 changelog/6428.bugfix.rst 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/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 *"]) From 757bded13592ca05d124215124b5808d9290e063 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Sat, 16 May 2020 12:00:23 -0300 Subject: [PATCH 43/56] Use Path() instead of str for path comparison On Windows specifically is common to have drives diverging just by casing ("C:" vs "c:"), depending on the cwd provided by the user. --- src/_pytest/nodes.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/_pytest/nodes.py b/src/_pytest/nodes.py index 61c6bc90a..7a8c28cd4 100644 --- a/src/_pytest/nodes.py +++ b/src/_pytest/nodes.py @@ -29,6 +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.pathlib import Path from _pytest.store import Store if TYPE_CHECKING: @@ -361,8 +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: - abspath = os.getcwd() != str(self.config.invocation_dir) + abspath = Path(os.getcwd()) != Path(self.config.invocation_dir) except OSError: abspath = True From eef4f87e7b9958687b6032f8475535ac0beca10f Mon Sep 17 00:00:00 2001 From: Gleb Nikonorov Date: Sat, 30 May 2020 20:36:02 -0400 Subject: [PATCH 44/56] Output a warning to stderr when an invalid key is read from an INI config file --- src/_pytest/config/__init__.py | 9 +++++++ testing/test_config.py | 46 ++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index bb5034ab1..65e5271c2 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -1020,6 +1020,7 @@ class Config: ) self._checkversion() + self._validatekeys() self._consider_importhook(args) self.pluginmanager.consider_preparse(args, exclude_only=False) if not os.environ.get("PYTEST_DISABLE_PLUGIN_AUTOLOAD"): @@ -1072,6 +1073,14 @@ class Config: ) ) + def _validatekeys(self): + for key in self._get_unknown_ini_keys(): + sys.stderr.write("WARNING: unknown config ini key: {}\n".format(key)) + + 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/testing/test_config.py b/testing/test_config.py index 17385dc17..9323e6716 100644 --- a/testing/test_config.py +++ b/testing/test_config.py @@ -147,6 +147,52 @@ class TestParseIni: result = testdir.inline_run("--confcutdir=.") assert result.ret == 0 + @pytest.mark.parametrize( + "ini_file_text, invalid_keys, stderr_output", + [ + ( + """ + [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", + ], + ), + ( + """ + [pytest] + unknown_ini = value1 + minversion = 5.0.0 + """, + ["unknown_ini"], + ["WARNING: unknown config ini key: unknown_ini"], + ), + ( + """ + [pytest] + minversion = 5.0.0 + """, + [], + [], + ), + ], + ) + def test_invalid_ini_keys_generate_warings( + self, testdir, ini_file_text, invalid_keys, stderr_output + ): + 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) + class TestConfigCmdlineParsing: def test_parsing_again_fails(self, testdir): From 8f2c2a5dd9fffc2a59d3ed868c801746ce4b51b5 Mon Sep 17 00:00:00 2001 From: Gleb Nikonorov Date: Sun, 31 May 2020 00:49:21 -0400 Subject: [PATCH 45/56] Add test case for invalid ini key in different section header --- testing/test_config.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/testing/test_config.py b/testing/test_config.py index 9323e6716..e35019337 100644 --- a/testing/test_config.py +++ b/testing/test_config.py @@ -173,6 +173,16 @@ class TestParseIni: ), ( """ + [some_other_header] + unknown_ini = value1 + [pytest] + minversion = 5.0.0 + """, + [], + [], + ), + ( + """ [pytest] minversion = 5.0.0 """, From b32f4de891e217a24f02bb66a63750a36d7effac Mon Sep 17 00:00:00 2001 From: Maximilian Cosmo Sitter <48606431+mcsitter@users.noreply.github.com> Date: Sun, 31 May 2020 08:37:26 +0200 Subject: [PATCH 46/56] Issue 7202 - Point development guide to contributing section (#7280) --- changelog/7202.doc.rst | 1 + doc/en/development_guide.rst | 59 ++---------------------------------- 2 files changed, 4 insertions(+), 56 deletions(-) create mode 100644 changelog/7202.doc.rst 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/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 `_. From db203afba32cc162ab9e8d1da079dde600bd21cc Mon Sep 17 00:00:00 2001 From: Gleb Nikonorov Date: Sun, 31 May 2020 02:45:40 -0400 Subject: [PATCH 47/56] Add in --strict-config flag to force warnings to errors --- src/_pytest/config/__init__.py | 9 ++++++--- src/_pytest/main.py | 5 +++++ testing/test_config.py | 20 ++++++++++++++------ 3 files changed, 25 insertions(+), 9 deletions(-) diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index 65e5271c2..63f7d4cb4 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -1020,7 +1020,7 @@ class Config: ) self._checkversion() - self._validatekeys() + self._validatekeys(args) self._consider_importhook(args) self.pluginmanager.consider_preparse(args, exclude_only=False) if not os.environ.get("PYTEST_DISABLE_PLUGIN_AUTOLOAD"): @@ -1073,9 +1073,12 @@ class Config: ) ) - def _validatekeys(self): + def _validatekeys(self, args: Sequence[str]): for key in self._get_unknown_ini_keys(): - sys.stderr.write("WARNING: unknown config ini key: {}\n".format(key)) + message = "Unknown config ini key: {}\n".format(key) + if "--strict-config" in args: + fail(message, pytrace=False) + sys.stderr.write("WARNING: {}".format(message)) def _get_unknown_ini_keys(self) -> List[str]: parser_inicfg = self._parser._inidict 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/testing/test_config.py b/testing/test_config.py index e35019337..6a08e93f3 100644 --- a/testing/test_config.py +++ b/testing/test_config.py @@ -148,7 +148,7 @@ class TestParseIni: assert result.ret == 0 @pytest.mark.parametrize( - "ini_file_text, invalid_keys, stderr_output", + "ini_file_text, invalid_keys, stderr_output, exception_text", [ ( """ @@ -158,9 +158,10 @@ class TestParseIni: """, ["unknown_ini", "another_unknown_ini"], [ - "WARNING: unknown config ini key: unknown_ini", - "WARNING: unknown config ini key: another_unknown_ini", + "WARNING: Unknown config ini key: unknown_ini", + "WARNING: Unknown config ini key: another_unknown_ini", ], + "Unknown config ini key: unknown_ini", ), ( """ @@ -169,7 +170,8 @@ class TestParseIni: minversion = 5.0.0 """, ["unknown_ini"], - ["WARNING: unknown config ini key: unknown_ini"], + ["WARNING: Unknown config ini key: unknown_ini"], + "Unknown config ini key: unknown_ini", ), ( """ @@ -180,6 +182,7 @@ class TestParseIni: """, [], [], + "", ), ( """ @@ -188,11 +191,12 @@ class TestParseIni: """, [], [], + "", ), ], ) - def test_invalid_ini_keys_generate_warings( - self, testdir, ini_file_text, invalid_keys, stderr_output + 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() @@ -203,6 +207,10 @@ class TestParseIni: 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): From 9da1d0687ea02b724ad27c1caafc58651e01ee5a Mon Sep 17 00:00:00 2001 From: Simon K Date: Sun, 31 May 2020 16:11:11 +0100 Subject: [PATCH 48/56] adding towncrier wrapper script so 'tox -e docs' works natively on windows (#7266) * enable tox -e docs natively on windows using a wrapper * rename the towncrier script; run the towncrier command in a safer manner * use subprocess.call; call exit() around main on towncrier wrapper * change to sys.exit() instead of builtin exit() --- scripts/towncrier-draft-to-file.py | 15 +++++++++++++++ tox.ini | 3 +-- 2 files changed, 16 insertions(+), 2 deletions(-) create mode 100644 scripts/towncrier-draft-to-file.py 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/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 From 92d15c6af1943faeb629bf3cbd6488b56d9aef52 Mon Sep 17 00:00:00 2001 From: Gleb Nikonorov Date: Sun, 31 May 2020 11:33:31 -0400 Subject: [PATCH 49/56] review feedback --- src/_pytest/config/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index 63f7d4cb4..5fc23716d 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -1020,7 +1020,6 @@ class Config: ) self._checkversion() - self._validatekeys(args) self._consider_importhook(args) self.pluginmanager.consider_preparse(args, exclude_only=False) if not os.environ.get("PYTEST_DISABLE_PLUGIN_AUTOLOAD"): @@ -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 @@ -1073,10 +1073,10 @@ class Config: ) ) - def _validatekeys(self, args: Sequence[str]): + def _validatekeys(self): for key in self._get_unknown_ini_keys(): message = "Unknown config ini key: {}\n".format(key) - if "--strict-config" in args: + if self.known_args_namespace.strict_config: fail(message, pytrace=False) sys.stderr.write("WARNING: {}".format(message)) From 9ae94b08e2ad55aed1abc7aa414adac626d005f9 Mon Sep 17 00:00:00 2001 From: Gleb Nikonorov Date: Sun, 31 May 2020 11:58:39 -0400 Subject: [PATCH 50/56] Add documentation --- AUTHORS | 1 + changelog/6856.feature.rst | 0 2 files changed, 1 insertion(+) create mode 100644 changelog/6856.feature.rst diff --git a/AUTHORS b/AUTHORS index 5d410da18..41b0e38b0 100644 --- a/AUTHORS +++ b/AUTHORS @@ -109,6 +109,7 @@ Gabriel Reis Gene Wood George Kussumoto Georgy Dyuldin +Gleb Nikonorov Graham Horler Greg Price Gregory Lee diff --git a/changelog/6856.feature.rst b/changelog/6856.feature.rst new file mode 100644 index 000000000..e69de29bb From 2f406bb9cb8538e5a43551d6eeffe2be80bccefa Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Mon, 1 Jun 2020 09:21:08 -0300 Subject: [PATCH 51/56] Replace custom flask theme by the official one (#6453) Ref: #6402 --- .../flask => _templates}/relations.html | 0 .../flask => _templates}/slim_searchbox.html | 0 doc/en/_themes/.gitignore | 3 - doc/en/_themes/LICENSE | 37 -- doc/en/_themes/README | 31 - doc/en/_themes/flask/layout.html | 24 - doc/en/_themes/flask/static/flasky.css_t | 623 ------------------ doc/en/_themes/flask/theme.conf | 9 - doc/en/_themes/flask_theme_support.py | 87 --- doc/en/conf.py | 3 +- doc/en/requirements.txt | 3 +- 11 files changed, 4 insertions(+), 816 deletions(-) rename doc/en/{_themes/flask => _templates}/relations.html (100%) rename doc/en/{_themes/flask => _templates}/slim_searchbox.html (100%) delete mode 100644 doc/en/_themes/.gitignore delete mode 100644 doc/en/_themes/LICENSE delete mode 100644 doc/en/_themes/README delete mode 100644 doc/en/_themes/flask/layout.html delete mode 100644 doc/en/_themes/flask/static/flasky.css_t delete mode 100644 doc/en/_themes/flask/theme.conf delete mode 100644 doc/en/_themes/flask_theme_support.py 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/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/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 From 9214e63af38ad76c6d4f02f0db4a4cdb736ab032 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Tue, 2 Jun 2020 10:29:36 +0300 Subject: [PATCH 52/56] ci: use fetch-depth: 0 instead of fetching manually (#7297) --- .github/workflows/main.yml | 8 ++++---- .github/workflows/release-on-comment.yml | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 6d1910014..262ed5946 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -128,8 +128,8 @@ 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' @@ -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 From eaf46f53545f4fd8f7d6bd64115c8751590a555c Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Mon, 1 Jun 2020 21:09:40 -0300 Subject: [PATCH 53/56] Adjust codecov: only patch statuses Fix #6994 --- codecov.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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 From fe640934111b52b0461a3d35994730667525b783 Mon Sep 17 00:00:00 2001 From: Tor Colvin Date: Tue, 2 Jun 2020 07:56:33 -0400 Subject: [PATCH 54/56] Fix removal of very long paths on Windows (#6755) Co-authored-by: Bruno Oliveira --- AUTHORS | 1 + changelog/6755.bugfix.rst | 1 + src/_pytest/pathlib.py | 32 ++++++++++++++++++++++++++++++++ testing/test_pathlib.py | 24 ++++++++++++++++++++++++ 4 files changed, 58 insertions(+) create mode 100644 changelog/6755.bugfix.rst diff --git a/AUTHORS b/AUTHORS index 5d410da18..a76c0a632 100644 --- a/AUTHORS +++ b/AUTHORS @@ -277,6 +277,7 @@ Tom Dalton Tom Viner Tomáš Gavenčiak Tomer Keren +Tor Colvin Trevor Bekolay Tyler Goodlet Tzu-ping Chung 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/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/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" From a5d13d4ced6dc3f8d826233e02788b86aebb3743 Mon Sep 17 00:00:00 2001 From: Gleb Nikonorov Date: Tue, 2 Jun 2020 08:21:57 -0400 Subject: [PATCH 55/56] Add changelog entry --- changelog/6856.feature.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/changelog/6856.feature.rst b/changelog/6856.feature.rst index e69de29bb..36892fa21 100644 --- a/changelog/6856.feature.rst +++ 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. From 5517f7264fcb85cf1563702bd9444e7be917daf3 Mon Sep 17 00:00:00 2001 From: xuiqzy Date: Tue, 2 Jun 2020 14:56:39 +0200 Subject: [PATCH 56/56] Remove doc line that is no longer relevant for Python3-only (#7263) * Fix typo in capture.rst documentation Rename ``capfsysbinary`` to ``capsysbinary`` as the former does not exist as far as i can see. * Make Python uppercase in doc/en/capture.rst Co-authored-by: Hugo van Kemenade * Remove the sentence entirely Co-authored-by: Hugo van Kemenade Co-authored-by: Ran Benita --- doc/en/capture.rst | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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.