Skip to content

Commit 2ba6c66

Browse files
committed
Preserve chronological order of stdout and stderr with capsys.
1 parent acec0b6 commit 2ba6c66

File tree

4 files changed

+160
-3
lines changed

4 files changed

+160
-3
lines changed

changelog/5449.feature.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Allow ``capsys`` to retrieve combined stdout + stderr streams with original order of messages.

doc/en/capture.rst

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,3 +170,40 @@ as a context manager, disabling capture inside the ``with`` block:
170170
print("this output is also captured")
171171
172172
.. include:: links.inc
173+
174+
Preserving streams order
175+
------------------------
176+
177+
By default, the ``readouterr()`` method returns two values - one for ``stdout`` and one for ``stderr``. This
178+
can be problematic if you want to ensure that all messages were in correct order regardless of the stream.
179+
When using ``capsys`` you can use ``combine=True`` to have both streams returned with the chronological order
180+
of messages preserved. The output will still be a tuple, but the second argument will be ``None``.
181+
182+
.. code-block:: python
183+
184+
def test_combine(capsys):
185+
print("I'm in stdout")
186+
print("I'm in stderr", file=sys.stderr)
187+
print("Hey, stdout again!")
188+
189+
out, err = capsys.readouterr(combined=True)
190+
191+
assert out == "I'm in stdout\nI'm in stderr\nHey, stdout again!\n"
192+
assert err is None
193+
194+
If you care about correct order and want to make sure that certain message went into correct stream, you can
195+
use ``flush=False`` with ``readouterr()`` so you will be able to retrieve streams twice:
196+
197+
.. code-block:: python
198+
199+
def test_no_flush(capsys):
200+
print("I'm in stdout")
201+
print("I'm in stderr", file=sys.stderr)
202+
print("Hey, stdout again!")
203+
204+
out, err = capsys.readouterr(flush=False)
205+
combined, _ = capsys.readouterr(combined=True)
206+
207+
assert out == "I'm in stdout\nHey, stdout again!\n"
208+
assert err == "I'm in stderr\n"
209+
assert combined == "I'm in stdout\nI'm in stderr\nHey, stdout again!\n"

src/_pytest/capture.py

Lines changed: 80 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -358,11 +358,17 @@ def close(self):
358358
self._capture.stop_capturing()
359359
self._capture = None
360360

361-
def readouterr(self):
361+
def readouterr(self, combined: bool = False, flush: bool = True) -> "CaptureResult":
362362
"""Read and return the captured output so far, resetting the internal buffer.
363363
364364
:return: captured content as a namedtuple with ``out`` and ``err`` string attributes
365365
"""
366+
if flush is False:
367+
if self.captureclass is not OrderedCapture:
368+
raise AttributeError("Only capsys can read streams without flushing.")
369+
OrderedCapture.set_flush(False)
370+
if combined:
371+
return CaptureResult(self._get_combined(), None)
366372
captured_out, captured_err = self._captured_out, self._captured_err
367373
if self._capture is not None:
368374
out, err = self._capture.readouterr()
@@ -372,6 +378,13 @@ def readouterr(self):
372378
self._captured_err = self.captureclass.EMPTY_BUFFER
373379
return CaptureResult(captured_out, captured_err)
374380

381+
def _get_combined(self):
382+
if self.captureclass is not OrderedCapture:
383+
raise AttributeError("Only capsys is able to combine streams.")
384+
result = "".join(line[0] for line in OrderedCapture.streams)
385+
OrderedCapture.flush()
386+
return result
387+
375388
def _suspend(self):
376389
"""Suspends this fixture's own capturing temporarily."""
377390
if self._capture is not None:
@@ -637,13 +650,17 @@ def __init__(self, fd, tmpfile=None):
637650
name = patchsysdict[fd]
638651
self._old = getattr(sys, name)
639652
self.name = name
653+
self.fd = fd
640654
if tmpfile is None:
641655
if name == "stdin":
642656
tmpfile = DontReadFromInput()
643657
else:
644-
tmpfile = CaptureIO()
658+
tmpfile = self._get_writer()
645659
self.tmpfile = tmpfile
646660

