Skip to content

Commit aad0fd6

Browse files
committed
Add capture parameter to CliRunner and revert default-mode fileno exposure
Closes: #3384 Refs: #3244
1 parent 25edc1e commit aad0fd6

4 files changed

Lines changed: 355 additions & 98 deletions

File tree

CHANGES.rst

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,18 @@ Unreleased
2929
non-shadowed help option names, so ``Try '... -h'`` no longer points to a
3030
subcommand option that shadows ``-h``. All surviving names are shown
3131
(``-h/--help``). :issue:`2790` :pr:`3208`
32+
- Add ``capture`` parameter to :class:`CliRunner` with two modes: ``sys``
33+
(default) and ``fd``. ``fd`` redirects file descriptors ``1`` and ``2``
34+
via :func:`os.dup2` so output that bypasses ``sys.stdout`` (stale stream
35+
references, C extensions, subprocesses, ``faulthandler``) is captured
36+
with proper isolation. :issue:`854` :issue:`2412` :issue:`2468`
37+
:issue:`2497` :issue:`2761` :issue:`2827` :issue:`2865`
38+
- Revert the ``8.3.3`` change that exposed the original file descriptor
39+
via ``fileno()`` on the redirected ``CliRunner`` streams in the default
40+
capture mode. ``os.dup2(w, sys.stdout.fileno())`` calls inside a CLI no
41+
longer mutate the host runner's stdout, which broke Pytest's ``fd``-level
42+
capture teardown. C-level consumers that need a real ``fd`` should use
43+
``capture="fd"``. :issue:`3384` :pr:`3391`
3244

3345
Version 8.3.3
3446
-------------

docs/testing.md

Lines changed: 58 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -204,50 +204,62 @@ Prompts will be emulated so they write the input data to
204204
the output stream as well. If hidden input is expected then this
205205
does not happen.
206206

207-
## File Descriptors and Low-Level I/O
208-
209-
{class}`CliRunner` captures output by replacing
210-
`sys.stdout` and `sys.stderr` with in-memory
211-
{class}`~io.BytesIO`-backed wrappers. This is
212-
Python-level redirection: calls to {func}`~click.echo`,
213-
{func}`print`, or `sys.stdout.write()` are captured, but
214-
the wrappers have no OS-level file descriptor.
215-
216-
Code that calls `fileno()` on `sys.stdout` or
217-
`sys.stderr`, like {mod}`faulthandler`,
218-
{mod}`subprocess`, or C extensions, would normally crash
219-
with {exc}`io.UnsupportedOperation` inside
220-
{class}`CliRunner`.
221-
222-
To avoid this, {class}`CliRunner` preserves the original
223-
stream's file descriptor and exposes it via `fileno()` on
224-
the replacement wrapper.
225-
226-
This means:
227-
- **Python-level writes** (`print()`, `click.echo()`,
228-
...) are captured as usual.
229-
- **fd-level writes** (C code writing directly to the
230-
file descriptor) go to the original terminal and are
231-
**not** captured.
232-
233-
This is the same trade-off that
234-
[pytest](https://docs.pytest.org/en/stable/how-to/capture-stdout-stderr.html)
235-
makes with its two capture modes:
236-
237-
- `capsys`, which captures Python-level output, where
238-
`fileno()` raises `UnsupportedOperation` and fd-level
239-
writes are not captured.
240-
- `capfd`, which captures fd-level output via
241-
`os.dup2()`, where `fileno()` works and fd-level
242-
writes *are* captured.
243-
244-
Rather than implementing a full `capfd`-style mechanism,
245-
{class}`CliRunner` takes the simpler path: expose the
246-
original `fd` so that standard library helpers keep
247-
working, while accepting that their output is not
248-
captured.
249-
250-
```{versionchanged} 8.3.3
251-
`fileno()` on the redirected streams now returns the
252-
original stream's file descriptor instead of raising.
207+
## Capture modes
208+
209+
{class}`CliRunner` captures output by replacing `sys.stdout` and `sys.stderr`
210+
with in-memory wrappers. The `capture` parameter controls which strategy is
211+
used.
212+
213+
### `capture="sys"` (default)
214+
215+
Captures Python-level writes (`print()`, `click.echo()`, `sys.stdout.write()`).
216+
It is fast and sufficient for most Click applications.
217+
218+
Code that holds a reference to the original `sys.stdout` (like a library that
219+
does `from sys import stdout` at import time) bypasses the capture and its
220+
output is lost.
221+
222+
In this mode `sys.stdout.fileno()` and `sys.stderr.fileno()` raise
223+
{exc}`io.UnsupportedOperation`, matching the pre-`8.3.3` behavior. C-level
224+
consumers ({mod}`faulthandler`, {mod}`subprocess`, C extensions) that expect a
225+
real file descriptor must opt into the `capture="fd"` mode.
226+
227+
### `capture="fd"`
228+
229+
Redirects OS file descriptors `1` and `2` to a temporary file via
230+
{func}`os.dup2`, inspired by [Pytest's
231+
`capfd`](https://docs.pytest.org/en/stable/how-to/capture-stdout-stderr.html).
232+
This catches output that bypasses `sys.stdout`, including:
233+
234+
- Stale references to the original `sys.stdout` and `sys.stderr`.
235+
- Logging frameworks that cache the original stream (like `structlog` or the
236+
stdlib's `logging` module).
237+
- C extensions and subprocesses that write directly to `fd 1` or `fd 2`.
238+
239+
```python
240+
from click.testing import CliRunner
241+
from myapp import cli
242+
243+
244+
def test_captures_everything():
245+
runner = CliRunner(capture="fd")
246+
result = runner.invoke(cli)
247+
# result.stdout contains both Python-level and fd-level output
248+
assert "expected output" in result.stdout
249+
```
250+
251+
In this mode `sys.stdout.fileno()` returns the saved (pre-redirection) `fd`, so
252+
{mod}`faulthandler` and similar consumers keep working. Writes to `fd 1` and
253+
`fd 2` land in the capture tmpfile, so `os.dup2()` calls inside the CLI no
254+
longer leak into the host runner's stdout.
255+
256+
```{note}
257+
`capture="fd"` is not available on Windows.
258+
```
259+
260+
```{versionchanged} 8.4.0
261+
Added the `capture` parameter. The default `sys` mode no longer exposes the
262+
original `fd` through `fileno()`, reverting the change introduced in `8.3.3`
263+
that broke Pytest's `fd`-level capture teardown. Use `capture="fd"` to restore
264+
that behavior with proper isolation.
253265
```

src/click/testing.py

Lines changed: 103 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222

2323
from .core import Command
2424

25+
CaptureMode = t.Literal["sys", "fd"]
26+
2527

2628
class 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+
70105
class 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

Comments
 (0)