#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
 | 
			
		||||
Ceridwen
 | 
			
		||||
Charles Cloud
 | 
			
		||||
Charles Machalow
 | 
			
		||||
Charnjit SiNGH (CCSJ)
 | 
			
		||||
Chris Lamb
 | 
			
		||||
Christian Boelsen
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1 @@
 | 
			
		|||
New :ref:`--capture=tee-sys <capture-method>` option to allow both live printing and capturing of test output.
 | 
			
		||||
| 
						 | 
				
			
			@ -21,18 +21,25 @@ file descriptors.  This allows to capture output from simple
 | 
			
		|||
print statements as well as output from a subprocess started by
 | 
			
		||||
a test.
 | 
			
		||||
 | 
			
		||||
.. _capture-method:
 | 
			
		||||
 | 
			
		||||
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.
 | 
			
		||||
 | 
			
		||||
* ``sys`` level capturing: Only writes to Python files ``sys.stdout``
 | 
			
		||||
  and ``sys.stderr`` will be captured.  No capturing of writes to
 | 
			
		||||
  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`:
 | 
			
		||||
 | 
			
		||||
You can influence output capturing mechanisms from the command line:
 | 
			
		||||
| 
						 | 
				
			
			@ -42,6 +49,8 @@ You can influence output capturing mechanisms from the command line:
 | 
			
		|||
    pytest -s                  # disable all capturing
 | 
			
		||||
    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=tee-sys   # combines 'sys' and '-s', capturing sys.stdout/stderr
 | 
			
		||||
                               # and passing it along to the actual sys.stdout/stderr
 | 
			
		||||
 | 
			
		||||
.. _printdebugging:
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -11,6 +11,7 @@ from io import UnsupportedOperation
 | 
			
		|||
from tempfile import TemporaryFile
 | 
			
		||||
 | 
			
		||||
import pytest
 | 
			
		||||
from _pytest.compat import CaptureAndPassthroughIO
 | 
			
		||||
from _pytest.compat import CaptureIO
 | 
			
		||||
from _pytest.fixtures import FixtureRequest
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -24,8 +25,8 @@ def pytest_addoption(parser):
 | 
			
		|||
        action="store",
 | 
			
		||||
        default="fd" if hasattr(os, "dup") else "sys",
 | 
			
		||||
        metavar="method",
 | 
			
		||||
        choices=["fd", "sys", "no"],
 | 
			
		||||
        help="per-test capturing method: one of fd|sys|no.",
 | 
			
		||||
        choices=["fd", "sys", "no", "tee-sys"],
 | 
			
		||||
        help="per-test capturing method: one of fd|sys|no|tee-sys.",
 | 
			
		||||
    )
 | 
			
		||||
    group._addoption(
 | 
			
		||||
        "-s",
 | 
			
		||||
| 
						 | 
				
			
			@ -90,6 +91,8 @@ class CaptureManager:
 | 
			
		|||
            return MultiCapture(out=True, err=True, Capture=SysCapture)
 | 
			
		||||
        elif method == "no":
 | 
			
		||||
            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
 | 
			
		||||
 | 
			
		||||
    def is_capturing(self):
 | 
			
		||||
| 
						 | 
				
			
			@ -681,6 +684,19 @@ class SysCapture:
 | 
			
		|||
        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):
 | 
			
		||||
    # Ignore type because it doesn't match the type in the superclass (str).
 | 
			
		||||
    EMPTY_BUFFER = b""  # type: ignore
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -13,6 +13,7 @@ from inspect import signature
 | 
			
		|||
from typing import Any
 | 
			
		||||
from typing import Callable
 | 
			
		||||
from typing import Generic
 | 
			
		||||
from typing import IO
 | 
			
		||||
from typing import Optional
 | 
			
		||||
from typing import overload
 | 
			
		||||
from typing import Tuple
 | 
			
		||||
| 
						 | 
				
			
			@ -371,6 +372,16 @@ class CaptureIO(io.TextIOWrapper):
 | 
			
		|||
        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
 | 
			
		||||
 | 
			
		||||
    def overload(f):  # noqa: F811
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1285,3 +1285,28 @@ def test_pdb_can_be_rewritten(testdir):
 | 
			
		|||
        ]
 | 
			
		||||
    )
 | 
			
		||||
    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)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def TeeStdCapture(out=True, err=True, in_=True):
 | 
			
		||||
    return capture.MultiCapture(out, err, in_, Capture=capture.TeeSysCapture)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TestCaptureManager:
 | 
			
		||||
    def test_getmethod_default_no_fd(self, monkeypatch):
 | 
			
		||||
        from _pytest.capture import pytest_addoption
 | 
			
		||||
| 
						 | 
				
			
			@ -816,6 +820,25 @@ class TestCaptureIO:
 | 
			
		|||
        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():
 | 
			
		||||
    from _pytest.capture import DontReadFromInput
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -1112,6 +1135,23 @@ class TestStdCapture:
 | 
			
		|||
            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):
 | 
			
		||||
    pytestmark = needsosdup
 | 
			
		||||
    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):
 | 
			
		||||
    if method == "StdCaptureFD" and not hasattr(os, "dup"):
 | 
			
		||||
        pytest.skip("need os.dup")
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
		Reference in New Issue