#4597: tee-stdio capture method
This commit is contained in:
		
						commit
						1ef29ab548
					
				
							
								
								
									
										1
									
								
								AUTHORS
								
								
								
								
							
							
						
						
									
										1
									
								
								AUTHORS
								
								
								
								
							| 
						 | 
					@ -52,6 +52,7 @@ Carl Friedrich Bolz
 | 
				
			||||||
Carlos Jenkins
 | 
					Carlos Jenkins
 | 
				
			||||||
Ceridwen
 | 
					Ceridwen
 | 
				
			||||||
Charles Cloud
 | 
					Charles Cloud
 | 
				
			||||||
 | 
					Charles Machalow
 | 
				
			||||||
Charnjit SiNGH (CCSJ)
 | 
					Charnjit SiNGH (CCSJ)
 | 
				
			||||||
Chris Lamb
 | 
					Chris Lamb
 | 
				
			||||||
Christian Boelsen
 | 
					Christian Boelsen
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1 @@
 | 
				
			||||||
 | 
					New :ref:`--capture=tee-sys <capture-method>` option to allow both live printing and capturing of test output.
 | 
				
			||||||
| 
						 | 
					@ -21,27 +21,36 @@ file descriptors.  This allows to capture output from simple
 | 
				
			||||||
print statements as well as output from a subprocess started by
 | 
					print statements as well as output from a subprocess started by
 | 
				
			||||||
a test.
 | 
					a test.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.. _capture-method:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
Setting capturing methods or disabling capturing
 | 
					Setting capturing methods or disabling capturing
 | 
				
			||||||
-------------------------------------------------
 | 
					-------------------------------------------------
 | 
				
			||||||
 | 
					
 | 
				
			||||||
There are two ways in which ``pytest`` can perform capturing:
 | 
					There are three ways in which ``pytest`` can perform capturing:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
* file descriptor (FD) level capturing (default): All writes going to the
 | 
					* ``fd`` (file descriptor) level capturing (default): All writes going to the
 | 
				
			||||||
  operating system file descriptors 1 and 2 will be captured.
 | 
					  operating system file descriptors 1 and 2 will be captured.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
* ``sys`` level capturing: Only writes to Python files ``sys.stdout``
 | 
					* ``sys`` level capturing: Only writes to Python files ``sys.stdout``
 | 
				
			||||||
  and ``sys.stderr`` will be captured.  No capturing of writes to
 | 
					  and ``sys.stderr`` will be captured.  No capturing of writes to
 | 
				
			||||||
  filedescriptors is performed.
 | 
					  filedescriptors is performed.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					* ``tee-sys`` capturing: Python writes to ``sys.stdout`` and ``sys.stderr``
 | 
				
			||||||
 | 
					  will be captured, however the writes will also be passed-through to
 | 
				
			||||||
 | 
					  the actual ``sys.stdout`` and ``sys.stderr``. This allows output to be
 | 
				
			||||||
 | 
					  'live printed' and captured for plugin use, such as junitxml (new in pytest 5.4).
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.. _`disable capturing`:
 | 
					.. _`disable capturing`:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
You can influence output capturing mechanisms from the command line:
 | 
					You can influence output capturing mechanisms from the command line:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.. code-block:: bash
 | 
					.. code-block:: bash
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    pytest -s            # disable all capturing
 | 
					    pytest -s                  # disable all capturing
 | 
				
			||||||
    pytest --capture=sys # replace sys.stdout/stderr with in-mem files
 | 
					    pytest --capture=sys       # replace sys.stdout/stderr with in-mem files
 | 
				
			||||||
    pytest --capture=fd  # also point filedescriptors 1 and 2 to temp file
 | 
					    pytest --capture=fd        # also point filedescriptors 1 and 2 to temp file
 | 
				
			||||||
 | 
					    pytest --capture=tee-sys   # combines 'sys' and '-s', capturing sys.stdout/stderr
 | 
				
			||||||
 | 
					                               # and passing it along to the actual sys.stdout/stderr
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.. _printdebugging:
 | 
					.. _printdebugging:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -11,6 +11,7 @@ from io import UnsupportedOperation
 | 
				
			||||||
from tempfile import TemporaryFile
 | 
					from tempfile import TemporaryFile
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import pytest
 | 
					import pytest
 | 
				
			||||||
 | 
					from _pytest.compat import CaptureAndPassthroughIO
 | 
				
			||||||
from _pytest.compat import CaptureIO
 | 
					from _pytest.compat import CaptureIO
 | 
				
			||||||
