Skip to content

Commit 4a1557f

Browse files
authored
Merge pull request #7091 from bluetech/capture-invalid-fd
Perform FD capturing even if the FD is invalid
2 parents 919ac22 + eaeafd7 commit 4a1557f

File tree

3 files changed

+95
-43
lines changed

3 files changed

+95
-43
lines changed

changelog/7091.improvement.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
When ``fd`` capturing is used, through ``--capture=fd`` or the ``capfd`` and
2+
``capfdbinary`` fixtures, and the file descriptor (0, 1, 2) cannot be
3+
duplicated, FD capturing is still performed. Previously, direct writes to the
4+
file descriptors would fail or be lost in this case.

src/_pytest/capture.py

Lines changed: 48 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -513,49 +513,57 @@ class FDCaptureBinary:
513513

514514
def __init__(self, targetfd, tmpfile=None):
515515
self.targetfd = targetfd
516+
516517
try:
517-
self.targetfd_save = os.dup(self.targetfd)
518+
os.fstat(targetfd)
518519
except OSError:
519-
self.start = lambda: None
520-
self.done = lambda: None
520+
# FD capturing is conceptually simple -- create a temporary file,
521+
# redirect the FD to it, redirect back when done. But when the
522+
# target FD is invalid it throws a wrench into this loveley scheme.
523+
#
524+
# Tests themselves shouldn't care if the FD is valid, FD capturing
525+
# should work regardless of external circumstances. So falling back
526+
# to just sys capturing is not a good option.
527+
#
528+
# Further complications are the need to support suspend() and the
529+
# possibility of FD reuse (e.g. the tmpfile getting the very same
530+
# target FD). The following approach is robust, I believe.
531+
self.targetfd_invalid = os.open(os.devnull, os.O_RDWR)
532+
os.dup2(self.targetfd_invalid, targetfd)
533+
else:
534+
self.targetfd_invalid = None
535+
self.targetfd_save = os.dup(targetfd)
536+
537+
if targetfd == 0:
538+
assert not tmpfile, "cannot set tmpfile with stdin"
539+
tmpfile = open(os.devnull)
540+
self.syscapture = SysCapture(targetfd)
521541
else:
522-
self.start = self._start
523-
self.done = self._done
524-
if targetfd == 0:
525-
assert not tmpfile, "cannot set tmpfile with stdin"
526-
tmpfile = open(os.devnull)
527-
self.syscapture = SysCapture(targetfd)
542+
if tmpfile is None:
543+
tmpfile = EncodedFile(
544+
TemporaryFile(buffering=0),
545+
encoding="utf-8",
546+
errors="replace",
547+
write_through=True,
548+
)
549+
if targetfd in patchsysdict:
550+
self.syscapture = SysCapture(targetfd, tmpfile)
528551
else:
529-
if tmpfile is None:
530-
tmpfile = EncodedFile(
531-
TemporaryFile(buffering=0),
532-
encoding="utf-8",
533-
errors="replace",
534-
write_through=True,
535-
)
536-
if targetfd in patchsysdict:
537-
self.syscapture = SysCapture(targetfd, tmpfile)
538-
else:
539-
self.syscapture = NoCapture()
540-
self.tmpfile = tmpfile
541-
self.tmpfile_fd = tmpfile.fileno()
552+
self.syscapture = NoCapture()
553+
self.tmpfile = tmpfile
542554

