Perform FD capturing even if the FD is invalid
The `FDCapture`/`FDCaptureBinary` classes, used by `capfd`/`capfdbinary` fixtures and the `--capture=fd` option (set by default), redirect FDs 1/2 (stdout/stderr) to a temporary file. To do this, they need to save the old file by duplicating the FD before redirecting it, to be restored once finished. Previously, if this duplicating (`os.dup()`) failed, most likely due to that FD being invalid, the FD redirection would silently not be done. The FD capturing also performs python-level redirection (monkeypatching `sys.stdout`/`sys.stderr`) which would still be done, but direct writes to the FDs would fail. This is not great. If pytest is run with `--capture=fd`, or a test is using `capfd`, it expects writes to the FD to work and be captured, regardless of external circumstances. So, instead of disabling FD capturing, keep the redirection to a temporary file, just don't restore it after closing, because there is nothing to restore to.
This commit is contained in:
parent
0a03217903
commit
eaeafd7c30
|
@ -0,0 +1,4 @@
|
||||||
|
When ``fd`` capturing is used, through ``--capture=fd`` or the ``capfd`` and
|
||||||
|
``capfdbinary`` fixtures, and the file descriptor (0, 1, 2) cannot be
|
||||||
|
duplicated, FD capturing is still performed. Previously, direct writes to the
|
||||||
|
file descriptors would fail or be lost in this case.
|
|
@ -513,14 +513,27 @@ class FDCaptureBinary:
|
||||||
|
|
||||||
def __init__(self, targetfd, tmpfile=None):
|
def __init__(self, targetfd, tmpfile=None):
|
||||||
self.targetfd = targetfd
|
self.targetfd = targetfd
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self.targetfd_save = os.dup(self.targetfd)
|
os.fstat(targetfd)
|
||||||
except OSError:
|
except OSError:
|
||||||
self.start = lambda: None
|
# FD capturing is conceptually simple -- create a temporary file,
|
||||||
self.done = lambda: None
|
# redirect the FD to it, redirect back when done. But when the
|
||||||
|
# target FD is invalid it throws a wrench into this loveley scheme.
|
||||||
|
#
|
||||||
|
# Tests themselves shouldn't care if the FD is valid, FD capturing
|
||||||
|
# should work regardless of external circumstances. So falling back
|
||||||
|
# to just sys capturing is not a good option.
|
||||||
|
#
|
||||||
|
# Further complications are the need to support suspend() and the
|
||||||
|
# possibility of FD reuse (e.g. the tmpfile getting the very same
|
||||||
|
# target FD). The following approach is robust, I believe.
|
||||||
|
self.targetfd_invalid = os.open(os.devnull, os.O_RDWR)
|
||||||
|
os.dup2(self.targetfd_invalid, targetfd)
|
||||||
else:
|
else:
|
||||||
self.start = self._start
|
self.targetfd_invalid = None
|
||||||
self.done = self._done
|
self.targetfd_save = os.dup(targetfd)
|
||||||
|
|
||||||
if targetfd == 0:
|
if targetfd == 0:
|
||||||
assert not tmpfile, "cannot set tmpfile with stdin"
|
assert not tmpfile, "cannot set tmpfile with stdin"
|
||||||
tmpfile = open(os.devnull)
|
tmpfile = open(os.devnull)
|
||||||
|
@ -538,24 +551,19 @@ class FDCaptureBinary:
|
||||||
else:
|
else:
|
||||||
self.syscapture = NoCapture()
|
self.syscapture = NoCapture()
|
||||||
self.tmpfile = tmpfile
|
self.tmpfile = tmpfile
|
||||||
self.tmpfile_fd = tmpfile.fileno()
|
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return "<{} {} oldfd={} _state={!r} tmpfile={}>".format(
|
return "<{} {} oldfd={} _state={!r} tmpfile={!r}>".format(
|
||||||
self.__class__.__name__,
|
self.__class__.__name__,
|
||||||
self.targetfd,
|
self.targetfd,
|
||||||
getattr(self, "targetfd_save", "<UNSET>"),
|
self.targetfd_save,
|
||||||
self._state,
|
self._state,
|
||||||
hasattr(self, "tmpfile") and repr(self.tmpfile) or "<UNSET>",
|
self.tmpfile,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _start(self):
|
def start(self):
|
||||||
""" Start capturing on targetfd using memorized tmpfile. """
|
""" Start capturing on targetfd using memorized tmpfile. """
|
||||||
try:
|
os.dup2(self.tmpfile.fileno(), self.targetfd)
|
||||||
os.fstat(self.targetfd_save)
|
|
||||||
except (AttributeError, OSError):
|
|
||||||
raise ValueError("saved filedescriptor not valid anymore")
|
|
||||||
os.dup2(self.tmpfile_fd, self.targetfd)
|
|
||||||
self.syscapture.start()
|
self.syscapture.start()
|
||||||
self._state = "started"
|
self._state = "started"
|
||||||
|
|
||||||
|
@ -566,12 +574,15 @@ class FDCaptureBinary:
|
||||||
self.tmpfile.truncate()
|
self.tmpfile.truncate()
|
||||||
return res
|
return res
|
||||||
|
|
||||||
def _done(self):
|
def done(self):
|
||||||
""" stop capturing, restore streams, return original capture file,
|
""" stop capturing, restore streams, return original capture file,
|
||||||
seeked to position zero. """
|
seeked to position zero. """
|
||||||
targetfd_save = self.__dict__.pop("targetfd_save")
|
os.dup2(self.targetfd_save, self.targetfd)
|
||||||
os.dup2(targetfd_save, self.targetfd)
|
os.close(self.targetfd_save)
|
||||||
os.close(targetfd_save)
|
if self.targetfd_invalid is not None:
|
||||||
|
if self.targetfd_invalid != self.targetfd:
|
||||||
|
os.close(self.targetfd)
|
||||||
|
os.close(self.targetfd_invalid)
|
||||||
self.syscapture.done()
|
self.syscapture.done()
|
||||||
self.tmpfile.close()
|
self.tmpfile.close()
|
||||||
self._state = "done"
|
self._state = "done"
|
||||||
|
@ -583,7 +594,7 @@ class FDCaptureBinary:
|
||||||
|
|
||||||
def resume(self):
|
def resume(self):
|
||||||
self.syscapture.resume()
|
self.syscapture.resume()
|
||||||
os.dup2(self.tmpfile_fd, self.targetfd)
|
os.dup2(self.tmpfile.fileno(), self.targetfd)
|
||||||
self._state = "resumed"
|
self._state = "resumed"
|
||||||
|
|
||||||
def writeorg(self, data):
|
def writeorg(self, data):
|
||||||
|
@ -609,8 +620,7 @@ class FDCapture(FDCaptureBinary):
|
||||||
|
|
||||||
def writeorg(self, data):
|
def writeorg(self, data):
|
||||||
""" write to original file descriptor. """
|
""" write to original file descriptor. """
|
||||||
data = data.encode("utf-8") # XXX use encoding of original stream
|
super().writeorg(data.encode("utf-8")) # XXX use encoding of original stream
|
||||||
os.write(self.targetfd_save, data)
|
|
||||||
|
|
||||||
|
|
||||||
class SysCaptureBinary:
|
class SysCaptureBinary:
|
||||||
|
|
|
@ -943,8 +943,8 @@ class TestFDCapture:
|
||||||
pytest.raises(AttributeError, cap.suspend)
|
pytest.raises(AttributeError, cap.suspend)
|
||||||
|
|
||||||
assert repr(cap) == (
|
assert repr(cap) == (
|
||||||
"<FDCapture 1 oldfd=<UNSET> _state='done' tmpfile={!r}>".format(
|
"<FDCapture 1 oldfd={} _state='done' tmpfile={!r}>".format(
|
||||||
cap.tmpfile
|
cap.targetfd_save, cap.tmpfile
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
# Should not crash with missing "_old".
|
# Should not crash with missing "_old".
|
||||||
|
@ -1150,6 +1150,7 @@ class TestStdCaptureFDinvalidFD:
|
||||||
testdir.makepyfile(
|
testdir.makepyfile(
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
|
from fnmatch import fnmatch
|
||||||
from _pytest import capture
|
from _pytest import capture
|
||||||
|
|
||||||
def StdCaptureFD(out=True, err=True, in_=True):
|
def StdCaptureFD(out=True, err=True, in_=True):
|
||||||
|
@ -1158,19 +1159,25 @@ class TestStdCaptureFDinvalidFD:
|
||||||
def test_stdout():
|
def test_stdout():
|
||||||
os.close(1)
|
os.close(1)
|
||||||
cap = StdCaptureFD(out=True, err=False, in_=False)
|
cap = StdCaptureFD(out=True, err=False, in_=False)
|
||||||
assert repr(cap.out) == "<FDCapture 1 oldfd=<UNSET> _state=None tmpfile=<UNSET>>"
|
assert fnmatch(repr(cap.out), "<FDCapture 1 oldfd=* _state=None tmpfile=*>")
|
||||||
|
cap.start_capturing()
|
||||||
|
os.write(1, b"stdout")
|
||||||
|
assert cap.readouterr() == ("stdout", "")
|
||||||
cap.stop_capturing()
|
cap.stop_capturing()
|
||||||
|
|
||||||
def test_stderr():
|
def test_stderr():
|
||||||
os.close(2)
|
os.close(2)
|
||||||
cap = StdCaptureFD(out=False, err=True, in_=False)
|
cap = StdCaptureFD(out=False, err=True, in_=False)
|
||||||
assert repr(cap.err) == "<FDCapture 2 oldfd=<UNSET> _state=None tmpfile=<UNSET>>"
|
assert fnmatch(repr(cap.err), "<FDCapture 2 oldfd=* _state=None tmpfile=*>")
|
||||||
|
cap.start_capturing()
|
||||||
|
os.write(2, b"stderr")
|
||||||
|
assert cap.readouterr() == ("", "stderr")
|
||||||
cap.stop_capturing()
|
cap.stop_capturing()
|
||||||
|
|
||||||
def test_stdin():
|
def test_stdin():
|
||||||
os.close(0)
|
os.close(0)
|
||||||
cap = StdCaptureFD(out=False, err=False, in_=True)
|
cap = StdCaptureFD(out=False, err=False, in_=True)
|
||||||
assert repr(cap.in_) == "<FDCapture 0 oldfd=<UNSET> _state=None tmpfile=<UNSET>>"
|
assert fnmatch(repr(cap.in_), "<FDCapture 0 oldfd=* _state=None tmpfile=*>")
|
||||||
cap.stop_capturing()
|
cap.stop_capturing()
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
@ -1178,6 +1185,37 @@ class TestStdCaptureFDinvalidFD:
|
||||||
assert result.ret == 0
|
assert result.ret == 0
|
||||||
assert result.parseoutcomes()["passed"] == 3
|
assert result.parseoutcomes()["passed"] == 3
|
||||||
|
|
||||||
|
def test_fdcapture_invalid_fd_with_fd_reuse(self, testdir):
|
||||||
|
with saved_fd(1):
|
||||||
|
os.close(1)
|
||||||
|
cap = capture.FDCaptureBinary(1)
|
||||||
|
cap.start()
|
||||||
|
os.write(1, b"started")
|
||||||
|
cap.suspend()
|
||||||
|
os.write(1, b" suspended")
|
||||||
|
cap.resume()
|
||||||
|
os.write(1, b" resumed")
|
||||||
|
assert cap.snap() == b"started resumed"
|
||||||
|
cap.done()
|
||||||
|
with pytest.raises(OSError):
|
||||||
|
os.write(1, b"done")
|
||||||
|
|
||||||
|
def test_fdcapture_invalid_fd_without_fd_reuse(self, testdir):
|
||||||
|
with saved_fd(1), saved_fd(2):
|
||||||
|
os.close(1)
|
||||||
|
os.close(2)
|
||||||
|
cap = capture.FDCaptureBinary(2)
|
||||||
|
cap.start()
|
||||||
|
os.write(2, b"started")
|
||||||
|
cap.suspend()
|
||||||
|
os.write(2, b" suspended")
|
||||||
|
cap.resume()
|
||||||
|
os.write(2, b" resumed")
|
||||||
|
assert cap.snap() == b"started resumed"
|
||||||
|
cap.done()
|
||||||
|
with pytest.raises(OSError):
|
||||||
|
os.write(2, b"done")
|
||||||
|
|
||||||
|
|
||||||
def test_capture_not_started_but_reset():
|
def test_capture_not_started_but_reset():
|
||||||
capsys = StdCapture()
|
capsys = StdCapture()
|
||||||
|
|
Loading…
Reference in New Issue