from _pytest.fixtures import FixtureRequest
 | 
					from _pytest.fixtures import FixtureRequest
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -24,8 +25,8 @@ def pytest_addoption(parser):
 | 
				
			||||||
        action="store",
 | 
					        action="store",
 | 
				
			||||||
        default="fd" if hasattr(os, "dup") else "sys",
 | 
					        default="fd" if hasattr(os, "dup") else "sys",
 | 
				
			||||||
        metavar="method",
 | 
					        metavar="method",
 | 
				
			||||||
        choices=["fd", "sys", "no"],
 | 
					        choices=["fd", "sys", "no", "tee-sys"],
 | 
				
			||||||
        help="per-test capturing method: one of fd|sys|no.",
 | 
					        help="per-test capturing method: one of fd|sys|no|tee-sys.",
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
    group._addoption(
 | 
					    group._addoption(
 | 
				
			||||||
        "-s",
 | 
					        "-s",
 | 
				
			||||||
| 
						 | 
					@ -90,6 +91,8 @@ class CaptureManager:
 | 
				
			||||||
            return MultiCapture(out=True, err=True, Capture=SysCapture)
 | 
					            return MultiCapture(out=True, err=True, Capture=SysCapture)
 | 
				
			||||||
        elif method == "no":
 | 
					        elif method == "no":
 | 
				
			||||||
            return MultiCapture(out=False, err=False, in_=False)
 | 
					            return MultiCapture(out=False, err=False, in_=False)
 | 
				
			||||||
 | 
					        elif method == "tee-sys":
 | 
				
			||||||
 | 
					            return MultiCapture(out=True, err=True, in_=False, Capture=TeeSysCapture)
 | 
				
			||||||
        raise ValueError("unknown capturing method: %r" % method)  # pragma: no cover
 | 
					        raise ValueError("unknown capturing method: %r" % method)  # pragma: no cover
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def is_capturing(self):
 | 
					    def is_capturing(self):
 | 
				
			||||||
| 
						 | 
					@ -681,6 +684,19 @@ class SysCapture:
 | 
				
			||||||
        self._old.flush()
 | 
					        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 = CaptureAndPassthroughIO(self._old)
 | 
				
			||||||
 | 
					        self.tmpfile = tmpfile
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class SysCaptureBinary(SysCapture):
 | 
					class SysCaptureBinary(SysCapture):
 | 
				
			||||||
    # Ignore type because it doesn't match the type in the superclass (str).
 | 
					    # Ignore type because it doesn't match the type in the superclass (str).
 | 
				
			||||||
    EMPTY_BUFFER = b""  # type: ignore
 | 
					    EMPTY_BUFFER = b""  # type: ignore
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -13,6 +13,7 @@ from inspect import signature
 | 
				
			||||||
from typing import Any
 | 
					from typing import Any
 | 
				
			||||||
from typing import Callable
 | 
					from typing import Callable
 | 
				
			||||||
from typing import Generic
 | 
					from typing import Generic
 | 
				
			||||||
 | 
					from typing import IO
 | 
				
			||||||
from typing import Optional
 | 
					from typing import Optional
 | 
				
			||||||
from typing import overload
 | 
					from typing import overload
 | 
				
			||||||
from typing import Tuple
 | 
					from typing import Tuple
 | 
				
			||||||
| 
						 | 
					@ -371,6 +372,16 @@ class CaptureIO(io.TextIOWrapper):
 | 
				
			||||||
        return self.buffer.getvalue().decode("UTF-8")
 | 
					        return self.buffer.getvalue().decode("UTF-8")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class CaptureAndPassthroughIO(CaptureIO):
 | 
				
			||||||
 | 
					    def __init__(self, other: IO) -> None:
 | 
				
			||||||
 | 
					        self._other = other
 | 
				
			||||||
 | 
					        super().__init__()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def write(self, s) -> int:
 | 
				
			||||||
 | 
					        super().write(s)
 | 
				
			||||||
 | 
					        return self._other.write(s)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
if sys.version_info < (3, 5, 2):  # pragma: no cover
 | 
					if sys.version_info < (3, 5, 2):  # pragma: no cover
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def overload(f):  # noqa: F811
 | 
					    def overload(f):  # noqa: F811
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1285,3 +1285,28 @@ def test_pdb_can_be_rewritten(testdir):
 | 
				
			||||||
        ]
 | 
					        ]
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
    assert result.ret == 1
 | 
					    assert result.ret == 1
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def test_tee_stdio_captures_and_live_prints(testdir):
 | 
				
			||||||
 | 
					    testpath = testdir.makepyfile(
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        import sys
 | 
				
			||||||
 | 
					        def test_simple():
 | 
				
			||||||
 | 
					            print ("@this is stdout@")
 | 
				
			||||||
 | 
					            print ("@this is stderr@", file=sys.stderr)
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    result = testdir.runpytest_subprocess(
 | 
				
			||||||
 | 
					        testpath, "--capture=tee-sys", "--junitxml=output.xml"
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # ensure stdout/stderr were 'live printed'
 | 
				
			||||||
 | 
					    result.stdout.fnmatch_lines(["*@this is stdout@*"])
 | 
				
			||||||
 | 
					    result.stderr.fnmatch_lines(["*@this is stderr@*"])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # now ensure the output is in the junitxml
 | 
				
			||||||
 | 
					    with open(os.path.join(testdir.tmpdir.strpath, "output.xml"), "r") as f:
 | 
				
			||||||
 | 
					        fullXml = f.read()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    assert "<system-out>@this is stdout@\n</system-out>" in fullXml
 | 
				
			||||||
 | 
					    assert "<system-err>@this is stderr@\n</system-err>" in fullXml
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -32,6 +32,10 @@ def StdCapture(out=True, err=True, in_=True):
 | 
				
			||||||
    return capture.MultiCapture(out, err, in_, Capture=capture.SysCapture)
 | 
					    return capture.MultiCapture(out, err, in_, Capture=capture.SysCapture)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def TeeStdCapture(out=True, err=True, in_=True):
 | 
				
			||||||
 | 
					    return capture.MultiCapture(out, err, in_, Capture=capture.TeeSysCapture)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class TestCaptureManager:
 | 
					class TestCaptureManager:
 | 
				
			||||||
    def test_getmethod_default_no_fd(self, monkeypatch):
 | 
					    def test_getmethod_default_no_fd(self, monkeypatch):
 | 
				
			||||||
        from _pytest.capture import pytest_addoption
 | 
					        from _pytest.capture import pytest_addoption
 | 
				
			||||||
| 
						 | 
					@ -816,6 +820,25 @@ class TestCaptureIO:
 | 
				
			||||||
        assert f.getvalue() == "foo\r\n"
 | 
					        assert f.getvalue() == "foo\r\n"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class TestCaptureAndPassthroughIO(TestCaptureIO):
 | 
				
			||||||
 | 
					    def test_text(self):
 | 
				
			||||||
 | 
					        sio = io.StringIO()
 | 
				
			||||||
 | 
					        f = capture.CaptureAndPassthroughIO(sio)
 | 
				
			||||||
 | 
					        f.write("hello")
 | 
				
			||||||
 | 
					        s1 = f.getvalue()
 | 
				
			||||||
 | 
					        assert s1 == "hello"
 | 
				
			||||||
 | 
					        s2 = sio.getvalue()
 | 
				
			||||||
 | 
					        assert s2 == s1
 | 
				
			||||||
 | 
					        f.close()
 | 
				
			||||||
 | 
					        sio.close()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_unicode_and_str_mixture(self):
 | 
				
			||||||
 | 
					        sio = io.StringIO()
 | 
				
			||||||
 | 
					        f = capture.CaptureAndPassthroughIO(sio)
 | 
				
			||||||
 | 
					        f.write("\u00f6")
 | 
				
			||||||
 | 
					        pytest.raises(TypeError, f.write, b"hello")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def test_dontreadfrominput():
 | 
					def test_dontreadfrominput():
 | 
				
			||||||
    from _pytest.capture import DontReadFromInput
 | 
					    from _pytest.capture import DontReadFromInput
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1112,6 +1135,23 @@ class TestStdCapture:
 | 
				
			||||||
            pytest.raises(IOError, sys.stdin.read)
 | 
					            pytest.raises(IOError, sys.stdin.read)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class TestTeeStdCapture(TestStdCapture):
 | 
				
			||||||
 | 
					    captureclass = staticmethod(TeeStdCapture)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_capturing_error_recursive(self):
 | 
				
			||||||
 | 
					        """ for TeeStdCapture since we passthrough stderr/stdout, cap1
 | 
				
			||||||
 | 
					        should get all output, while cap2 should only get "cap2\n" """
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        with self.getcapture() as cap1:
 | 
				
			||||||
 | 
					            print("cap1")
 | 
				
			||||||
 | 
					            with self.getcapture() as cap2:
 | 
				
			||||||
 | 
					                print("cap2")
 | 
				
			||||||
 | 
					                out2, err2 = cap2.readouterr()
 | 
				
			||||||
 | 
					                out1, err1 = cap1.readouterr()
 | 
				
			||||||
 | 
					        assert out1 == "cap1\ncap2\n"
 | 
				
			||||||
 | 
					        assert out2 == "cap2\n"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class TestStdCaptureFD(TestStdCapture):
 | 
					class TestStdCaptureFD(TestStdCapture):
 | 
				
			||||||
    pytestmark = needsosdup
 | 
					    pytestmark = needsosdup
 | 
				
			||||||
    captureclass = staticmethod(StdCaptureFD)
 | 
					    captureclass = staticmethod(StdCaptureFD)
 | 
				
			||||||
| 
						 | 
					@ -1252,7 +1292,7 @@ def test_close_and_capture_again(testdir):
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@pytest.mark.parametrize("method", ["SysCapture", "FDCapture"])
 | 
					@pytest.mark.parametrize("method", ["SysCapture", "FDCapture", "TeeSysCapture"])
 | 
				
			||||||
def test_capturing_and_logging_fundamentals(testdir, method):
 | 
					def test_capturing_and_logging_fundamentals(testdir, method):
 | 
				
			||||||
    if method == "StdCaptureFD" and not hasattr(os, "dup"):
 | 
					    if method == "StdCaptureFD" and not hasattr(os, "dup"):
 | 
				
			||||||
        pytest.skip("need os.dup")
 | 
					        pytest.skip("need os.dup")
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
		Loading…
	
		Reference in New Issue