Skip to content

Commit bc1e5bd

Browse files
committed
Preserve chronological order of stdout and stderr with capsys.
1 parent cb06bc7 commit bc1e5bd

File tree

4 files changed

+161
-3
lines changed

4 files changed

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

351-
def readouterr(self):
351+
def readouterr(self, combined: bool = False, flush: bool = True) -> "CaptureResult":
352352
"""Read and return the captured output so far, resetting the internal buffer.
353353
354354
:return: captured content as a namedtuple with ``out`` and ``err`` string attributes
355355
"""
356+
if flush is False:
357+
if self.captureclass is not OrderedCapture:
358+
raise AttributeError("Only capsys can read streams without flushing.")
359+
OrderedCapture.set_flush(False)
360+
if combined:
361+
return CaptureResult(self._get_combined(), None)
356362
captured_out, captured_err = self._captured_out, self._captured_err
357363
if self._capture is not None:
358364
out, err = self._capture.readouterr()
@@ -362,6 +368,13 @@ def readouterr(self):
362368
self._captured_err = self.captureclass.EMPTY_BUFFER
363369
return CaptureResult(captured_out, captured_err)
364370

371+
def _get_combined(self):
372+
if self.captureclass is not OrderedCapture:
373+
raise AttributeError("Only capsys is able to combine streams.")
374+
result = "".join(line[0] for line in OrderedCapture.streams)
375+
OrderedCapture.flush()
376+
return result
377+
365378
def _suspend(self):
366379
"""Suspends this fixture's own capturing temporarily."""
367380
if self._capture is not None:
@@ -604,13 +617,17 @@ def __init__(self, fd, tmpfile=None):
604617
name = patchsysdict[fd]
605618
self._old = getattr(sys, name)
606619
self.name = name
620+
self.fd = fd
607621
if tmpfile is None:
608622
if name == "stdin":
609623
tmpfile = DontReadFromInput()
610624
else:
611-
tmpfile = CaptureIO()
625+
tmpfile = self._get_writer()
612626
self.tmpfile = tmpfile
613627

628+
def _get_writer(self):
629+
return CaptureIO()
630+
614631
def __repr__(self):
615632
return "<{} {} _old={} _state={!r} tmpfile={!r}>".format(
616633
self.__class__.__name__,
@@ -677,14 +694,74 @@ def __init__(self, fd, tmpfile=None):
677694
self.tmpfile = tmpfile
678695

679696

697+
class OrderedCapture(SysCapture):
698+
"""Capture class that keeps streams in order."""
699+
700+
streams = collections.deque() # type: collections.deque
701+
_flush = True
702+
703+
def _get_writer(self):
704+
return OrderedWriter(self.fd)
705+
706+
def snap(self):
707+
res = self.tmpfile.getvalue()
708+
if self.name == "stderr":
709+
# both streams are being read one after another, while stderr is last - it will clear the queue
710+
self.flush()
711+
return res
712+
713+
@classmethod
714+
def set_flush(cls, flush: bool) -> None:
715+
cls._flush = flush
716+
717+
@classmethod
718+
def flush(cls) -> None:
719+
"""Clear streams."""
720+
if cls._flush is False:
721+
cls.set_flush(True)
722+
else:
723+
cls.streams.clear()
724+
725+
@classmethod
726+
def close(cls) -> None:
727+
cls.set_flush(True)
728+
cls.flush()
729+
730+
680731
map_fixname_class = {
681732
"capfd": FDCapture,
682733
"capfdbinary": FDCaptureBinary,
683-
"capsys": SysCapture,
734+
"capsys": OrderedCapture,
684735
"capsysbinary": SysCaptureBinary,
685736
}
686737

687738

739+
class OrderedWriter:
740+
encoding = sys.getdefaultencoding()
741+
742+
def __init__(self, fd: int) -> None:
743+
super().__init__()
744+
self._fd = fd # type: int
745+
746+
def write(self, text: str, **kwargs) -> int:
747+
OrderedCapture.streams.append((text, self._fd))
748+
return len(text)
749+
750+
def getvalue(self) -> str:
751+
return "".join(
752+
line[0] for line in OrderedCapture.streams if line[1] == self._fd
753+
)
754+
755+
def flush(self) -> None:
756+
pass
757+
758+
def isatty(self) -> bool:
759+
return False
760+
761+
def close(self) -> None:
762+
OrderedCapture.close()
763+
764+
688765
class DontReadFromInput:
689766
encoding = None
690767

testing/test_capture.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1493,3 +1493,45 @@ def test__get_multicapture() -> None:
14931493
pytest.raises(ValueError, _get_multicapture, "unknown").match(
14941494
r"^unknown capturing method: 'unknown'"
14951495
)
1496+
1497+
1498+
def test_combined_streams(capsys):
1499+
"""Show that capsys is capable of preserving chronological order of streams."""
1500+
print("stdout1")
1501+
print("stdout2")
1502+
print("stderr1", file=sys.stderr)
1503+
print("stdout3")
1504+
print("stderr2", file=sys.stderr)
1505+
print("stderr3", file=sys.stderr)
1506+
print("stdout4")
1507+
print("stdout5")
1508+
1509+
# this won't clear streams
1510+
out, err = capsys.readouterr(flush=False)
1511+
assert "stdout1" in out
1512+
assert "stdout1" not in err
1513+
assert "stderr1" in err
1514+
assert "stderr1" not in out
1515+
1516+
out, err = capsys.readouterr(combined=True)
1517+
assert (
1518+
out == "stdout1\n"
1519+
"stdout2\n"
1520+
"stderr1\n"
1521+
"stdout3\n"
1522+
"stderr2\n"
1523+
"stderr3\n"
1524+
"stdout4\n"
1525+
"stdout5\n"
1526+
)
1527+
assert err is None
1528+
1529+
1530+
def test_no_capsys_exceptions(capfd):
1531+
with pytest.raises(AttributeError, match="Only capsys is able to combine streams."):
1532+
capfd.readouterr(combined=True)
1533+
1534+
with pytest.raises(
1535+
AttributeError, match="Only capsys can read streams without flushing."
1536+
):
1537+
capfd.readouterr(flush=False)

0 commit comments

Comments
 (0)