Skip to content

#4597: tee-stdio capture method #6315

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Dec 9, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AUTHORS
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ Carl Friedrich Bolz
Carlos Jenkins
Ceridwen
Charles Cloud
Charles Machalow
Charnjit SiNGH (CCSJ)
Chris Lamb
Christian Boelsen
Expand Down
1 change: 1 addition & 0 deletions changelog/4597.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
New :ref:`--capture=tee-sys <capture-method>` option to allow both live printing and capturing of test output.
19 changes: 14 additions & 5 deletions doc/en/capture.rst
Original file line number Diff line number Diff line change
Expand Up @@ -21,27 +21,36 @@ 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:

.. code-block:: bash

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

Expand Down
20 changes: 18 additions & 2 deletions src/_pytest/capture.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from tempfile import TemporaryFile

import pytest
from _pytest.compat import CaptureAndPassthroughIO
from _pytest.compat import CaptureIO
from _pytest.fixtures import FixtureRequest

Expand All @@ -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",
Expand Down Expand Up @@ -90,6 +91,8 @@ def _getcapture(self, method):
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):
Expand Down Expand Up @@ -681,6 +684,19 @@ def writeorg(self, data):
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
Expand Down
11 changes: 11 additions & 0 deletions src/_pytest/compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
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
Expand Down Expand Up @@ -371,6 +372,16 @@ def getvalue(self) -> str:
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
Expand Down
25 changes: 25 additions & 0 deletions testing/acceptance_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -1285,3 +1285,28 @@ def test():
]
)
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
42 changes: 41 additions & 1 deletion testing/test_capture.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -816,6 +820,25 @@ def test_write_bytes_to_buffer(self):
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

Expand Down Expand Up @@ -1112,6 +1135,23 @@ def test_stdin_nulled_by_default(self):
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)
Expand Down Expand Up @@ -1252,7 +1292,7 @@ def test_capture_again():
)


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