@@ -101,21 +101,55 @@ def __init__(self) -> None:
101101
102102
103103class _NamedTextIOWrapper (io .TextIOWrapper ):
104+ """A :class:`~io.TextIOWrapper` with custom ``name`` and ``mode``
105+ that does not close its underlying buffer.
106+
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.
115+ """
116+
104117 def __init__ (
105- self , buffer : t .BinaryIO , name : str , mode : str , ** kwargs : t .Any
118+ self ,
119+ buffer : t .BinaryIO ,
120+ name : str ,
121+ mode : str ,
122+ * ,
123+ original_fd : int = - 1 ,
124+ ** kwargs : t .Any ,
106125 ) -> None :
107126 super ().__init__ (buffer , ** kwargs )
108127 self ._name = name
109128 self ._mode = mode
129+ self ._original_fd = original_fd
110130
111131 def close (self ) -> None :
112- """
113- The buffer this object contains belongs to some other object, so
114- prevent the default __del__ implementation from closing that buffer.
132+ """The buffer this object contains belongs to some other object,
133+ so prevent the default ``__del__`` implementation from closing
134+ that buffer.
115135
116136 .. versionadded:: 8.3.2
117137 """
118- ...
138+
139+ 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
149+ """
150+ if self ._original_fd >= 0 :
151+ return self ._original_fd
152+ return super ().fileno ()
119153
120154 @property
121155 def name (self ) -> str :
@@ -321,6 +355,20 @@ def isolation(
321355
322356 stream_mixer = StreamMixer ()
323357
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+
324372 if self .echo_stdin :
325373 bytes_input = echo_input = t .cast (
326374 t .BinaryIO , EchoingStdin (bytes_input , stream_mixer .stdout )
@@ -336,7 +384,11 @@ def isolation(
336384 text_input ._CHUNK_SIZE = 1 # type: ignore
337385
338386 sys .stdout = _NamedTextIOWrapper (
339- stream_mixer .stdout , encoding = self .charset , name = "<stdout>" , mode = "w"
387+ stream_mixer .stdout ,
388+ encoding = self .charset ,
389+ name = "<stdout>" ,
390+ mode = "w" ,
391+ original_fd = old_stdout_fd ,
340392 )
341393
342394 sys .stderr = _NamedTextIOWrapper (
@@ -345,6 +397,7 @@ def isolation(
345397 name = "<stderr>" ,
346398 mode = "w" ,
347399 errors = "backslashreplace" ,
400+ original_fd = old_stderr_fd ,
348401 )
349402
350403 @_pause_echo (echo_input ) # type: ignore
0 commit comments