543555
def __repr__(self):
544-
return "<{} {} oldfd={} _state={!r} tmpfile={}>".format(
556+
return "<{} {} oldfd={} _state={!r} tmpfile={!r}>".format(
545557
self.__class__.__name__,
546558
self.targetfd,
547-
getattr(self, "targetfd_save", "<UNSET>"),
559+
self.targetfd_save,
548560
self._state,
549-
hasattr(self, "tmpfile") and repr(self.tmpfile) or "<UNSET>",
561+
self.tmpfile,
550562
)
551563

552-
def _start(self):
564+
def start(self):
553565
""" Start capturing on targetfd using memorized tmpfile. """
554-
try:
555-
os.fstat(self.targetfd_save)
556-
except (AttributeError, OSError):
557-
raise ValueError("saved filedescriptor not valid anymore")
558-
os.dup2(self.tmpfile_fd, self.targetfd)
566+
os.dup2(self.tmpfile.fileno(), self.targetfd)
559567
self.syscapture.start()
560568
self._state = "started"
561569

@@ -566,12 +574,15 @@ def snap(self):
566574
self.tmpfile.truncate()
567575
return res
568576

569-
def _done(self):
577+
def done(self):
570578
""" stop capturing, restore streams, return original capture file,
571579
seeked to position zero. """
572-
targetfd_save = self.__dict__.pop("targetfd_save")
573-
os.dup2(targetfd_save, self.targetfd)
574-
os.close(targetfd_save)
580+
os.dup2(self.targetfd_save, self.targetfd)
581+
os.close(self.targetfd_save)
582+
if self.targetfd_invalid is not None:
583+
if self.targetfd_invalid != self.targetfd:
584+
os.close(self.targetfd)
585+
os.close(self.targetfd_invalid)
575586
self.syscapture.done()
576587
self.tmpfile.close()
577588
self._state = "done"
@@ -583,7 +594,7 @@ def suspend(self):
583594

584595
def resume(self):
585596
self.syscapture.resume()
586-
os.dup2(self.tmpfile_fd, self.targetfd)
597+
os.dup2(self.tmpfile.fileno(), self.targetfd)
587598
self._state = "resumed"
588599

589600
def writeorg(self, data):
@@ -609,8 +620,7 @@ def snap(self):
609620

610621
def writeorg(self, data):
611622
""" write to original file descriptor. """
612-
data = data.encode("utf-8") # XXX use encoding of original stream
613-
os.write(self.targetfd_save, data)
623+
super().writeorg(data.encode("utf-8")) # XXX use encoding of original stream
614624

615625

616626
class SysCaptureBinary:

testing/test_capture.py

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -943,8 +943,8 @@ def test_simple_resume_suspend(self):
943943
pytest.raises(AttributeError, cap.suspend)
944944

945945
assert repr(cap) == (
946-
"<FDCapture 1 oldfd=<UNSET> _state='done' tmpfile={!r}>".format(
947-
cap.tmpfile
946+
"<FDCapture 1 oldfd={} _state='done' tmpfile={!r}>".format(
947+
cap.targetfd_save, cap.tmpfile
948948
)
949949
)
950950
# Should not crash with missing "_old".
@@ -1150,6 +1150,7 @@ def test_stdcapture_fd_invalid_fd(self, testdir):
11501150
testdir.makepyfile(
11511151
"""
11521152
import os
1153+
from fnmatch import fnmatch
11531154
from _pytest import capture
11541155
11551156
def StdCaptureFD(out=True, err=True, in_=True):
@@ -1158,26 +1159,63 @@ def StdCaptureFD(out=True, err=True, in_=True):
11581159
def test_stdout():
11591160
os.close(1)
11601161
cap = StdCaptureFD(out=True, err=False, in_=False)
1161-
assert repr(cap.out) == "<FDCapture 1 oldfd=<UNSET> _state=None tmpfile=<UNSET>>"
1162+
assert fnmatch(repr(cap.out), "<FDCapture 1 oldfd=* _state=None tmpfile=*>")
1163+
cap.start_capturing()
1164+
os.write(1, b"stdout")
1165+
assert cap.readouterr() == ("stdout", "")
11621166
cap.stop_capturing()
11631167
11641168
def test_stderr():
11651169
os.close(2)
11661170
cap = StdCaptureFD(out=False, err=True, in_=False)
1167-
assert repr(cap.err) == "<FDCapture 2 oldfd=<UNSET> _state=None tmpfile=<UNSET>>"
1171+
assert fnmatch(repr(cap.err), "<FDCapture 2 oldfd=* _state=None tmpfile=*>")
1172+
cap.start_capturing()
1173+
os.write(2, b"stderr")
1174+
assert cap.readouterr() == ("", "stderr")
11681175
cap.stop_capturing()
11691176
11701177
def test_stdin():
11711178
os.close(0)
11721179
cap = StdCaptureFD(out=False, err=False, in_=True)
1173-
assert repr(cap.in_) == "<FDCapture 0 oldfd=<UNSET> _state=None tmpfile=<UNSET>>"
1180+
assert fnmatch(repr(cap.in_), "<FDCapture 0 oldfd=* _state=None tmpfile=*>")
11741181
cap.stop_capturing()
11751182
"""
11761183
)
11771184
result = testdir.runpytest_subprocess("--capture=fd")
11781185
assert result.ret == 0
11791186
assert result.parseoutcomes()["passed"] == 3
11801187

1188+
def test_fdcapture_invalid_fd_with_fd_reuse(self, testdir):
1189+
with saved_fd(1):
1190+
os.close(1)
1191+
cap = capture.FDCaptureBinary(1)
1192+
cap.start()
1193+
os.write(1, b"started")
1194+
cap.suspend()
1195+
os.write(1, b" suspended")
1196+
cap.resume()
1197+
os.write(1, b" resumed")
1198+
assert cap.snap() == b"started resumed"
1199+
cap.done()
1200+
with pytest.raises(OSError):
1201+
os.write(1, b"done")
1202+
1203+
def test_fdcapture_invalid_fd_without_fd_reuse(self, testdir):
1204+
with saved_fd(1), saved_fd(2):
1205+
os.close(1)
1206+
os.close(2)
1207+
cap = capture.FDCaptureBinary(2)
1208+
cap.start()
1209+
os.write(2, b"started")
1210+
cap.suspend()
1211+
os.write(2, b" suspended")
1212+
cap.resume()
1213+
os.write(2, b" resumed")
1214+
assert cap.snap() == b"started resumed"
1215+
cap.done()
1216+
with pytest.raises(OSError):
1217+
os.write(2, b"done")
1218+
11811219

11821220
def test_capture_not_started_but_reset():
11831221
capsys = StdCapture()

0 commit comments

Comments
 (0)