Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ repos:
hooks:
- id: check-merge-conflict
- id: debug-statements
exclude: ^(src/click/testing\.py|tests/test_testing\.py)$
- id: fix-byte-order-marker
- id: trailing-whitespace
- id: end-of-file-fixer
45 changes: 45 additions & 0 deletions src/click/testing.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import contextlib
import io
import os
import pdb
import shlex
import sys
import tempfile
Expand Down Expand Up @@ -390,12 +391,55 @@ def should_strip_ansi(
old__getchar_func = termui._getchar
old_should_strip_ansi = utils.should_strip_ansi # type: ignore
old__compat_should_strip_ansi = _compat.should_strip_ansi
old_pdb_init = pdb.Pdb.__init__
termui.visible_prompt_func = visible_input
termui.hidden_prompt_func = hidden_input
termui._getchar = _getchar
utils.should_strip_ansi = should_strip_ansi # type: ignore
_compat.should_strip_ansi = should_strip_ansi

def _patched_pdb_init(
self: pdb.Pdb,
completekey: str = "tab",
stdin: t.IO[str] | None = None,
stdout: t.IO[str] | None = None,
**kwargs: t.Any,
) -> None:
"""Default ``pdb.Pdb`` to real terminal streams during
``CliRunner`` isolation.

Without this patch, ``pdb.Pdb.__init__`` inherits from
``cmd.Cmd`` which falls back to ``sys.stdin``/``sys.stdout``
when no explicit streams are provided. During isolation
those are ``BytesIO``-backed wrappers, so the debugger
reads from an empty buffer and writes to captured output,
making interactive debugging impossible.

By defaulting to ``sys.__stdin__``/``sys.__stdout__`` (the
original terminal streams Python preserves regardless of
redirection), debuggers can interact with the user while
``click.echo`` output is still captured normally.

This covers ``pdb.set_trace()``, ``breakpoint()``,
``pdb.post_mortem()``, and debuggers that subclass
``pdb.Pdb`` (ipdb, pdbpp). Explicit ``stdin``/``stdout``
arguments are honored and not overridden. Debuggers that
do not subclass ``pdb.Pdb`` (pudb, debugpy) are not
covered.

See: https://github.com/pallets/click/issues/654 and
https://github.com/pallets/click/issues/824
"""
if stdin is None:
stdin = sys.__stdin__
if stdout is None:
stdout = sys.__stdout__
old_pdb_init(
self, completekey=completekey, stdin=stdin, stdout=stdout, **kwargs
)

pdb.Pdb.__init__ = _patched_pdb_init # type: ignore[assignment]

old_env = {}
try:
for key, value in env.items():
Expand Down Expand Up @@ -426,6 +470,7 @@ def should_strip_ansi(
utils.should_strip_ansi = old_should_strip_ansi # type: ignore
_compat.should_strip_ansi = old__compat_should_strip_ansi
formatting.FORCED_WIDTH = old_forced_width
pdb.Pdb.__init__ = old_pdb_init # type: ignore[method-assign]

def invoke(
self,
Expand Down
50 changes: 50 additions & 0 deletions tests/test_testing.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import os
import pdb
import sys
from io import BytesIO

Expand Down Expand Up @@ -469,3 +470,52 @@ def cli():

result = runner.invoke(cli)
assert result.stderr == "gyarados gyarados gyarados"


def test_pdb_uses_real_streams():
"""``pdb.Pdb()`` inside ``CliRunner`` defaults to real terminal streams
so that interactive debuggers work instead of reading from the
captured ``BytesIO`` stdin.
"""

@click.command()
def cli():
debugger = pdb.Pdb()
assert debugger.stdin is sys.__stdin__
assert debugger.stdout is sys.__stdout__
click.echo("after debugger")

runner = CliRunner()
result = runner.invoke(cli, catch_exceptions=False)
assert result.output == "after debugger\n"


def test_pdb_explicit_streams_honored():
"""Explicit ``stdin``/``stdout`` arguments to ``pdb.Pdb()`` are not
overridden by the ``CliRunner`` patch.
"""

@click.command()
def cli():
custom_in = sys.stdin
custom_out = sys.stdout
debugger = pdb.Pdb(stdin=custom_in, stdout=custom_out)
assert debugger.stdin is custom_in
assert debugger.stdout is custom_out

runner = CliRunner()
runner.invoke(cli, catch_exceptions=False)


def test_pdb_init_restored_after_invoke():
"""``pdb.Pdb.__init__`` is restored to its original after invoke."""
original = pdb.Pdb.__init__

@click.command()
def cli():
pass

runner = CliRunner()
runner.invoke(cli)

assert pdb.Pdb.__init__ is original
Loading