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