Skip to content

Commit 1ef29ab

Browse files
authored
#4597: tee-stdio capture method (#6315)
#4597: tee-stdio capture method
2 parents 30f2729 + e13ad22 commit 1ef29ab

File tree

7 files changed

+111
-8
lines changed

7 files changed

+111
-8
lines changed

AUTHORS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ Carl Friedrich Bolz
5252
Carlos Jenkins
5353
Ceridwen
5454
Charles Cloud
55+
Charles Machalow
5556
Charnjit SiNGH (CCSJ)
5657
Chris Lamb
5758
Christian Boelsen

changelog/4597.feature.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
New :ref:`--capture=tee-sys <capture-method>` option to allow both live printing and capturing of test output.

doc/en/capture.rst

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,27 +21,36 @@ file descriptors. This allows to capture output from simple
2121
print statements as well as output from a subprocess started by
2222
a test.
2323

24+
.. _capture-method:
25+
2426
Setting capturing methods or disabling capturing
2527
-------------------------------------------------
2628

27-
There are two ways in which ``pytest`` can perform capturing:
29+
There are three ways in which ``pytest`` can perform capturing:
2830

29-
* file descriptor (FD) level capturing (default): All writes going to the
31+
* ``fd`` (file descriptor) level capturing (default): All writes going to the
3032
operating system file descriptors 1 and 2 will be captured.
3133

3234
* ``sys`` level capturing: Only writes to Python files ``sys.stdout``
3335
and ``sys.stderr`` will be captured. No capturing of writes to
3436
filedescriptors is performed.
3537

38+
* ``tee-sys`` capturing: Python writes to ``sys.stdout`` and ``sys.stderr``
39+
will be captured, however the writes will also be passed-through to
40+
the actual ``sys.stdout`` and ``sys.stderr``. This allows output to be
41+
'live printed' and captured for plugin use, such as junitxml (new in pytest 5.4).
42+
3643
.. _`disable capturing`:
3744

3845
You can influence output capturing mechanisms from the command line:
3946

4047
.. code-block:: bash
4148
42-
pytest -s # disable all capturing
43-
pytest --capture=sys # replace sys.stdout/stderr with in-mem files
44-
pytest --capture=fd # also point filedescriptors 1 and 2 to temp file
49+
pytest -s # disable all capturing
50+
pytest --capture=sys # replace sys.stdout/stderr with in-mem files
51+
pytest --capture=fd # also point filedescriptors 1 and 2 to temp file
52+
pytest --capture=tee-sys # combines 'sys' and '-s', capturing sys.stdout/stderr
53+
# and passing it along to the actual sys.stdout/stderr
4554
4655
.. _printdebugging:
4756

src/_pytest/capture.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from tempfile import TemporaryFile
1212

1313
import pytest
14+
from _pytest.compat import CaptureAndPassthroughIO
1415
from _pytest.compat import CaptureIO
1516
from _pytest.fixtures import FixtureRequest
1617

@@ -24,8 +25,8 @@ def pytest_addoption(parser):
2425
action="store",
2526
default="fd" if hasattr(os, "dup") else "sys",
2627
metavar="method",
27-
choices=["fd", "sys", "no"],
28-
help="per-test capturing method: one of fd|sys|no.",
28+
choices=["fd", "sys", "no", "tee-sys"],
29+
help="per-test capturing method: one of fd|sys|no|tee-sys.",
2930
)
3031
group._addoption(
3132
"-s",
@@ -90,6 +91,8 @@ def _getcapture(self, method):
9091
return MultiCapture(out=True, err=True, Capture=SysCapture)
9192
elif method == "no":
9293
return MultiCapture(out=False, err=False, in_=False)
94+
elif method == "tee-sys":
95+
return MultiCapture(out=True, err=True, in_=False, Capture=TeeSysCapture)
9396
raise ValueError("unknown capturing method: %r" % method) # pragma: no cover
9497

9598
def is_capturing(self):
@@ -681,6 +684,19 @@ def writeorg(self, data):
681684
self._old.flush()
682685

683686

687+
class TeeSysCapture(SysCapture):
688+
def __init__(self, fd, tmpfile=None):
689+
name = patchsysdict[fd]
690+
self._old = getattr(sys, name)
691+
self.name = name
692+
if tmpfile is None:
693+
if name == "stdin":
694+
tmpfile = DontReadFromInput()
695+
else:
696+
tmpfile = CaptureAndPassthroughIO(self._old)
697+
self.tmpfile = tmpfile
698+
699+
684700
class SysCaptureBinary(SysCapture):
685701
# Ignore type because it doesn't match the type in the superclass (str).
686702
EMPTY_BUFFER = b"" # type: ignore

src/_pytest/compat.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from typing import Any
1414
from typing import Callable
1515
from typing import Generic
16+
from typing import IO
1617
from typing import Optional
1718
from typing import overload
1819
from typing import Tuple
@@ -371,6 +372,16 @@ def getvalue(self) -> str:
371372
return self.buffer.getvalue().decode("UTF-8")
372373

373374

375+
class CaptureAndPassthroughIO(CaptureIO):
376+
def __init__(self, other: IO) -> None:
377+
self._other = other
378+
super().__init__()
379+
380+
def write(self, s) -> int:
381+
super().write(s)
382+
return self._other.write(s)
383+
384+
374385
if sys.version_info < (3, 5, 2): # pragma: no cover
375386

376387
def overload(f): # noqa: F811

testing/acceptance_test.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1285,3 +1285,28 @@ def test():
12851285
]
12861286
)
12871287
assert result.ret == 1
1288+
1289+
1290+
def test_tee_stdio_captures_and_live_prints(testdir):
1291+
testpath = testdir.makepyfile(
1292+
"""
1293+
import sys
1294+
def test_simple():
1295+
print ("@this is stdout@")
1296+
print ("@this is stderr@", file=sys.stderr)
1297+
"""
1298+
)
1299+
result = testdir.runpytest_subprocess(
1300+
testpath, "--capture=tee-sys", "--junitxml=output.xml"
1301+
)
1302+
1303+
# ensure stdout/stderr were 'live printed'
1304+
result.stdout.fnmatch_lines(["*@this is stdout@*"])
1305+
result.stderr.fnmatch_lines(["*@this is stderr@*"])
1306+
1307+
# now ensure the output is in the junitxml
1308+
with open(os.path.join(testdir.tmpdir.strpath, "output.xml"), "r") as f:
1309+
fullXml = f.read()
1310+
1311+
assert "<system-out>@this is stdout@\n</system-out>" in fullXml
1312+
assert "<system-err>@this is stderr@\n</system-err>" in fullXml

