diff --git a/changelog/5449.feature.rst b/changelog/5449.feature.rst new file mode 100644 index 00000000000..84a8fb74db7 --- /dev/null +++ b/changelog/5449.feature.rst @@ -0,0 +1 @@ +Allow ``capsys`` to retrieve combined stdout + stderr streams with original order of messages. diff --git a/doc/en/capture.rst b/doc/en/capture.rst index 7c8c25cc5c9..836808032c3 100644 --- a/doc/en/capture.rst +++ b/doc/en/capture.rst @@ -170,3 +170,21 @@ as a context manager, disabling capture inside the ``with`` block: with capsys.disabled(): print("output not captured, going directly to sys.stdout") print("this output is also captured") + + +Preserving streams order +------------------------ + +The ``capsys`` fixture has an additional ``read_combined()`` method. This method returns single value +with both ``stdout`` and ``stderr`` streams combined with preserved chronological order. + +.. code-block:: python + + def test_combine(capsys): + print("I'm in stdout") + print("I'm in stderr", file=sys.stderr) + print("Hey, stdout again!") + + output = capsys.read_combined() + + assert output == "I'm in stdout\nI'm in stderr\nHey, stdout again!\n" diff --git a/src/_pytest/capture.py b/src/_pytest/capture.py index 7eafeb3e406..313dea8b5e8 100644 --- a/src/_pytest/capture.py +++ b/src/_pytest/capture.py @@ -366,7 +366,7 @@ def close(self): self._capture.stop_capturing() self._capture = None - def readouterr(self): + def readouterr(self) -> "CaptureResult": """Read and return the captured output so far, resetting the internal buffer. :return: captured content as a namedtuple with ``out`` and ``err`` string attributes @@ -380,6 +380,17 @@ def readouterr(self): self._captured_err = self.captureclass.EMPTY_BUFFER return CaptureResult(captured_out, captured_err) + def read_combined(self) -> str: + """ + Read captured output with both stdout and stderr streams combined, with preserving + the correct order of messages. + """ + if self.captureclass is not OrderedCapture: + raise AttributeError("Only capsys is able to combine streams.") + result = "".join(line[0] for line in OrderedCapture.streams) + OrderedCapture.flush() + return result + def _suspend(self): """Suspends this fixture's own capturing temporarily.""" if self._capture is not None: @@ -622,13 +633,17 @@ def __init__(self, fd, tmpfile=None): name = patchsysdict[fd] self._old = getattr(sys, name) self.name = name + self.fd = fd if tmpfile is None: if name == "stdin": tmpfile = DontReadFromInput() else: - tmpfile = CaptureIO() + tmpfile = self._get_writer() self.tmpfile = tmpfile + def _get_writer(self): + return CaptureIO() + def __repr__(self): return "<{} {} _old={} _state={!r} tmpfile={!r}>".format( self.__class__.__name__, @@ -695,14 +710,65 @@ def __init__(self, fd, tmpfile=None): self.tmpfile = tmpfile +class OrderedCapture(SysCapture): + """Capture class that keeps streams in order.""" + + streams = collections.deque() # type: collections.deque + + def _get_writer(self): + return OrderedWriter(self.fd) + + def snap(self): + res = self.tmpfile.getvalue() + if self.name == "stderr": + # both streams are being read one after another, while stderr is last - it will clear the queue + self.flush() + return res + + @classmethod + def flush(cls) -> None: + """Clear streams.""" + cls.streams.clear() + + @classmethod + def close(cls) -> None: + cls.flush() + + map_fixname_class = { "capfd": FDCapture, "capfdbinary": FDCaptureBinary, - "capsys": SysCapture, + "capsys": OrderedCapture, "capsysbinary": SysCaptureBinary, } +class OrderedWriter: + encoding = sys.getdefaultencoding() + + def __init__(self, fd: int) -> None: + super().__init__() + self._fd = fd # type: int + + def write(self, text: str, **kwargs) -> int: + OrderedCapture.streams.append((text, self._fd)) + return len(text) + + def getvalue(self) -> str: + return "".join( + line[0] for line in OrderedCapture.streams if line[1] == self._fd + ) + + def flush(self) -> None: + pass + + def isatty(self) -> bool: + return False + + def close(self) -> None: + OrderedCapture.close() + + class DontReadFromInput: encoding = None diff --git a/testing/test_capture.py b/testing/test_capture.py index c064614d238..2319e2a5a7e 100644 --- a/testing/test_capture.py +++ b/testing/test_capture.py @@ -1522,3 +1522,32 @@ def test_logging(): ) result.stdout.no_fnmatch_line("*Captured stderr call*") result.stdout.no_fnmatch_line("*during collection*") + + +def test_combined_streams(capsys): + """Show that capsys is capable of preserving chronological order of streams.""" + print("stdout1") + print("stdout2") + print("stderr1", file=sys.stderr) + print("stdout3") + print("stderr2", file=sys.stderr) + print("stderr3", file=sys.stderr) + print("stdout4") + print("stdout5") + + output = capsys.read_combined() + assert ( + output == "stdout1\n" + "stdout2\n" + "stderr1\n" + "stdout3\n" + "stderr2\n" + "stderr3\n" + "stdout4\n" + "stdout5\n" + ) + + +def test_no_capsys_exceptions(capfd): + with pytest.raises(AttributeError, match="Only capsys is able to combine streams."): + capfd.read_combined()