From 73448f265d8c680c35dc66dfe65c3cc14fc337eb Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Thu, 7 May 2020 21:20:09 +0300 Subject: [PATCH] Handle EPIPE/BrokenPipeError in pytest's CLI Running `pytest | head -1` and similar causes an annoying error to be printed to stderr: Exception ignored in: <_io.TextIOWrapper name='' mode='w' encoding='utf-8'> BrokenPipeError: [Errno 32] Broken pipe (or possibly even a propagating exception in older/other Python versions). The standard UNIX behavior is to handle the EPIPE silently. To recommended method to do this in Python is described here: https://docs.python.org/3/library/signal.html#note-on-sigpipe It is not appropriate to apply this recommendation to `pytest.main()`, which is used programmatically for in-process runs. Hence, change pytest's entrypoint to a new `pytest.console_main()` function, to be used exclusively by pytest's CLI, and add the SIGPIPE code there. Fixes #4375. --- changelog/4375.improvement.rst | 3 +++ setup.cfg | 4 ++-- src/_pytest/config/__init__.py | 18 ++++++++++++++++++ src/pytest/__init__.py | 2 ++ src/pytest/__main__.py | 2 +- testing/acceptance_test.py | 19 +++++++++++++++++++ 6 files changed, 45 insertions(+), 3 deletions(-) create mode 100644 changelog/4375.improvement.rst diff --git a/changelog/4375.improvement.rst b/changelog/4375.improvement.rst new file mode 100644 index 000000000..0c9a7f3e6 --- /dev/null +++ b/changelog/4375.improvement.rst @@ -0,0 +1,3 @@ +The ``pytest`` command now supresses the ``BrokenPipeError`` error message that +is printed to stderr when the output of ``pytest`` is piped and and the pipe is +closed by the piped-to program (common examples are ``less`` and ``head``). diff --git a/setup.cfg b/setup.cfg index 708951da4..5928781c0 100644 --- a/setup.cfg +++ b/setup.cfg @@ -43,8 +43,8 @@ python_requires = >=3.5 [options.entry_points] console_scripts = - pytest=pytest:main - py.test=pytest:main + pytest=pytest:console_main + py.test=pytest:console_main [build_sphinx] source-dir = doc/en/ diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index 8e5944fc7..8a2c16d5d 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -137,6 +137,24 @@ def main(args=None, plugins=None) -> Union[int, ExitCode]: return ExitCode.USAGE_ERROR +def console_main() -> int: + """pytest's CLI entry point. + + This function is not meant for programmable use; use `main()` instead. + """ + # https://docs.python.org/3/library/signal.html#note-on-sigpipe + try: + code = main() + sys.stdout.flush() + return code + except BrokenPipeError: + # Python flushes standard streams on exit; redirect remaining output + # to devnull to avoid another BrokenPipeError at shutdown + devnull = os.open(os.devnull, os.O_WRONLY) + os.dup2(devnull, sys.stdout.fileno()) + return 1 # Python exits with error code 1 on EPIPE + + class cmdline: # compatibility namespace main = staticmethod(main) diff --git a/src/pytest/__init__.py b/src/pytest/__init__.py index 8629569b2..64d6d1f23 100644 --- a/src/pytest/__init__.py +++ b/src/pytest/__init__.py @@ -6,6 +6,7 @@ from . import collect from _pytest import __version__ from _pytest.assertion import register_assert_rewrite from _pytest.config import cmdline +from _pytest.config import console_main from _pytest.config import ExitCode from _pytest.config import hookimpl from _pytest.config import hookspec @@ -57,6 +58,7 @@ __all__ = [ "cmdline", "collect", "Collector", + "console_main", "deprecated_call", "exit", "ExitCode", diff --git a/src/pytest/__main__.py b/src/pytest/__main__.py index 01b2f6ccf..25b1e45b8 100644 --- a/src/pytest/__main__.py +++ b/src/pytest/__main__.py @@ -4,4 +4,4 @@ pytest entry point import pytest if __name__ == "__main__": - raise SystemExit(pytest.main()) + raise SystemExit(pytest.console_main()) diff --git a/testing/acceptance_test.py b/testing/acceptance_test.py index 36a24a38a..45a23ee93 100644 --- a/testing/acceptance_test.py +++ b/testing/acceptance_test.py @@ -11,6 +11,7 @@ import pytest from _pytest.compat import importlib_metadata from _pytest.config import ExitCode from _pytest.monkeypatch import MonkeyPatch +from _pytest.pytester import Testdir def prepend_pythonpath(*dirs): @@ -1343,3 +1344,21 @@ def test_tee_stdio_captures_and_live_prints(testdir): fullXml = f.read() assert "@this is stdout@\n" in fullXml assert "@this is stderr@\n" in fullXml + + +@pytest.mark.skipif( + sys.platform == "win32", + reason="Windows raises `OSError: [Errno 22] Invalid argument` instead", +) +def test_no_brokenpipeerror_message(testdir: Testdir) -> None: + """Ensure that the broken pipe error message is supressed. + + In some Python versions, it reaches sys.unraisablehook, in others + a BrokenPipeError exception is propagated, but either way it prints + to stderr on shutdown, so checking nothing is printed is enough. + """ + popen = testdir.popen((*testdir._getpytestargs(), "--help")) + popen.stdout.close() + ret = popen.wait() + assert popen.stderr.read() == b"" + assert ret == 1