Skip to content

Commit 02ee20a

Browse files
committed
Expose original file descriptor in CliRunner
Fix #2865
1 parent 04ef3a6 commit 02ee20a

File tree

4 files changed

+111
-5
lines changed

4 files changed

+111
-5
lines changed

CHANGES.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ Released 2026-04-02
1717
with a dedicated CI job. :pr:`3139`
1818
- Fix callable ``flag_value`` being instantiated when used as a default via
1919
``default=True``. :issue:`3121` :pr:`3201` :pr:`3213` :pr:`3225`
20+
- ``CliRunner``'s redirected streams now expose the original file descriptor
21+
via ``fileno()``, so that ``faulthandler``, ``subprocess``, and other
22+
C-level consumers no longer crash with ``io.UnsupportedOperation``.
23+
:issue:`2865`
2024

2125
Version 8.3.1
2226
--------------

docs/testing.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,3 +196,26 @@ 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 `sys.stdout` and `sys.stderr` with in-memory {class}`~io.BytesIO`-backed wrappers. This is Python-level redirection: calls to {func}`~click.echo`, {func}`print`, or `sys.stdout.write()` are captured, but the wrappers have no OS-level file descriptor.
203+
204+
Code that calls `fileno()` on `sys.stdout` or `sys.stderr`, like {mod}`faulthandler`, {mod}`subprocess`, or C extensions, would normally crash with {exc}`io.UnsupportedOperation` inside {class}`CliRunner`.
205+
206+
To avoid this, {class}`CliRunner` preserves the original stream's file descriptor and exposes it via `fileno()` on the replacement wrapper.
207+
208+
This means:
209+
- **Python-level writes** (`print()`, `click.echo()`, ...) are captured as usual.
210+
- **fd-level writes** (C code writing directly to the file descriptor) go to the original terminal and are **not** captured.
211+
212+
This is the same trade-off that [pytest](https://docs.pytest.org/en/stable/how-to/capture-stdout-stderr.html) makes with its two capture modes:
213+
214+
- `capsys`, which captures Python-level output, where `fileno()` raises `UnsupportedOperation` and fd-level writes are not captured.
215+
- `capfd`, which captures fd-level output via `os.dup2()`, where `fileno()` works and fd-level writes *are* captured.
216+
217+
Rather than implementing a full `capfd`-style mechanism, {class}`CliRunner` takes the simpler path: expose the original `fd` so that standard library helpers keep working, while accepting that their output is not captured.
218+
219+
```{versionchanged} 8.3.2
220+
`fileno()` on the redirected streams now returns the original stream's file descriptor instead of raising.
221+
```

src/click/testing.py

Lines changed: 58 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -100,21 +100,55 @@ def __init__(self) -> None:
100100

101101

102102
class _NamedTextIOWrapper(io.TextIOWrapper):
103+
"""A :class:`~io.TextIOWrapper` with custom ``name`` and ``mode``
104+
that does not close its underlying buffer.
105+
106+
An optional ``original_fd`` preserves the file descriptor of the
107+
stream being replaced, so that C-level consumers that call
108+
:meth:`fileno` (``faulthandler``, ``subprocess``, ...) still work.
109+
Inspired by pytest's ``capsys``/``capfd`` split: see :doc:`/testing`
110+
for details.
111+
112+
.. versionchanged:: 8.3.2
113+
Added ``original_fd`` parameter and :meth:`fileno` override.
114+
"""
115+
103116
def __init__(
104-
self, buffer: t.BinaryIO, name: str, mode: str, **kwargs: t.Any
117+
self,
118+
buffer: t.BinaryIO,
119+
name: str,
120+
mode: str,
121+
*,
122+
original_fd: int = -1,
123+
**kwargs: t.Any,
105124
) -> None:
106125
super().__init__(buffer, **kwargs)
107126
self._name = name
108127
self._mode = mode
128+
self._original_fd = original_fd
109129

110130
def close(self) -> None:
131+
"""The buffer this object contains belongs to some other object,
132+
so prevent the default ``__del__`` implementation from closing
133+
that buffer.
134+
135+
.. versionadded:: 8.3.2
111136
"""
112-
The buffer this object contains belongs to some other object, so
113-
prevent the default __del__ implementation from closing that buffer.
137+
138+
def fileno(self) -> int:
139+
"""Return the file descriptor of the original stream, if one was
140+
provided at construction time.
141+
142+
This allows C-level consumers (``faulthandler``, ``subprocess``,
143+
signal handlers, ...) to obtain a valid fd without crashing, even
144+
though the Python-level writes are redirected to an in-memory
145+
buffer.
114146
115147
.. versionadded:: 8.3.2
116148
"""
117-
...
149+
if self._original_fd >= 0:
150+
return self._original_fd
151+
return super().fileno()
118152

119153
@property
120154
def name(self) -> str:
@@ -320,6 +354,20 @@ def isolation(
320354

321355
stream_mixer = StreamMixer()
322356

357+
# Preserve the original file descriptors so that C-level
358+
# consumers (faulthandler, subprocess, etc.) can still obtain a
359+
# valid fd from the redirected streams. The original streams
360+
# may themselves lack a fileno() (e.g. when CliRunner is used
361+
# inside pytest's capsys), so we fall back to -1.
362+
def _safe_fileno(stream: t.IO[t.Any]) -> int:
363+
try:
364+
return stream.fileno()
365+
except (AttributeError, io.UnsupportedOperation):
366+
return -1
367+
368+
old_stdout_fd = _safe_fileno(old_stdout)
369+
old_stderr_fd = _safe_fileno(old_stderr)
370+
323371
if self.echo_stdin:
324372
bytes_input = echo_input = t.cast(
325373
t.BinaryIO, EchoingStdin(bytes_input, stream_mixer.stdout)
@@ -335,7 +383,11 @@ def isolation(
335383
text_input._CHUNK_SIZE = 1 # type: ignore
336384

337385
sys.stdout = _NamedTextIOWrapper(
338-
stream_mixer.stdout, encoding=self.charset, name="<stdout>", mode="w"
386+
stream_mixer.stdout,
387+
encoding=self.charset,
388+
name="<stdout>",
389+
mode="w",
390+
original_fd=old_stdout_fd,
339391
)
340392

341393
sys.stderr = _NamedTextIOWrapper(
@@ -344,6 +396,7 @@ def isolation(
344396
name="<stderr>",
345397
mode="w",
346398
errors="backslashreplace",
399+
original_fd=old_stderr_fd,
347400
)
348401

349402
@_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 sys
34
from io import BytesIO
@@ -469,3 +470,28 @@ def cli():
469470

470471
result = runner.invoke(cli)
471472
assert result.stderr == "gyarados gyarados gyarados"
473+
474+
475+
def test_faulthandler_enable(runner):
476+
"""``faulthandler.enable()`` inside ``CliRunner`` should not crash with
477+
``io.UnsupportedOperation: fileno``.
478+
479+
``faulthandler.enable()`` needs a real OS file descriptor to register
480+
its signal handler. ``CliRunner`` replaces ``sys.stderr`` with a
481+
``BytesIO`` wrapper that has no ``fileno()``, causing the call to fail.
482+
483+
Reproduce:https://github.com/pallets/click/issues/2865
484+
"""
485+
486+
@click.command()
487+
@click.option("--flag", type=bool, default=True)
488+
def cli(flag):
489+
click.echo("Executing main function...")
490+
if flag:
491+
click.echo("Registering faulthandler")
492+
faulthandler.enable()
493+
click.echo("Finished executing main function.")
494+
495+
result = runner.invoke(cli, ["--flag", True])
496+
assert result.exit_code == 0, result.output
497+
assert "Finished executing main function." in result.output

0 commit comments

Comments
 (0)