2222
2323 from .core import Command
2424
25+ CaptureMode = t .Literal ["sys" , "fd" ]
26+
2527
2628class EchoingStdin :
2729 def __init__ (self , input : t .BinaryIO , output : t .BinaryIO ) -> None :
@@ -67,6 +69,39 @@ def _pause_echo(stream: EchoingStdin | None) -> cabc.Iterator[None]:
6769 stream ._paused = False
6870
6971
72+ class _FDCapture :
73+ """Redirect a file descriptor to a temporary file for capture.
74+
75+ Saves the current target of *targetfd* via :func:`os.dup`, then
76+ redirects it to a temporary file via :func:`os.dup2`. On
77+ :meth:`stop`, restores the original ``fd`` and returns the captured
78+ bytes. Inspired by Pytest's ``FDCapture``.
79+
80+ .. versionadded:: 8.4.0
81+ """
82+
83+ def __init__ (self , targetfd : int ) -> None :
84+ self ._targetfd = targetfd
85+ self .saved_fd : int = - 1
86+ self ._tmpfile : t .BinaryIO | None = None
87+
88+ def start (self ) -> None :
89+ self .saved_fd = os .dup (self ._targetfd )
90+ self ._tmpfile = tempfile .TemporaryFile (buffering = 0 )
91+ os .dup2 (self ._tmpfile .fileno (), self ._targetfd )
92+
93+ def stop (self ) -> bytes :
94+ assert self ._tmpfile is not None , "_FDCapture.start() was not called"
95+ os .dup2 (self .saved_fd , self ._targetfd )
96+ os .close (self .saved_fd )
97+ self .saved_fd = - 1
98+ self ._tmpfile .seek (0 )
99+ data = self ._tmpfile .read ()
100+ self ._tmpfile .close ()
101+ self ._tmpfile = None
102+ return data
103+
104+
70105class BytesIOCopy (io .BytesIO ):
71106 """Patch ``io.BytesIO`` to let the written stream be copied to another.
72107
@@ -104,29 +139,25 @@ class _NamedTextIOWrapper(io.TextIOWrapper):
104139 """A :class:`~io.TextIOWrapper` with custom ``name`` and ``mode``
105140 that does not close its underlying buffer.
106141
107- An optional ``original_fd`` preserves the file descriptor of the
108- stream being replaced, so that C-level consumers that call
109- :meth:`fileno` (``faulthandler``, ``subprocess``, ...) still work.
110- Inspired by pytest's ``capsys``/``capfd`` split: see :doc:`/testing`
111- for details.
112-
113- .. versionchanged:: 8.3.3
114- Added ``original_fd`` parameter and :meth:`fileno` override.
142+ When ``CliRunner`` runs in ``fd`` mode, ``_original_fd`` is patched to
143+ point at the saved (pre-redirection) ``fd``, so C-level consumers that call
144+ :meth:`fileno` (like ``faulthandler`` or ``subprocess``) keep working. In
145+ the default ``sys`` mode ``_original_fd`` stays at ``-1`` and
146+ :meth:`fileno` raises :exc:`io.UnsupportedOperation`, matching the
147+ pre-``8.3.3`` behavior.
115148 """
116149
117150 def __init__ (
118151 self ,
119152 buffer : t .BinaryIO ,
120153 name : str ,
121154 mode : str ,
122- * ,
123- original_fd : int = - 1 ,
124155 ** kwargs : t .Any ,
125156 ) -> None :
126157 super ().__init__ (buffer , ** kwargs )
127158 self ._name = name
128159 self ._mode = mode
129- self ._original_fd = original_fd
160+ self ._original_fd : int = - 1
130161
131162 def close (self ) -> None :
132163 """The buffer this object contains belongs to some other object,
@@ -137,15 +168,10 @@ def close(self) -> None:
137168 """
138169
139170 def fileno (self ) -> int :
140- """Return the file descriptor of the original stream, if one was
141- provided at construction time.
142-
143- This allows C-level consumers (``faulthandler``, ``subprocess``,
144- signal handlers, ...) to obtain a valid fd without crashing, even
145- though the Python-level writes are redirected to an in-memory
146- buffer.
147-
148- .. versionadded:: 8.3.3
171+ """Return the file descriptor of the saved original stream when
172+ ``CliRunner`` runs in ``fd`` mode. Otherwise delegate to
173+ :class:`~io.TextIOWrapper`, which raises
174+ :exc:`io.UnsupportedOperation` for a ``BytesIO``-backed buffer.
149175 """
150176 if self ._original_fd >= 0 :
151177 return self ._original_fd
@@ -272,6 +298,21 @@ class CliRunner:
272298 will automatically echo the input.
273299 :param catch_exceptions: Whether to catch any exceptions other than
274300 ``SystemExit`` when running :meth:`~CliRunner.invoke`.
301+ :param capture: Selects the output capture strategy. ``sys`` (default)
302+ captures Python-level writes only and leaves
303+ :meth:`sys.stdout.fileno` raising :exc:`io.UnsupportedOperation`, so
304+ user code that calls :func:`os.dup2` on ``sys.stdout.fileno()`` cannot
305+ clobber the host runner's stdout. ``fd`` redirects file descriptors
306+ ``1`` and ``2`` via :func:`os.dup2` to a temporary file, also catching
307+ output from stale stream references, C extensions, and subprocesses.
308+ ``fd`` is not supported on Windows.
309+
310+ .. versionchanged:: 8.4.0
311+ Added the ``capture`` parameter. The default ``sys`` mode no longer
312+ exposes the original fd through :meth:`fileno`, reverting the change
313+ introduced in ``8.3.3`` that broke Pytest's ``fd``-level capture
314+ teardown. Use ``capture="fd"`` to restore that behavior with proper
315+ isolation. :issue:`3384`
275316
276317 .. versionchanged:: 8.2
277318 Added the ``catch_exceptions`` parameter.
@@ -286,11 +327,21 @@ def __init__(
286327 env : cabc .Mapping [str , str | None ] | None = None ,
287328 echo_stdin : bool = False ,
288329 catch_exceptions : bool = True ,
330+ capture : CaptureMode = "sys" ,
289331 ) -> None :
332+ if capture not in {"sys" , "fd" }:
333+ raise ValueError (
334+ f"capture={ capture !r} is not valid. Choose from 'sys' or 'fd'."
335+ )
336+ if capture == "fd" and sys .platform == "win32" :
337+ raise ValueError (
338+ f"capture={ capture !r} is not supported on Windows. Use 'sys'."
339+ )
290340 self .charset = charset
291341 self .env : cabc .Mapping [str , str | None ] = env or {}
292342 self .echo_stdin = echo_stdin
293343 self .catch_exceptions = catch_exceptions
344+ self .capture : CaptureMode = capture
294345
295346 def get_default_prog_name (self , cli : Command ) -> str :
296347 """Given a command object it will return the default program name
@@ -355,20 +406,6 @@ def isolation(
355406
356407 stream_mixer = StreamMixer ()
357408
358- # Preserve the original file descriptors so that C-level
359- # consumers (faulthandler, subprocess, etc.) can still obtain a
360- # valid fd from the redirected streams. The original streams
361- # may themselves lack a fileno() (e.g. when CliRunner is used
362- # inside pytest's capsys), so we fall back to -1.
363- def _safe_fileno (stream : t .IO [t .Any ]) -> int :
364- try :
365- return stream .fileno ()
366- except (AttributeError , io .UnsupportedOperation ):
367- return - 1
368-
369- old_stdout_fd = _safe_fileno (old_stdout )
370- old_stderr_fd = _safe_fileno (old_stderr )
371-
372409 if self .echo_stdin :
373410 bytes_input = echo_input = t .cast (
374411 t .BinaryIO , EchoingStdin (bytes_input , stream_mixer .stdout )
@@ -388,7 +425,6 @@ def _safe_fileno(stream: t.IO[t.Any]) -> int:
388425 encoding = self .charset ,
389426 name = "<stdout>" ,
390427 mode = "w" ,
391- original_fd = old_stdout_fd ,
392428 )
393429
394430 sys .stderr = _NamedTextIOWrapper (
@@ -397,7 +433,6 @@ def _safe_fileno(stream: t.IO[t.Any]) -> int:
397433 name = "<stderr>" ,
398434 mode = "w" ,
399435 errors = "backslashreplace" ,
400- original_fd = old_stderr_fd ,
401436 )
402437
403438 @_pause_echo (echo_input ) # type: ignore
@@ -579,7 +614,27 @@ def invoke(
579614 if catch_exceptions is None :
580615 catch_exceptions = self .catch_exceptions
581616
617+ # Set up fd capture before isolation replaces sys.stdout and sys.stderr.
618+ cap_out : _FDCapture | None = None
619+ cap_err : _FDCapture | None = None
620+
621+ if self .capture == "fd" :
622+ cap_out = _FDCapture (1 )
623+ cap_err = _FDCapture (2 )
624+ try :
625+ cap_out .start ()
626+ cap_err .start ()
627+ except OSError :
628+ cap_out = cap_err = None
629+
582630 with self .isolation (input = input , env = env , color = color ) as outstreams :
631+ # Point the captured streams' fileno() at the saved (original)
632+ # fd so that C-level consumers like faulthandler keep working
633+ # while fd 1/2 are redirected to the capture tmpfile.
634+ if cap_out is not None and cap_err is not None :
635+ sys .stdout ._original_fd = cap_out .saved_fd # type: ignore[union-attr]
636+ sys .stderr ._original_fd = cap_err .saved_fd # type: ignore[union-attr]
637+
583638 return_value = None
584639 exception : BaseException | None = None
585640 exit_code = 0
@@ -620,6 +675,18 @@ def invoke(
620675 finally :
621676 sys .stdout .flush ()
622677 sys .stderr .flush ()
678+
679+ # Stop fd capture and merge the captured bytes into
680+ # the stdout/stderr BytesIO streams. BytesIOCopy mirrors
681+ # those writes into outstreams[2] automatically.
682+ if cap_out is not None and cap_err is not None :
683+ fd_out = cap_out .stop ()
684+ fd_err = cap_err .stop ()
685+ if fd_out :
686+ outstreams [0 ].write (fd_out )
687+ if fd_err :
688+ outstreams [1 ].write (fd_err )
689+
623690 stdout = outstreams [0 ].getvalue ()
624691 stderr = outstreams [1 ].getvalue ()
625692 output = outstreams [2 ].getvalue ()
0 commit comments