Include new --capture-mode=tee-sys option

Fix #4597
This commit is contained in:
cmachalo 2019-12-03 14:15:13 -08:00 committed by Bruno Oliveira
parent 30f2729684
commit e13ad22364
7 changed files with 111 additions and 8 deletions

View File

@ -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

View File

@ -0,0 +1 @@
New :ref:`--capture=tee-sys <capture-method>` option to allow both live printing and capturing of test output.

View File

@ -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:

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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")