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='<stdout>' 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.
			
			
This commit is contained in:
		
							parent
							
								
									de556f895f
								
							
						
					
					
						commit
						73448f265d
					
				| 
						 | 
					@ -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``).
 | 
				
			||||||
| 
						 | 
					@ -43,8 +43,8 @@ python_requires = >=3.5
 | 
				
			||||||
 | 
					
 | 
				
			||||||
[options.entry_points]
 | 
					[options.entry_points]
 | 
				
			||||||
console_scripts =
 | 
					console_scripts =
 | 
				
			||||||
	pytest=pytest:main
 | 
						pytest=pytest:console_main
 | 
				
			||||||
	py.test=pytest:main
 | 
						py.test=pytest:console_main
 | 
				
			||||||
 | 
					
 | 
				
			||||||
[build_sphinx]
 | 
					[build_sphinx]
 | 
				
			||||||
source-dir = doc/en/
 | 
					source-dir = doc/en/
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -137,6 +137,24 @@ def main(args=None, plugins=None) -> Union[int, ExitCode]:
 | 
				
			||||||
        return ExitCode.USAGE_ERROR
 | 
					        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
 | 
					class cmdline:  # compatibility namespace
 | 
				
			||||||
    main = staticmethod(main)
 | 
					    main = staticmethod(main)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -6,6 +6,7 @@ from . import collect
 | 
				
			||||||
from _pytest import __version__
 | 
					from _pytest import __version__
 | 
				
			||||||
from _pytest.assertion import register_assert_rewrite
 | 
					from _pytest.assertion import register_assert_rewrite
 | 
				
			||||||
from _pytest.config import cmdline
 | 
					from _pytest.config import cmdline
 | 
				
			||||||
 | 
					from _pytest.config import console_main
 | 
				
			||||||
from _pytest.config import ExitCode
 | 
					from _pytest.config import ExitCode
 | 
				
			||||||
from _pytest.config import hookimpl
 | 
					from _pytest.config import hookimpl
 | 
				
			||||||
from _pytest.config import hookspec
 | 
					from _pytest.config import hookspec
 | 
				
			||||||
| 
						 | 
					@ -57,6 +58,7 @@ __all__ = [
 | 
				
			||||||
    "cmdline",
 | 
					    "cmdline",
 | 
				
			||||||
    "collect",
 | 
					    "collect",
 | 
				
			||||||
    "Collector",
 | 
					    "Collector",
 | 
				
			||||||
 | 
					    "console_main",
 | 
				
			||||||
    "deprecated_call",
 | 
					    "deprecated_call",
 | 
				
			||||||
    "exit",
 | 
					    "exit",
 | 
				
			||||||
    "ExitCode",
 | 
					    "ExitCode",
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -4,4 +4,4 @@ pytest entry point
 | 
				
			||||||
import pytest
 | 
					import pytest
 | 
				
			||||||
 | 
					
 | 
				
			||||||
if __name__ == "__main__":
 | 
					if __name__ == "__main__":
 | 
				
			||||||
    raise SystemExit(pytest.main())
 | 
					    raise SystemExit(pytest.console_main())
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -11,6 +11,7 @@ import pytest
 | 
				
			||||||
from _pytest.compat import importlib_metadata
 | 
					from _pytest.compat import importlib_metadata
 | 
				
			||||||
from _pytest.config import ExitCode
 | 
					from _pytest.config import ExitCode
 | 
				
			||||||
from _pytest.monkeypatch import MonkeyPatch
 | 
					from _pytest.monkeypatch import MonkeyPatch
 | 
				
			||||||
 | 
					from _pytest.pytester import Testdir
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def prepend_pythonpath(*dirs):
 | 
					def prepend_pythonpath(*dirs):
 | 
				
			||||||
| 
						 | 
					@ -1343,3 +1344,21 @@ def test_tee_stdio_captures_and_live_prints(testdir):
 | 
				
			||||||
        fullXml = f.read()
 | 
					        fullXml = f.read()
 | 
				
			||||||
    assert "@this is stdout@\n" in fullXml
 | 
					    assert "@this is stdout@\n" in fullXml
 | 
				
			||||||
    assert "@this is stderr@\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
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
		Loading…
	
		Reference in New Issue