Skip to content

Commit 8762c77

Browse files
committed
Expose original file descriptor in CliRunner
Fix #2865
1 parent 878de46 commit 8762c77

4 files changed

Lines changed: 137 additions & 6 deletions

File tree

CHANGES.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ Unreleased
2121
``pytest-xdist`` to detect test pollution and race conditions. :pr:`3151`
2222
- Add contributor documentation for running stress tests, randomized
2323
parallel tests, and Flask smoke tests. :pr:`3151` :pr:`3177`
24+
- ``CliRunner``'s redirected streams now expose the original file descriptor
25+
via ``fileno()``, so that ``faulthandler``, ``subprocess``, and other
26+
C-level consumers no longer crash with ``io.UnsupportedOperation``.
27+
:issue:`2865`
2428

2529
Version 8.3.2
2630
-------------

docs/testing.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,3 +196,51 @@ def test_prompts():
196196
Prompts will be emulated so they write the input data to
197197
the output stream as well. If hidden input is expected then this
198198
does not happen.
199+
200+
## File Descriptors and Low-Level I/O
201+
202+
{class}`CliRunner` captures output by replacing
203+
`sys.stdout` and `sys.stderr` with in-memory
204+
{class}`~io.BytesIO`-backed wrappers. This is
205+
Python-level redirection: calls to {func}`~click.echo`,
206+
{func}`print`, or `sys.stdout.write()` are captured, but
207+
the wrappers have no OS-level file descriptor.
208+
209+
Code that calls `fileno()` on `sys.stdout` or
210+
`sys.stderr`, like {mod}`faulthandler`,
211+
{mod}`subprocess`, or C extensions, would normally crash
212+
with {exc}`io.UnsupportedOperation` inside
213+
{class}`CliRunner`.
214+
215+
To avoid this, {class}`CliRunner` preserves the original
216+
stream's file descriptor and exposes it via `fileno()` on
217+
the replacement wrapper.
218+
219+
This means:
220+
- **Python-level writes** (`print()`, `click.echo()`,
221+
...) are captured as usual.
222+
- **fd-level writes** (C code writing directly to the
223+
file descriptor) go to the original terminal and are
224+
**not** captured.
225+
226+
This is the same trade-off that
227+
[pytest](https://docs.pytest.org/en/stable/how-to/capture-stdout-stderr.html)
228+
makes with its two capture modes:
229+
230+
- `capsys`, which captures Python-level output, where
231+
`fileno()` raises `UnsupportedOperation` and fd-level
232+
writes are not captured.
233+
- `capfd`, which captures fd-level output via
234+
`os.dup2()`, where `fileno()` works and fd-level
235+
writes *are* captured.
236+
237+
Rather than implementing a full `capfd`-style mechanism,
238+
{class}`CliRunner` takes the simpler path: expose the
239+
original `fd` so that standard library helpers keep
240+
working, while accepting that their output is not
241+
captured.
242+
243+
```{versionchanged} 8.3.3
244+
`fileno()` on the redirected streams now returns the
245+
original stream's file descriptor instead of raising.
246+
```

src/click/testing.py

Lines changed: 59 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -101,21 +101,55 @@ def __init__(self) -> None:
101101

102102

103103
class _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

tests/test_testing.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import faulthandler
12
import os
23
import pdb
34
import sys
@@ -519,3 +520,28 @@ def cli():
519520
runner.invoke(cli)
520521

521522
assert pdb.Pdb.__init__ is original
523+
524+
525+
def test_faulthandler_enable(runner):
526+
"""``faulthandler.enable()`` inside ``CliRunner`` should not crash with
527+
``io.UnsupportedOperation: fileno``.
528+
529+
``faulthandler.enable()`` needs a real OS file descriptor to register
530+
its signal handler. ``CliRunner`` replaces ``sys.stderr`` with a
531+
``BytesIO`` wrapper that has no ``fileno()``, causing the call to fail.
532+
533+
Reproduce:https://github.com/pallets/click/issues/2865
534+
"""
535+
536+
@click.command()
537+
@click.option("--flag", type=bool, default=True)
538+
def cli(flag):
539+
click.echo("Executing main function...")
540+
if flag:
541+
click.echo("Registering faulthandler")
542+
faulthandler.enable()
543+
click.echo("Finished executing main function.")
544+
545+
result = runner.invoke(cli, ["--flag", True])
546+
assert result.exit_code == 0, result.output
547+
assert "Finished executing main function." in result.output

0 commit comments

Comments
 (0)