Skip to content

Commit ac2dd7a

Browse files
authored
Allow debugger interactions in tests (#3235)
2 parents 1339fd3 + 4f9086b commit ac2dd7a

4 files changed

Lines changed: 100 additions & 0 deletions

File tree

.pre-commit-config.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ repos:
1313
hooks:
1414
- id: check-merge-conflict
1515
- id: debug-statements
16+
exclude: ^(src/click/testing\.py|tests/test_testing\.py)$
1617
- id: fix-byte-order-marker
1718
- id: trailing-whitespace
1819
- id: end-of-file-fixer

CHANGES.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ Unreleased
1313
``semver.Version``. :issue:`3298` :pr:`3299`
1414
- Fix pager test pollution under parallel execution by using pytest's
1515
``tmp_path`` fixture instead of a shared temporary file path. :pr:`3238`
16+
- Patch ``pdb.Pdb`` in ``CliRunner`` isolation so ``pdb.set_trace()``,
17+
``breakpoint()``, and debuggers subclassing ``pdb.Pdb`` (ipdb, pdbpp) can
18+
interact with the real terminal instead of the captured I/O streams.
19+
:issue:`654` :issue:`824` :issue:`843` :pr:`951` :pr:`3235`
1620

1721
Version 8.3.2
1822
-------------

src/click/testing.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import contextlib
55
import io
66
import os
7+
import pdb
78
import shlex
89
import sys
910
import tempfile
@@ -390,12 +391,55 @@ def should_strip_ansi(
390391
old__getchar_func = termui._getchar
391392
old_should_strip_ansi = utils.should_strip_ansi # type: ignore
392393
old__compat_should_strip_ansi = _compat.should_strip_ansi
394+
old_pdb_init = pdb.Pdb.__init__
393395
termui.visible_prompt_func = visible_input
394396
termui.hidden_prompt_func = hidden_input
395397
termui._getchar = _getchar
396398
utils.should_strip_ansi = should_strip_ansi # type: ignore
397399
_compat.should_strip_ansi = should_strip_ansi
398400

401+
def _patched_pdb_init(
402+
self: pdb.Pdb,
403+
completekey: str = "tab",
404+
stdin: t.IO[str] | None = None,
405+
stdout: t.IO[str] | None = None,
406+
**kwargs: t.Any,
407+
) -> None:
408+
"""Default ``pdb.Pdb`` to real terminal streams during
409+
``CliRunner`` isolation.
410+
411+
Without this patch, ``pdb.Pdb.__init__`` inherits from
412+
``cmd.Cmd`` which falls back to ``sys.stdin``/``sys.stdout``
413+
when no explicit streams are provided. During isolation
414+
those are ``BytesIO``-backed wrappers, so the debugger
415+
reads from an empty buffer and writes to captured output,
416+
making interactive debugging impossible.
417+
418+
By defaulting to ``sys.__stdin__``/``sys.__stdout__`` (the
419+
original terminal streams Python preserves regardless of
420+
redirection), debuggers can interact with the user while
421+
``click.echo`` output is still captured normally.
422+
423+
This covers ``pdb.set_trace()``, ``breakpoint()``,
424+
``pdb.post_mortem()``, and debuggers that subclass
425+
``pdb.Pdb`` (ipdb, pdbpp). Explicit ``stdin``/``stdout``
426+
arguments are honored and not overridden. Debuggers that
427+
do not subclass ``pdb.Pdb`` (pudb, debugpy) are not
428+
covered.
429+
430+
See: https://github.com/pallets/click/issues/654 and
431+
https://github.com/pallets/click/issues/824
432+
"""
433+
if stdin is None:
434+
stdin = sys.__stdin__
435+
if stdout is None:
436+
stdout = sys.__stdout__
437+
old_pdb_init(
438+
self, completekey=completekey, stdin=stdin, stdout=stdout, **kwargs
439+
)
440+
441+
pdb.Pdb.__init__ = _patched_pdb_init # type: ignore[assignment]
442+
399443
old_env = {}
400444
try:
401445
for key, value in env.items():
@@ -426,6 +470,7 @@ def should_strip_ansi(
426470
utils.should_strip_ansi = old_should_strip_ansi # type: ignore
427471
_compat.should_strip_ansi = old__compat_should_strip_ansi
428472
formatting.FORCED_WIDTH = old_forced_width
473+
pdb.Pdb.__init__ = old_pdb_init # type: ignore[method-assign]
429474

430475
def invoke(
431476
self,

tests/test_testing.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import os
2+
import pdb
23
import sys
34
from io import BytesIO
45

@@ -469,3 +470,52 @@ def cli():
469470

470471
result = runner.invoke(cli)
471472
assert result.stderr == "gyarados gyarados gyarados"
473+
474+
475+
def test_pdb_uses_real_streams():
476+
"""``pdb.Pdb()`` inside ``CliRunner`` defaults to real terminal streams
477+
so that interactive debuggers work instead of reading from the
478+
captured ``BytesIO`` stdin.
479+
"""
480+
481+
@click.command()
482+
def cli():
483+
debugger = pdb.Pdb()
484+
assert debugger.stdin is sys.__stdin__
485+
assert debugger.stdout is sys.__stdout__
486+
click.echo("after debugger")
487+
488+
runner = CliRunner()
489+
result = runner.invoke(cli, catch_exceptions=False)
490+
assert result.output == "after debugger\n"
491+
492+
493+
def test_pdb_explicit_streams_honored():
494+
"""Explicit ``stdin``/``stdout`` arguments to ``pdb.Pdb()`` are not
495+
overridden by the ``CliRunner`` patch.
496+
"""
497+
498+
@click.command()
499+
def cli():
500+
custom_in = sys.stdin
501+
custom_out = sys.stdout
502+
debugger = pdb.Pdb(stdin=custom_in, stdout=custom_out)
503+
assert debugger.stdin is custom_in
504+
assert debugger.stdout is custom_out
505+
506+
runner = CliRunner()
507+
runner.invoke(cli, catch_exceptions=False)
508+
509+
510+
def test_pdb_init_restored_after_invoke():
511+
"""``pdb.Pdb.__init__`` is restored to its original after invoke."""
512+
original = pdb.Pdb.__init__
513+
514+
@click.command()
515+
def cli():
516+
pass
517+
518+
runner = CliRunner()
519+
runner.invoke(cli)
520+
521+
assert pdb.Pdb.__init__ is original

0 commit comments

Comments
 (0)