testing/test_capture.py

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ def StdCapture(out=True, err=True, in_=True):
3232
return capture.MultiCapture(out, err, in_, Capture=capture.SysCapture)
3333

3434

35+
def TeeStdCapture(out=True, err=True, in_=True):
36+
return capture.MultiCapture(out, err, in_, Capture=capture.TeeSysCapture)
37+
38+
3539
class TestCaptureManager:
3640
def test_getmethod_default_no_fd(self, monkeypatch):
3741
from _pytest.capture import pytest_addoption
@@ -816,6 +820,25 @@ def test_write_bytes_to_buffer(self):
816820
assert f.getvalue() == "foo\r\n"
817821

818822

823+
class TestCaptureAndPassthroughIO(TestCaptureIO):
824+
def test_text(self):
825+
sio = io.StringIO()
826+
f = capture.CaptureAndPassthroughIO(sio)
827+
f.write("hello")
828+
s1 = f.getvalue()
829+
assert s1 == "hello"
830+
s2 = sio.getvalue()
831+
assert s2 == s1
832+
f.close()
833+
sio.close()
834+
835+
def test_unicode_and_str_mixture(self):
836+
sio = io.StringIO()
837+
f = capture.CaptureAndPassthroughIO(sio)
838+
f.write("\u00f6")
839+
pytest.raises(TypeError, f.write, b"hello")
840+
841+
819842
def test_dontreadfrominput():
820843
from _pytest.capture import DontReadFromInput
821844

@@ -1112,6 +1135,23 @@ def test_stdin_nulled_by_default(self):
11121135
pytest.raises(IOError, sys.stdin.read)
11131136

11141137

1138+
class TestTeeStdCapture(TestStdCapture):
1139+
captureclass = staticmethod(TeeStdCapture)
1140+
1141+
def test_capturing_error_recursive(self):
1142+
""" for TeeStdCapture since we passthrough stderr/stdout, cap1
1143+
should get all output, while cap2 should only get "cap2\n" """
1144+
1145+
with self.getcapture() as cap1:
1146+
print("cap1")
1147+
with self.getcapture() as cap2:
1148+
print("cap2")
1149+
out2, err2 = cap2.readouterr()
1150+
out1, err1 = cap1.readouterr()
1151+
assert out1 == "cap1\ncap2\n"
1152+
assert out2 == "cap2\n"
1153+
1154+
11151155
class TestStdCaptureFD(TestStdCapture):
11161156
pytestmark = needsosdup
11171157
captureclass = staticmethod(StdCaptureFD)
@@ -1252,7 +1292,7 @@ def test_capture_again():
12521292
)
12531293

12541294

1255-
@pytest.mark.parametrize("method", ["SysCapture", "FDCapture"])
1295+
@pytest.mark.parametrize("method", ["SysCapture", "FDCapture", "TeeSysCapture"])
12561296
def test_capturing_and_logging_fundamentals(testdir, method):
12571297
if method == "StdCaptureFD" and not hasattr(os, "dup"):
12581298
pytest.skip("need os.dup")

0 commit comments

Comments
 (0)