661+
def _get_writer(self):
662+
return CaptureIO()
663+
647664
def __repr__(self):
648665
return "<{} {} _old={} _state={!r} tmpfile={!r}>".format(
649666
self.__class__.__name__,
@@ -705,14 +722,74 @@ def __init__(self, fd, tmpfile=None):
705722
self.tmpfile = tmpfile
706723

707724

725+
class OrderedCapture(SysCapture):
726+
"""Capture class that keeps streams in order."""
727+
728+
streams = collections.deque() # type: collections.deque
729+
_flush = True
730+
731+
def _get_writer(self):
732+
return OrderedWriter(self.fd)
733+
734+
def snap(self):
735+
res = self.tmpfile.getvalue()
736+
if self.name == "stderr":
737+
# both streams are being read one after another, while stderr is last - it will clear the queue
738+
self.flush()
739+
return res
740+
741+
@classmethod
742+
def set_flush(cls, flush: bool) -> None:
743+
cls._flush = flush
744+
745+
@classmethod
746+
def flush(cls) -> None:
747+
"""Clear streams."""
748+
if cls._flush is False:
749+
cls.set_flush(True)
750+
else:
751+
cls.streams.clear()
752+
753+
@classmethod
754+
def close(cls) -> None:
755+
cls.set_flush(True)
756+
cls.flush()
757+
758+
708759
map_fixname_class = {
709760
"capfd": FDCapture,
710761
"capfdbinary": FDCaptureBinary,
711-
"capsys": SysCapture,
762+
"capsys": OrderedCapture,
712763
"capsysbinary": SysCaptureBinary,
713764
}
714765

715766

767+
class OrderedWriter:
768+
encoding = sys.getdefaultencoding()
769+
770+
def __init__(self, fd: int) -> None:
771+
super().__init__()
772+
self._fd = fd # type: int
773+
774+
def write(self, text: str, **kwargs) -> int:
775+
OrderedCapture.streams.append((text, self._fd))
776+
return len(text)
777+
778+
def getvalue(self) -> str:
779+
return "".join(
780+
line[0] for line in OrderedCapture.streams if line[1] == self._fd
781+
)
782+
783+
def flush(self) -> None:
784+
pass
785+
786+
def isatty(self) -> bool:
787+
return False
788+
789+
def close(self) -> None:
790+
OrderedCapture.close()
791+
792+
716793
class DontReadFromInput:
717794
encoding = None
718795

testing/test_capture.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1572,3 +1572,45 @@ def test__get_multicapture() -> None:
15721572
pytest.raises(ValueError, _get_multicapture, "unknown").match(
15731573
r"^unknown capturing method: 'unknown'"
15741574
)
1575+
1576+
1577+
def test_combined_streams(capsys):
1578+
"""Show that capsys is capable of preserving chronological order of streams."""
1579+
print("stdout1")
1580+
print("stdout2")
1581+
print("stderr1", file=sys.stderr)
1582+
print("stdout3")
1583+
print("stderr2", file=sys.stderr)
1584+
print("stderr3", file=sys.stderr)
1585+
print("stdout4")
1586+
print("stdout5")
1587+
1588+
# this won't clear streams
1589+
out, err = capsys.readouterr(flush=False)
1590+
assert "stdout1" in out
1591+
assert "stdout1" not in err
1592+
assert "stderr1" in err
1593+
assert "stderr1" not in out
1594+
1595+
out, err = capsys.readouterr(combined=True)
1596+
assert (
1597+
out == "stdout1\n"
1598+
"stdout2\n"
1599+
"stderr1\n"
1600+
"stdout3\n"
1601+
"stderr2\n"
1602+
"stderr3\n"
1603+
"stdout4\n"
1604+
"stdout5\n"
1605+
)
1606+
assert err is None
1607+
1608+
1609+
def test_no_capsys_exceptions(capsysbinary):
1610+
with pytest.raises(AttributeError, match="Only capsys is able to combine streams."):
1611+
capsysbinary.readouterr(combined=True)
1612+
1613+
with pytest.raises(
1614+
AttributeError, match="Only capsys can read streams without flushing."
1615+
):
1616+
capsysbinary.readouterr(flush=False)

0 commit comments

Comments
 (0)